ffmpeg-skill 3.6.4 → 3.6.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/scripts/_common/asr.py +78 -39
- package/scripts/_common/color.py +4 -3
- package/scripts/_common/decision.py +21 -5
- package/scripts/_common/probe.py +38 -8
- package/scripts/_common/runner.py +73 -3
- package/scripts/_contract.py +2 -0
- package/scripts/audio.py +22 -2
- package/scripts/batch.py +40 -6
- package/scripts/caption.py +31 -17
- package/scripts/check.py +5 -1
- package/scripts/crop.py +3 -1
- package/scripts/cropdetect.py +2 -2
- package/scripts/cut.py +40 -9
- package/scripts/export.py +13 -8
- package/scripts/join.py +5 -5
- package/scripts/look.py +4 -4
- package/scripts/loudness.py +8 -3
- package/scripts/metadata.py +1 -1
- package/scripts/render.py +35 -5
- package/scripts/report.py +4 -2
- package/scripts/scenes.py +2 -2
- package/scripts/silence.py +12 -7
- package/scripts/stabilize.py +5 -2
- package/scripts/sync.py +7 -3
package/docs/contract.md
CHANGED
|
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
|
|
|
21
21
|
| Field | Meaning | Changes when |
|
|
22
22
|
|---|---|---|
|
|
23
23
|
| `contract_version` | shape of this document (`2.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
-
| `skill.version` | the npm / package.json version (`3.6.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.6.6`) | any release |
|
|
25
25
|
|
|
26
26
|
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
27
|
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
|
|
|
149
149
|
"deprecated": [],
|
|
150
150
|
"removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
|
|
151
151
|
{"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
|
|
152
|
-
"skill": {"id": "ffmpeg-skill", "version": "3.6.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.6.6", "execution_mode": "local", "kind": "execution",
|
|
153
153
|
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
154
154
|
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
155
155
|
"requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.6.
|
|
3
|
+
"version": "3.6.6",
|
|
4
4
|
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
5
5
|
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
|
|
6
6
|
"keywords": [
|
package/scripts/_common/asr.py
CHANGED
|
@@ -9,7 +9,7 @@ why it is its own file.
|
|
|
9
9
|
"""
|
|
10
10
|
from __future__ import annotations
|
|
11
11
|
|
|
12
|
-
import
|
|
12
|
+
import math
|
|
13
13
|
import os
|
|
14
14
|
import re
|
|
15
15
|
import unicodedata
|
|
@@ -18,7 +18,7 @@ from typing import Any, Dict, List, NamedTuple, Optional, Tuple
|
|
|
18
18
|
|
|
19
19
|
from _common.decision import _unspaced_script, fmt_srt_time, parse_time
|
|
20
20
|
from _common.emit import die, info, warn
|
|
21
|
-
from _common.runner import read_text_or_die
|
|
21
|
+
from _common.runner import parse_json, read_json, read_text_or_die
|
|
22
22
|
|
|
23
23
|
# The engines this skill knows how to drive, and how to install each -- one string, so every
|
|
24
24
|
# tool that needs a transcript refuses in the same words. The two Parakeet engines are
|
|
@@ -85,26 +85,60 @@ class Transcription:
|
|
|
85
85
|
f"facts={self.facts!r}, notes={self.notes!r})")
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
# A line that starts a cue even with no blank line before it: a clock time, "-->", a clock time,
|
|
89
|
+
# then nothing or whitespace (ffmpeg's position fields, X1:.. Y1:..). ffmpeg's SRT demuxer starts
|
|
90
|
+
# an event at every such line; the first line of a block holding "-->" is a timing line whatever
|
|
91
|
+
# its shape.
|
|
92
|
+
_SRT_CUE_START = re.compile(r"^\s*\d+:\d+[\d:.,]*\s*-->\s*\d+:\d+[\d:.,]*(\s|$)")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def srt_blocks(text: str) -> List[Tuple[str, List[str]]]:
|
|
96
|
+
"""(timing line, text lines) of every cue in SRT text whose line ends are already `\\n`.
|
|
97
|
+
|
|
98
|
+
A blank (or whitespace-only) line ends a cue, and the first line holding "-->" in a run of
|
|
99
|
+
lines is its timing line; the lines before it (the index) are not text. Inside a run, a later
|
|
100
|
+
line shaped like a timing line (_SRT_CUE_START) starts the next cue, and a bare number just
|
|
101
|
+
before it is that cue's index, so an SRT written without blank lines between cues reads as
|
|
102
|
+
ffmpeg reads it rather than as one cue. A later line that merely contains "-->" is text."""
|
|
103
|
+
out: List[Tuple[str, List[str]]] = []
|
|
104
|
+
for run in re.split(r"\n\s*\n", text):
|
|
105
|
+
lines = [ln for ln in run.split("\n") if ln.strip()]
|
|
106
|
+
first = next((i for i, ln in enumerate(lines) if "-->" in ln), None)
|
|
107
|
+
if first is None:
|
|
108
|
+
continue
|
|
109
|
+
timing, body = lines[first], []
|
|
110
|
+
for ln in lines[first + 1:]:
|
|
111
|
+
if _SRT_CUE_START.match(ln):
|
|
112
|
+
if body and body[-1].strip().isdigit():
|
|
113
|
+
body.pop()
|
|
114
|
+
out.append((timing, body))
|
|
115
|
+
timing, body = ln, []
|
|
116
|
+
else:
|
|
117
|
+
body.append(ln)
|
|
118
|
+
out.append((timing, body))
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def srt_timing_times(line: str) -> Tuple[str, str]:
|
|
123
|
+
"""The start and end tokens of an SRT timing line: what is before the first "-->", and the
|
|
124
|
+
first word after it. ffmpeg reads the two times and ignores the rest (position fields
|
|
125
|
+
`X1:.. X2:.. Y1:.. Y2:..`, or a stray second "-->")."""
|
|
126
|
+
a, _sep, b = line.partition("-->")
|
|
127
|
+
words = b.split()
|
|
128
|
+
return a.strip(), words[0] if words else ""
|
|
129
|
+
|
|
130
|
+
|
|
88
131
|
def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
89
132
|
cues: List[Tuple[float, float, str]] = []
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
times = next((b for b in block if "-->" in b), None)
|
|
98
|
-
if times:
|
|
99
|
-
a, b = times.split("-->")
|
|
100
|
-
text = "\n".join(block[block.index(times) + 1:]).strip()
|
|
101
|
-
try:
|
|
102
|
-
cues.append((parse_time(a), parse_time(b), text))
|
|
103
|
-
except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
|
|
104
|
-
die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
|
|
105
|
-
block = []
|
|
133
|
+
for times, body in srt_blocks(read_text_or_die(path, "--srt")):
|
|
134
|
+
a, b = srt_timing_times(times)
|
|
135
|
+
text = "\n".join(body).strip()
|
|
136
|
+
try:
|
|
137
|
+
cues.append((parse_time(a), parse_time(b), text))
|
|
138
|
+
except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
|
|
139
|
+
die(f"{path}: cannot read the timing line {times.strip()!r}: {e}", kind="input")
|
|
106
140
|
if not cues:
|
|
107
|
-
die(f"no cues found in {path}")
|
|
141
|
+
die(f"no cues found in {path}", kind="input")
|
|
108
142
|
return cues
|
|
109
143
|
|
|
110
144
|
|
|
@@ -204,8 +238,9 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
|
204
238
|
# line with two adjacent pipes ("a||b") becomes "a\n\nb" -- and writing that blank line
|
|
205
239
|
# raw would split one cue into two malformed half-blocks (the second missing its own
|
|
206
240
|
# index/timecode). Collapse any run of blank lines within the cue text to a single
|
|
207
|
-
# newline so the cue's own text can never fake the format's block boundary.
|
|
208
|
-
|
|
241
|
+
# newline so the cue's own text can never fake the format's block boundary. A line of
|
|
242
|
+
# only spaces or tabs is blank to every SRT reader too, so it goes the same way.
|
|
243
|
+
t = "\n".join(ln for ln in t.split("\n") if ln.strip())
|
|
209
244
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
210
245
|
# under batch.py: this SRT as written is the run's own (an edit made after it is not)
|
|
211
246
|
from _common.runner import report_committed
|
|
@@ -476,20 +511,24 @@ def whisper_word_timings(srt_path: Optional[str]) -> List[Tuple[float, float, st
|
|
|
476
511
|
if not os.path.exists(cand):
|
|
477
512
|
continue
|
|
478
513
|
try:
|
|
479
|
-
data =
|
|
514
|
+
data = read_json(cand)
|
|
480
515
|
except (OSError, ValueError):
|
|
481
516
|
continue
|
|
517
|
+
# the shape is not checked first: a segment that is a string or a list, or "segments" that
|
|
518
|
+
# is a number, is simply no words, as _dicts() reads every engine's JSON
|
|
482
519
|
raw = []
|
|
483
520
|
if isinstance(data, dict):
|
|
484
|
-
raw =
|
|
485
|
-
for seg in data.get("segments")
|
|
486
|
-
raw.extend((seg
|
|
521
|
+
raw = _dicts(data.get("words"))
|
|
522
|
+
for seg in _dicts(data.get("segments")):
|
|
523
|
+
raw.extend(_dicts(seg.get("words")))
|
|
487
524
|
words = []
|
|
488
525
|
for w in raw:
|
|
489
526
|
try:
|
|
490
527
|
text = str(w.get("word") or w.get("text") or "").strip()
|
|
491
|
-
|
|
492
|
-
|
|
528
|
+
start, end = float(w["start"]), float(w["end"])
|
|
529
|
+
# NaN / Infinity are valid to json.loads and place no word anywhere
|
|
530
|
+
if text and math.isfinite(start) and math.isfinite(end):
|
|
531
|
+
words.append((start, end, text))
|
|
493
532
|
except (AttributeError, KeyError, TypeError, ValueError):
|
|
494
533
|
continue
|
|
495
534
|
if words:
|
|
@@ -519,12 +558,12 @@ def _words_from_whisper_cpp_json(path: str) -> "List[Dict[str, Any]]":
|
|
|
519
558
|
boundaries). A token that is part of a character (U+FFFD) is still glued.
|
|
520
559
|
"""
|
|
521
560
|
try:
|
|
522
|
-
doc =
|
|
561
|
+
doc = read_json(path)
|
|
523
562
|
except (OSError, ValueError):
|
|
524
563
|
return []
|
|
525
564
|
out: "List[Dict[str, Any]]" = []
|
|
526
|
-
for seg in (doc.get("transcription")
|
|
527
|
-
for tok in (seg.get("tokens")
|
|
565
|
+
for seg in _dicts(doc.get("transcription")) if isinstance(doc, dict) else []:
|
|
566
|
+
for tok in _dicts(seg.get("tokens")):
|
|
528
567
|
text = str(tok.get("text") or "")
|
|
529
568
|
if not text.strip() or text.strip().startswith("[_"):
|
|
530
569
|
continue
|
|
@@ -552,7 +591,7 @@ def _whisper_json_language(path: str) -> Optional[str]:
|
|
|
552
591
|
"""The language a Whisper engine's JSON says it decoded: whisper.cpp's `result.language`, or
|
|
553
592
|
openai-whisper's top-level `language`. None when the file has neither."""
|
|
554
593
|
try:
|
|
555
|
-
doc =
|
|
594
|
+
doc = read_json(path) or {}
|
|
556
595
|
lang = str(((doc.get("result") or {}).get("language")) or doc.get("language") or "").strip().lower()
|
|
557
596
|
except (OSError, ValueError, AttributeError):
|
|
558
597
|
return None
|
|
@@ -568,12 +607,12 @@ def _heard_language(heard: list) -> Optional[str]:
|
|
|
568
607
|
def _words_from_openai_whisper_json(path: str) -> "List[Dict[str, Any]]":
|
|
569
608
|
"""openai-whisper --word_timestamps True --output_format json: segments[].words[]."""
|
|
570
609
|
try:
|
|
571
|
-
doc =
|
|
610
|
+
doc = read_json(path)
|
|
572
611
|
except (OSError, ValueError):
|
|
573
612
|
return []
|
|
574
613
|
out: "List[Dict[str, Any]]" = []
|
|
575
|
-
for seg in (doc.get("segments")
|
|
576
|
-
for w in (seg.get("words")
|
|
614
|
+
for seg in _dicts(doc.get("segments")) if isinstance(doc, dict) else []:
|
|
615
|
+
for w in _dicts(seg.get("words")):
|
|
577
616
|
try:
|
|
578
617
|
out.append({"word": str(w.get("word") or w.get("text") or "").strip(),
|
|
579
618
|
"start": float(w["start"]), "end": float(w["end"])})
|
|
@@ -592,7 +631,7 @@ def _words_from_parakeet_mlx_json(path: str) -> "List[Dict[str, Any]]":
|
|
|
592
631
|
after it starts a word too (" m" "is" "ter" " " "Q" "u" "il" "ter" -> "mister", "Quilter").
|
|
593
632
|
"""
|
|
594
633
|
try:
|
|
595
|
-
doc =
|
|
634
|
+
doc = read_json(path)
|
|
596
635
|
except (OSError, ValueError):
|
|
597
636
|
return []
|
|
598
637
|
out: "List[Dict[str, Any]]" = []
|
|
@@ -619,7 +658,7 @@ def _words_from_parakeet_mlx_json(path: str) -> "List[Dict[str, Any]]":
|
|
|
619
658
|
def _cues_from_parakeet_mlx_json(path: str) -> List[Tuple[float, float, str]]:
|
|
620
659
|
"""parakeet-mlx's own sentence segmentation, as cues."""
|
|
621
660
|
try:
|
|
622
|
-
doc =
|
|
661
|
+
doc = read_json(path)
|
|
623
662
|
except (OSError, ValueError):
|
|
624
663
|
return []
|
|
625
664
|
cues: List[Tuple[float, float, str]] = []
|
|
@@ -642,7 +681,7 @@ def _dicts(value: Any) -> "List[Dict[str, Any]]":
|
|
|
642
681
|
def _words_from_parakeet_cpp_json(text: str) -> "List[Dict[str, Any]]":
|
|
643
682
|
"""parakeet-cli transcribe --json: {"words": [{"w", "start", "end", "conf"}]}, times in seconds."""
|
|
644
683
|
try:
|
|
645
|
-
doc =
|
|
684
|
+
doc = parse_json(text)
|
|
646
685
|
except ValueError:
|
|
647
686
|
return []
|
|
648
687
|
out: "List[Dict[str, Any]]" = []
|
|
@@ -978,7 +1017,7 @@ def _heard_nothing(text: str, key: str) -> bool:
|
|
|
978
1017
|
Anything else that yields no words -- not JSON, the wrong shape, entries that do not parse --
|
|
979
1018
|
is output that could not be read, not a finding that nobody spoke."""
|
|
980
1019
|
try:
|
|
981
|
-
doc =
|
|
1020
|
+
doc = parse_json(text)
|
|
982
1021
|
except ValueError:
|
|
983
1022
|
return False
|
|
984
1023
|
return isinstance(doc, dict) and doc.get(key) == []
|
|
@@ -990,7 +1029,7 @@ def _whisper_heard_nothing(path: str, key: str) -> bool:
|
|
|
990
1029
|
no speech (#414). A document with text and no word timings is a build without them, not
|
|
991
1030
|
silence, and stays the "no word-level timings" refusal."""
|
|
992
1031
|
try:
|
|
993
|
-
doc =
|
|
1032
|
+
doc = read_json(path)
|
|
994
1033
|
except (OSError, ValueError):
|
|
995
1034
|
return False
|
|
996
1035
|
if not isinstance(doc, dict) or not isinstance(doc.get(key), list):
|
package/scripts/_common/color.py
CHANGED
|
@@ -74,7 +74,8 @@ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
|
|
|
74
74
|
_EVEN_PAD = "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0,"
|
|
75
75
|
_MATRIX_FILTER_RE = re.compile(r"^(?:(?:%s)?colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709(?:,crop=iw-[01]:ih-[01]:0:0:exact=1)?|(?:format=rgb24,)?%s)$"
|
|
76
76
|
% (re.escape(_EVEN_PAD), "|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
|
|
77
|
-
|
|
77
|
+
# [N:v], [N:v:0] and [N:V:0] (PROBED_VIDEO: the motion picture, not an attached cover)
|
|
78
|
+
_INPUT_VIDEO_REF = re.compile(r"\[(\d+):[vV](?::0)?\]")
|
|
78
79
|
|
|
79
80
|
|
|
80
81
|
def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
@@ -263,7 +264,7 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
|
|
|
263
264
|
|
|
264
265
|
- no other filter: unchanged (the -vf is the graph);
|
|
265
266
|
- a -vf: the conversion is prepended to that chain;
|
|
266
|
-
- a -filter_complex: every `[N:v]` / `[N:v:0]` reference to an input that reads the same file
|
|
267
|
+
- a -filter_complex: every `[N:v]` / `[N:v:0]` / `[N:V:0]` reference to an input that reads the same file
|
|
267
268
|
as input 0 (the file the encoder line was built for; cut.py's segment join opens it once
|
|
268
269
|
per segment) reads that input through the conversion instead. Other inputs are untouched.
|
|
269
270
|
|
|
@@ -298,7 +299,7 @@ def fold_bt709_matrix(cmd: Sequence[str]) -> List[str]:
|
|
|
298
299
|
graph = _INPUT_VIDEO_REF.sub(through, str(out[graph_at + 1]))
|
|
299
300
|
if not refs:
|
|
300
301
|
die("internal: a BT.709 matrix conversion was requested for a -filter_complex that never reads "
|
|
301
|
-
"input 0's video as [0:v]", kind="ffmpeg")
|
|
302
|
+
"input 0's video as [0:v] or [0:V:0]", kind="ffmpeg")
|
|
302
303
|
# one conversion per input, split when the graph reads that input more than once
|
|
303
304
|
# (speedramp.py trims [0:v] once per segment)
|
|
304
305
|
chains = [f"{ref}{conv}" + (f",split={len(labels)}" if len(labels) > 1 else "") + "".join(labels)
|
|
@@ -6,7 +6,6 @@ and encoder-selection rules testable on their own.
|
|
|
6
6
|
"""
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
-
import json
|
|
10
9
|
import math
|
|
11
10
|
import os
|
|
12
11
|
import re
|
|
@@ -17,6 +16,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
17
16
|
from _common.color import (as_limited, bt709_label_filter, bt709_matrix_args, bt709_matrix_filter, bt709_tag_args, drawn_stream_format,
|
|
18
17
|
is_neutral_color, limited_range_filter, rgb_island, rgb_to_bt709_filter, _sdr_bt709)
|
|
19
18
|
from _common.emit import die, record_fallback, refuse, warn
|
|
19
|
+
from _common.runner import read_json
|
|
20
20
|
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_list_error, ffmpeg_version, hw_platform_reason
|
|
21
21
|
|
|
22
22
|
|
|
@@ -1025,13 +1025,29 @@ def load_brand(path: Optional[str]) -> Dict[str, Any]:
|
|
|
1025
1025
|
if not path:
|
|
1026
1026
|
return brand
|
|
1027
1027
|
if not os.path.exists(path):
|
|
1028
|
-
die(f"brand file not found: {path}")
|
|
1028
|
+
die(f"brand file not found: {path}", kind="input")
|
|
1029
1029
|
try:
|
|
1030
|
-
data =
|
|
1031
|
-
except
|
|
1032
|
-
die(f"brand file
|
|
1030
|
+
data = read_json(path)
|
|
1031
|
+
except OSError as exc:
|
|
1032
|
+
die(f"brand file {path} cannot be read: {exc.strerror or exc}", kind="input")
|
|
1033
|
+
except ValueError as exc: # malformed, not UTF-8, or nested too deep (read_json)
|
|
1034
|
+
die(f"brand file is not valid JSON: {exc}", kind="input")
|
|
1035
|
+
if not isinstance(data, dict):
|
|
1036
|
+
die(f"brand file {path} must be a JSON object {{...}}, got {type(data).__name__}", kind="input")
|
|
1037
|
+
# the shapes the readers below and brand_caption_style() walk: a block that is not an object,
|
|
1038
|
+
# and a path that is not a string, were a traceback there
|
|
1039
|
+
for key, value in (*data.items(), ("styles.caption", (data.get("styles") or {}).get("caption")
|
|
1040
|
+
if isinstance(data.get("styles"), dict) else None)):
|
|
1041
|
+
if value is None:
|
|
1042
|
+
continue
|
|
1043
|
+
if (isinstance(BRAND_DEFAULTS.get(key), dict) or key == "styles.caption") and not isinstance(value, dict):
|
|
1044
|
+
die(f'brand file {path}: "{key}" must be an object {{...}}, got {type(value).__name__}', kind="input")
|
|
1045
|
+
if key in ("logo", "font_file", "font") and not isinstance(value, str):
|
|
1046
|
+
die(f'brand file {path}: "{key}" must be a string, got {type(value).__name__}', kind="input")
|
|
1033
1047
|
base = Path(path).resolve().parent
|
|
1034
1048
|
for k, v in data.items():
|
|
1049
|
+
if v is None and isinstance(brand.get(k), dict):
|
|
1050
|
+
continue # a null block keeps its defaults: readers walk it as an object (#483 review)
|
|
1035
1051
|
if isinstance(v, dict) and isinstance(brand.get(k), dict):
|
|
1036
1052
|
brand[k].update(v)
|
|
1037
1053
|
else:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -15,6 +15,14 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
|
15
15
|
from _common.emit import die, warn
|
|
16
16
|
from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
|
|
17
17
|
|
|
18
|
+
# The streams probe() describes, as stream specifiers: its `video` is the first picture that is not
|
|
19
|
+
# a cover (`V`), its `audio` the first sound stream. Without a -map, FFmpeg picks the largest
|
|
20
|
+
# picture and the sound with the default disposition or the most channels, so on a file with two
|
|
21
|
+
# of either a tool measured one stream and wrote another: loudness measured track 1 and applied the
|
|
22
|
+
# gain to track 0, cut and crop wrote the larger picture they never probed (A110, A118, A128, A258).
|
|
23
|
+
PROBED_VIDEO = "V:0"
|
|
24
|
+
PROBED_AUDIO = "a:0"
|
|
25
|
+
|
|
18
26
|
|
|
19
27
|
def fingerprint(path: str) -> Dict[str, Any]:
|
|
20
28
|
"""Size plus a sha256 over the first and last 8 MiB: enough to notice a re-export, a re-trim
|
|
@@ -50,7 +58,8 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
|
|
|
50
58
|
# first_pts=0 pads a sound that starts after the file's start (or after `start`) with silence,
|
|
51
59
|
# so sample i is at start + i / sample_rate on the file's clock: sync.py measured a track that
|
|
52
60
|
# starts 0.3 s late 0.279 s off, and --replace-audio placed it that far late (#409)
|
|
53
|
-
|
|
61
|
+
# the first audio track, the one probe() describes (default selection takes the most channels)
|
|
62
|
+
cmd += ["-map", f"0:{PROBED_AUDIO}", "-vn", "-af", "aresample=async=1:first_pts=0", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
|
|
54
63
|
proc = run_analysis(cmd, check=False, text=False)
|
|
55
64
|
if proc.returncode != 0 or not proc.stdout:
|
|
56
65
|
if check:
|
|
@@ -99,7 +108,7 @@ def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start:
|
|
|
99
108
|
cmd += ["-i", path]
|
|
100
109
|
if seconds is not None:
|
|
101
110
|
cmd += ["-t", f"{max(0.0, seconds):.3f}"]
|
|
102
|
-
cmd += ["-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
|
|
111
|
+
cmd += ["-map", f"0:{PROBED_VIDEO}", "-vf", f"fps={fps:g},scale={width}:{height}:flags=area,format=gray",
|
|
103
112
|
"-f", "rawvideo", "-"]
|
|
104
113
|
proc = run_analysis(cmd, check=False, text=False)
|
|
105
114
|
if proc.returncode != 0 or not proc.stdout:
|
|
@@ -422,6 +431,26 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
422
431
|
_CARRY: Dict[str, Dict[str, Any]] = {}
|
|
423
432
|
|
|
424
433
|
|
|
434
|
+
def probed_maps(meta: Optional[Dict[str, Any]], *, video: bool = True, audio: bool = True,
|
|
435
|
+
audio_stream: int = 0, input_index: int = 0) -> List[str]:
|
|
436
|
+
"""`-map` arguments for the picture and sound probe() describes in `meta` (audio track
|
|
437
|
+
`audio_stream` where a tool takes --audio-stream). A kind the file does not have is left out,
|
|
438
|
+
so the same call serves a silent clip and an audio-only file."""
|
|
439
|
+
out: List[str] = []
|
|
440
|
+
if video and (meta or {}).get("video"):
|
|
441
|
+
out += ["-map", f"{input_index}:{PROBED_VIDEO}"]
|
|
442
|
+
if audio and (meta or {}).get("audio"):
|
|
443
|
+
out += ["-map", f"{input_index}:a:{audio_stream}"]
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
def several_av_streams(meta: Optional[Dict[str, Any]]) -> bool:
|
|
448
|
+
"""True when FFmpeg's default stream selection could pick a picture or a sound other than the
|
|
449
|
+
one probe() describes: more than one playable video stream or more than one audio stream."""
|
|
450
|
+
counts = (meta or {}).get("stream_counts") or {}
|
|
451
|
+
return (counts.get("video") or 0) > 1 or (counts.get("audio") or 0) > 1
|
|
452
|
+
|
|
453
|
+
|
|
425
454
|
def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
|
|
426
455
|
"""The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
|
|
427
456
|
return _CARRY.get(os.path.realpath(path)) if path else None
|
|
@@ -628,10 +657,11 @@ def measured_level_dbfs(path: str, seconds: Optional[float] = 120.0,
|
|
|
628
657
|
audio_stream: Optional[int] = None) -> Optional[Dict[str, float]]:
|
|
629
658
|
"""Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
|
|
630
659
|
Cheap enough to run once as a hint when a threshold-based tool found nothing. seconds=None
|
|
631
|
-
measures the whole file; audio_stream picks the Nth audio stream (default:
|
|
660
|
+
measures the whole file; audio_stream picks the Nth audio stream (default: the first, the one
|
|
661
|
+
probe() describes; FFmpeg's own choice was the track with the most channels)."""
|
|
632
662
|
ffmpeg = require_tool("ffmpeg")
|
|
633
663
|
head = ["-t", f"{seconds:.0f}"] if seconds is not None else []
|
|
634
|
-
pick = ["-map", f"0:a:{audio_stream}"]
|
|
664
|
+
pick = ["-map", f"0:a:{audio_stream or 0}"]
|
|
635
665
|
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", *head, "-i", path, *pick, "-vn",
|
|
636
666
|
"-af", "volumedetect", "-f", "null", "-"], check=False)
|
|
637
667
|
m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
@@ -751,7 +781,7 @@ def measure_signal_range(path: str, standard: str, window: float = 10.0,
|
|
|
751
781
|
stats_file = os.path.join(tmp, "stats.txt")
|
|
752
782
|
vf = (f"signalstats=stat=brng,metadata=print:file={escape_filter_path(stats_file)},"
|
|
753
783
|
f"{mask},signalstats,metadata=print:file=-")
|
|
754
|
-
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", "0:
|
|
784
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an", "-sn", "-dn", "-vf", vf, "-f", "null", "-"]
|
|
755
785
|
proc = run_analysis(cmd, check=False, record=True)
|
|
756
786
|
try:
|
|
757
787
|
with open(stats_file, encoding="utf-8", errors="replace") as f:
|
|
@@ -804,7 +834,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
|
804
834
|
white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
|
|
805
835
|
"""
|
|
806
836
|
ffmpeg = require_tool("ffmpeg")
|
|
807
|
-
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
837
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an",
|
|
808
838
|
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
809
839
|
proc = run_analysis(cmd, check=False)
|
|
810
840
|
vals: Dict[str, List[float]] = {}
|
|
@@ -976,7 +1006,7 @@ def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tu
|
|
|
976
1006
|
if dry_run_input_pending(path):
|
|
977
1007
|
return []
|
|
978
1008
|
ffmpeg = require_tool("ffmpeg")
|
|
979
|
-
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
|
|
1009
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_AUDIO}", "-vn", "-af",
|
|
980
1010
|
f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
|
|
981
1011
|
proc = run_analysis(cmd, check=False, record=True)
|
|
982
1012
|
if proc.returncode != 0:
|
|
@@ -1188,7 +1218,7 @@ def detect_scenes(path: str, threshold: float, min_len: float, duration: float,
|
|
|
1188
1218
|
a real cut is a one-frame spike. On real footage this roughly doubles precision at
|
|
1189
1219
|
equal recall compared with the raw scdet threshold."""
|
|
1190
1220
|
ffmpeg = require_tool("ffmpeg")
|
|
1191
|
-
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
1221
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf",
|
|
1192
1222
|
"scale=320:-2,scdet=threshold=0,metadata=print:file=-", "-f", "null", "-"])
|
|
1193
1223
|
# No `sc_pass=1` on scdet: on FFmpeg 5.x that option means "pass only the frames whose
|
|
1194
1224
|
# score exceeds the threshold", so every truly static frame (score exactly 0 -- a title
|
|
@@ -2045,6 +2045,26 @@ def subtitle_container_holds(codec: str, out_ext: str) -> bool:
|
|
|
2045
2045
|
return True
|
|
2046
2046
|
|
|
2047
2047
|
|
|
2048
|
+
def default_selection_maps(meta: Optional[Dict[str, Any]], dst: str) -> List[str]:
|
|
2049
|
+
"""The -map arguments for a re-encode that would otherwise rely on ffmpeg's default stream
|
|
2050
|
+
selection: none for a file with one picture and one sound (the default picks those, and a
|
|
2051
|
+
subtitle stream the container can hold, as it always did), and with several, the picture
|
|
2052
|
+
and sound probe() describes plus the first subtitle stream when `dst`'s container can hold it
|
|
2053
|
+
-- what the default would have carried, without its largest-picture / most-channels choice
|
|
2054
|
+
(A258, #484 review)."""
|
|
2055
|
+
from _common.probe import probed_maps, several_av_streams # probe imports this module
|
|
2056
|
+
if not several_av_streams(meta):
|
|
2057
|
+
return []
|
|
2058
|
+
maps = probed_maps(meta)
|
|
2059
|
+
subs = (meta or {}).get("subtitle_stream_details") or []
|
|
2060
|
+
codec, ext = (subs[0].get("codec") or "") if subs else "", os.path.splitext(dst)[1]
|
|
2061
|
+
if subs and subtitle_container_holds(codec, ext):
|
|
2062
|
+
# with its own -c:s: the default subtitle encoder does not exist for every container
|
|
2063
|
+
# (none for MP4 in FFmpeg 6.1) and is a text encoder that cannot take a bitmap track
|
|
2064
|
+
maps += ["-map", "0:s:0", "-c:s", subtitle_codec_for(codec, ext)]
|
|
2065
|
+
return maps
|
|
2066
|
+
|
|
2067
|
+
|
|
2048
2068
|
def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool = False) -> bool:
|
|
2049
2069
|
"""Run an ffmpeg command that already maps its video/audio, trying first to also
|
|
2050
2070
|
stream-copy any subtitle/data streams the source has (`-map 0:s?`/`0:d?` are no-ops when
|
|
@@ -2487,15 +2507,27 @@ def _read_ff_list(flag: str, pattern: str) -> set:
|
|
|
2487
2507
|
return names
|
|
2488
2508
|
|
|
2489
2509
|
|
|
2510
|
+
_LINE_END = re.compile(r"\r*\n|\r")
|
|
2511
|
+
|
|
2512
|
+
|
|
2513
|
+
def normalize_text(text: str) -> str:
|
|
2514
|
+
"""A caller's text as every reader here takes it: a leading UTF-8 BOM dropped, and every line
|
|
2515
|
+
ending `\\n`. `\\r\\r\\n` is one line end, as ffmpeg's text demuxers read it (a CRLF file passed
|
|
2516
|
+
through a second CRLF conversion); a lone `\\r` (classic Mac) is one too. Only these: a line is
|
|
2517
|
+
never split at U+2028, U+0085, \\v or \\f, which str.splitlines() also splits at."""
|
|
2518
|
+
return _LINE_END.sub("\n", text.lstrip("\ufeff"))
|
|
2519
|
+
|
|
2520
|
+
|
|
2490
2521
|
def read_text_or_die(path: str, flag: str) -> str:
|
|
2491
2522
|
"""Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
|
|
2492
|
-
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback.
|
|
2523
|
+
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback. The text
|
|
2524
|
+
comes back through normalize_text(): no BOM, `\\n` line ends."""
|
|
2493
2525
|
if os.path.isdir(path):
|
|
2494
2526
|
# checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
|
|
2495
2527
|
die(f"{flag}: {path} is a directory, not a text file")
|
|
2496
2528
|
try:
|
|
2497
|
-
with open(path, "r", encoding="utf-8") as fh:
|
|
2498
|
-
return fh.read()
|
|
2529
|
+
with open(path, "r", encoding="utf-8", newline="") as fh:
|
|
2530
|
+
return normalize_text(fh.read())
|
|
2499
2531
|
except FileNotFoundError:
|
|
2500
2532
|
die(f"{flag}: {path} does not exist")
|
|
2501
2533
|
except IsADirectoryError:
|
|
@@ -2507,6 +2539,44 @@ def read_text_or_die(path: str, flag: str) -> str:
|
|
|
2507
2539
|
return "" # unreachable
|
|
2508
2540
|
|
|
2509
2541
|
|
|
2542
|
+
# Deeper than any project, recipe, brand, plan or transcript this skill reads (those are four or
|
|
2543
|
+
# five levels); a file nested past it is refused before code that walks it recursively
|
|
2544
|
+
# (copy.deepcopy, json.dumps) can raise RecursionError.
|
|
2545
|
+
JSON_MAX_DEPTH = 64
|
|
2546
|
+
|
|
2547
|
+
|
|
2548
|
+
class JsonTooDeep(ValueError):
|
|
2549
|
+
"""parse_json()'s refusal of a document nested past JSON_MAX_DEPTH: a ValueError, so callers
|
|
2550
|
+
that catch ValueError refuse it as malformed JSON; one that falls back on a non-JSON file (a
|
|
2551
|
+
media file) can tell this apart, since the file was JSON."""
|
|
2552
|
+
|
|
2553
|
+
|
|
2554
|
+
def parse_json(text: str) -> Any:
|
|
2555
|
+
"""json.loads for a caller's file: a BOM is dropped, and every way the text can fail to be a
|
|
2556
|
+
document this skill can walk raises ValueError -- malformed JSON (json's own ValueError),
|
|
2557
|
+
nesting too deep for the parser (RecursionError) or deeper than JSON_MAX_DEPTH. Callers that
|
|
2558
|
+
already catch ValueError therefore refuse all three as they refuse malformed JSON."""
|
|
2559
|
+
try:
|
|
2560
|
+
data = json.loads(text.lstrip("\ufeff"))
|
|
2561
|
+
except RecursionError:
|
|
2562
|
+
raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep") from None
|
|
2563
|
+
stack = [(data, 1)]
|
|
2564
|
+
while stack:
|
|
2565
|
+
value, depth = stack.pop()
|
|
2566
|
+
if isinstance(value, (dict, list)):
|
|
2567
|
+
if depth > JSON_MAX_DEPTH:
|
|
2568
|
+
raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep")
|
|
2569
|
+
stack.extend((v, depth + 1) for v in (value.values() if isinstance(value, dict) else value))
|
|
2570
|
+
return data
|
|
2571
|
+
|
|
2572
|
+
|
|
2573
|
+
def read_json(path: "os.PathLike[str] | str") -> Any:
|
|
2574
|
+
"""parse_json() of a UTF-8 file. Raises OSError for a file that cannot be read and ValueError
|
|
2575
|
+
(UnicodeDecodeError is one) for anything else, so `except (OSError, ValueError)` covers it."""
|
|
2576
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
2577
|
+
return parse_json(fh.read())
|
|
2578
|
+
|
|
2579
|
+
|
|
2510
2580
|
# Deferred to the foot of the module on purpose. runner is the first module the package loads and
|
|
2511
2581
|
# emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
|
|
2512
2582
|
# somewhere: by the time this line runs every name emit reads from runner above is defined, and
|
package/scripts/_contract.py
CHANGED
|
@@ -443,6 +443,8 @@ ERROR_REASONS = {
|
|
|
443
443
|
"plan_unknown_tool": "render.py PLAN: the plan names no tool render.py can run; details: {plan, plan_sha256, checked_at, tool, audit_log}",
|
|
444
444
|
"plan_cwd_missing": "render.py PLAN: the directory the plan was made in is gone; details: {plan, plan_sha256, checked_at, cwd, audit_log}",
|
|
445
445
|
"plan_argv_invalid": "render.py PLAN: argv is not a list; details: {plan, plan_sha256, checked_at, argv_type, audit_log}",
|
|
446
|
+
"plan_field_invalid": "render.py PLAN: inputs, unbound_inputs, verify (objects), commands (strings) or cwd has the wrong JSON type; "
|
|
447
|
+
"details: {plan, plan_sha256, checked_at, field, field_type, audit_log}",
|
|
446
448
|
"decoder_missing": "color.py / graphics.py / overlay.py: a VP9 source or --video layer stored with an alpha layer, on a build with no libvpx-vp9 decoder (FFmpeg's own VP9 decoder drops the alpha), kind missing_tool; details: {name}",
|
|
447
449
|
"encoder_missing": "--codec av1/prores: the build's encoder list was read and has no encoder for the codec (kind missing_tool; details: {codec, encoders}); or ffmpeg itself reported \"Unknown encoder\" for the encoder the command named (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
448
450
|
"asr_engine_missing": "kind missing_tool: --transcribe (caption.py; silence.py with --filler or a sentence flag) found no local speech-to-text engine; the message lists the install lines; details: {}",
|
package/scripts/audio.py
CHANGED
|
@@ -157,6 +157,22 @@ def find_silent_tracks(args: argparse.Namespace, problems: List[Dict[str, Any]])
|
|
|
157
157
|
return silent
|
|
158
158
|
|
|
159
159
|
|
|
160
|
+
def main_track_channels(args: argparse.Namespace, meta: Dict[str, Any]) -> int:
|
|
161
|
+
"""Channel count of the track audio.py processes: --replace's first audio track, else the
|
|
162
|
+
input's --audio-stream track; 0 when it is not known (a dry run's pending input)."""
|
|
163
|
+
if args.replace:
|
|
164
|
+
if dry_run_input_pending(args.replace) or not os.path.isfile(args.replace):
|
|
165
|
+
return 0
|
|
166
|
+
proc = run([require_tool("ffprobe"), "-v", "error", "-select_streams", "a:0", "-show_entries",
|
|
167
|
+
"stream=channels", "-of", "csv=p=0", args.replace], quiet=True, check=False)
|
|
168
|
+
first = (proc.stdout or "").strip().splitlines()[:1]
|
|
169
|
+
return int(first[0]) if first and first[0].strip().isdigit() else 0
|
|
170
|
+
streams = meta.get("audio_streams") or []
|
|
171
|
+
if 0 <= args.audio_stream < len(streams):
|
|
172
|
+
return streams[args.audio_stream].get("channels") or 0
|
|
173
|
+
return (meta.get("audio") or {}).get("channels") or 0
|
|
174
|
+
|
|
175
|
+
|
|
160
176
|
def main() -> int:
|
|
161
177
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
162
178
|
ap.add_argument("input")
|
|
@@ -279,7 +295,10 @@ def main() -> int:
|
|
|
279
295
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
280
296
|
if args.audio_stream and not streams and not STATE.dry_run:
|
|
281
297
|
die("--audio-stream needs an input with audio streams")
|
|
282
|
-
|
|
298
|
+
# the channels of the track the graph processes (main_src below): the --audio-stream track,
|
|
299
|
+
# or --replace's first track. meta["audio"] is the input's first track, so a mono a:0 left a
|
|
300
|
+
# stereo --audio-stream 1 unfolded by --mono, and a stereo a:0 halved a mono one (A128)
|
|
301
|
+
in_channels = main_track_channels(args, meta)
|
|
283
302
|
if args.stereo_widen is not None:
|
|
284
303
|
if args.mono:
|
|
285
304
|
die("--stereo-widen and --mono contradict each other: there is no stereo image in a 1-channel output")
|
|
@@ -324,7 +343,8 @@ def main() -> int:
|
|
|
324
343
|
if args.limit:
|
|
325
344
|
fx.append(dynamics_filter("alimiter", args))
|
|
326
345
|
if args.mono:
|
|
327
|
-
|
|
346
|
+
# after --downmix the track is the stereo fold-down, whatever the source had
|
|
347
|
+
in_ch = 2 if args.downmix else (in_channels or 2)
|
|
328
348
|
if in_ch == 2:
|
|
329
349
|
fx.append("pan=mono|c0=0.5*c0+0.5*c1")
|
|
330
350
|
elif in_ch > 2:
|