ffmpeg-skill 3.8.7 → 3.8.9
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/README.md +1 -1
- package/docs/contract.md +15 -4
- package/package.json +1 -1
- package/references/gotchas.md +5 -3
- package/references/scripts.md +69 -17
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +28 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +146 -5
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +19 -11
- package/scripts/batch.py +91 -8
- package/scripts/caption.py +39 -3
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +15 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +232 -39
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
- package/templates/audiogram.json +9 -0
|
@@ -1048,7 +1048,7 @@ _CODEC_OPTION_RE = re.compile(r"^-(?:c|codec)(?::([va])(?::\d+)?)?$|^-(v|a)codec
|
|
|
1048
1048
|
|
|
1049
1049
|
|
|
1050
1050
|
def _an_input_has(cmd: Sequence[str], kind: str) -> bool:
|
|
1051
|
-
"""False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a")
|
|
1051
|
+
"""False only when every input of `cmd` is a file that was read and holds no `kind` ("v"/"a"/"s")
|
|
1052
1052
|
stream, and no filter graph could make one. An encoder option for a stream the output will
|
|
1053
1053
|
not have selects nothing (FFmpeg applies -c:a after stream selection), so it is not a reason
|
|
1054
1054
|
to refuse: cut.py names -c:a aac for a silent source too. Anything not known counts as present."""
|
|
@@ -1110,6 +1110,120 @@ def _check_container_codecs(cmd: Sequence[str]) -> None:
|
|
|
1110
1110
|
output=output, container="webm", encoders=refused)
|
|
1111
1111
|
|
|
1112
1112
|
|
|
1113
|
+
# the options whose value is a filter graph this module can read the filter names out of
|
|
1114
|
+
_GRAPH_OPTIONS = frozenset({"-vf", "-af", "-filter:v", "-filter:a", "-filter_complex", "-lavfi"})
|
|
1115
|
+
_ENCODER_OPTION_RE = re.compile(r"^-(?:c|codec):([vas])(?::\d+)?$|^-([vas])codec$")
|
|
1116
|
+
# the output option that drops every stream of a kind: an encoder named for that kind selects nothing
|
|
1117
|
+
_NO_STREAM_OPTION = {"v": "-vn", "a": "-an", "s": "-sn"}
|
|
1118
|
+
# one row of `ffmpeg -encoders` / `ffmpeg -filters` (the filter row by its io-spec token, so the
|
|
1119
|
+
# flag-column width of FFmpeg 6/7 and 8+ does not matter): ffmpeg_encoders(), ffmpeg_filters()
|
|
1120
|
+
_ENCODER_ROW = r"^\s*[VAS][.\w]{5}\s+(\S+)"
|
|
1121
|
+
_FILTER_ROW = r"^\s*(?:[A-Z.|]{1,6}\s+)?([A-Za-z0-9_]+)\s+\S*->\S*"
|
|
1122
|
+
# quiet reads of a list the tool itself never asked for: {"-encoders": names, "-filters": names}
|
|
1123
|
+
_PREFLIGHT_LISTS: Dict[str, set] = {}
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def graph_filter_names(graph: str) -> List[str]:
|
|
1127
|
+
"""The filter names of one filter graph string, in order, without duplicates. A `,` or `;`
|
|
1128
|
+
separates filters only outside '...' quotes and when no backslash escapes it; `[labels]` before
|
|
1129
|
+
a filter and its `=options` / `@instance` are dropped. A graph-level `sws_flags=` is not a
|
|
1130
|
+
filter and is left out, as is anything that is not a plain identifier."""
|
|
1131
|
+
parts: List[str] = []
|
|
1132
|
+
cur: List[str] = []
|
|
1133
|
+
quoted = False
|
|
1134
|
+
i = 0
|
|
1135
|
+
while i < len(graph):
|
|
1136
|
+
ch = graph[i]
|
|
1137
|
+
if ch == "\\" and i + 1 < len(graph):
|
|
1138
|
+
cur.append(graph[i:i + 2])
|
|
1139
|
+
i += 2
|
|
1140
|
+
continue
|
|
1141
|
+
if ch == "'":
|
|
1142
|
+
quoted = not quoted
|
|
1143
|
+
elif ch in ",;" and not quoted:
|
|
1144
|
+
parts.append("".join(cur))
|
|
1145
|
+
cur = []
|
|
1146
|
+
i += 1
|
|
1147
|
+
continue
|
|
1148
|
+
cur.append(ch)
|
|
1149
|
+
i += 1
|
|
1150
|
+
parts.append("".join(cur))
|
|
1151
|
+
names: List[str] = []
|
|
1152
|
+
for part in parts:
|
|
1153
|
+
part = part.strip()
|
|
1154
|
+
while part.startswith("["):
|
|
1155
|
+
end = part.find("]")
|
|
1156
|
+
if end < 0:
|
|
1157
|
+
break
|
|
1158
|
+
part = part[end + 1:].lstrip()
|
|
1159
|
+
m = re.match(r"[A-Za-z0-9_]+", part)
|
|
1160
|
+
if not m or m.end() < len(part) and part[m.end()] not in "=@[ \t":
|
|
1161
|
+
continue
|
|
1162
|
+
if m.group(0) != "sws_flags" and m.group(0) not in names:
|
|
1163
|
+
names.append(m.group(0))
|
|
1164
|
+
return names
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
def _build_list(flag: str) -> set:
|
|
1168
|
+
"""This build's encoder or filter names for the check below: the list a tool already read, else
|
|
1169
|
+
one quiet read. A list that cannot be read is empty here and refuses nothing; the
|
|
1170
|
+
`ffmpeg_list_unreadable` warning stays with the tools that decide something on the list."""
|
|
1171
|
+
cached = _ENCODERS if flag == "-encoders" else _FILTERS
|
|
1172
|
+
if cached is not None:
|
|
1173
|
+
return cached
|
|
1174
|
+
if flag not in _PREFLIGHT_LISTS:
|
|
1175
|
+
_PREFLIGHT_LISTS[flag] = _read_ff_list(flag, _ENCODER_ROW if flag == "-encoders" else _FILTER_ROW, quiet=True)
|
|
1176
|
+
return _PREFLIGHT_LISTS[flag]
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def _check_build_components(cmd: Sequence[str]) -> None:
|
|
1180
|
+
"""An encoder or a filter the command names and this ffmpeg build does not have is refused
|
|
1181
|
+
before anything runs, dry runs included, with the kind, reason and hint the run got from
|
|
1182
|
+
ffmpeg's "Unknown encoder" / "No such filter" (#402): the dry run said completed, and a real
|
|
1183
|
+
run found out after its earlier commands had run. Read from the command as built, so every tool
|
|
1184
|
+
and every route to an encoder (an HDR source kept as HEVC, --codec, a preset) is covered.
|
|
1185
|
+
Only output options are read (a -c:v before an -i picks a decoder), and an encoder only for a
|
|
1186
|
+
stream kind the output keeps: not one -an/-vn/-sn drops or no input file carries. A VideoToolbox encoder is
|
|
1187
|
+
left to --hw's own checks and its CPU retry, a -filter_complex_script and a lavfi input are not
|
|
1188
|
+
parsed, and a list that could not be read refuses nothing."""
|
|
1189
|
+
last_input = max((i for i, tok in enumerate(cmd[:-1]) if tok == "-i"), default=-1)
|
|
1190
|
+
encoders: List[str] = []
|
|
1191
|
+
streams: Dict[str, set] = {} # encoder -> the stream kinds ("v", "a", "s") it is named for
|
|
1192
|
+
filters: List[str] = []
|
|
1193
|
+
output_opts = {str(tok) for tok in cmd[last_input + 2:-1]}
|
|
1194
|
+
for i in range(1, len(cmd) - 1):
|
|
1195
|
+
tok, value = str(cmd[i]), str(cmd[i + 1])
|
|
1196
|
+
m = _ENCODER_OPTION_RE.match(tok)
|
|
1197
|
+
if tok in _GRAPH_OPTIONS:
|
|
1198
|
+
filters += [n for n in graph_filter_names(value) if n not in filters]
|
|
1199
|
+
elif i > last_input and m and value != "copy" and not value.endswith("_videotoolbox"):
|
|
1200
|
+
if value not in encoders:
|
|
1201
|
+
encoders.append(value)
|
|
1202
|
+
streams.setdefault(value, set()).add(m.group(1) or m.group(2))
|
|
1203
|
+
|
|
1204
|
+
def selected(name: str) -> bool:
|
|
1205
|
+
# ffmpeg picks an encoder per output stream, after stream selection: one named for a kind
|
|
1206
|
+
# the output drops (-an/-vn/-sn) or that no input carries (cut.py names -c:a aac for a
|
|
1207
|
+
# silent source too) is never opened. What the command cannot tell (a -filter_complex graph, a
|
|
1208
|
+
# lavfi or unreadable input) counts as selected; explicit -map lists are not read.
|
|
1209
|
+
return any(_NO_STREAM_OPTION[k] not in output_opts and _an_input_has(cmd, k) for k in streams.get(name, ()))
|
|
1210
|
+
|
|
1211
|
+
for flag, wanted, what, reason in (("-encoders", encoders, "encoder", "encoder_missing"),
|
|
1212
|
+
("-filters", filters, "filter", "filter_missing")):
|
|
1213
|
+
have = _build_list(flag) if wanted else set()
|
|
1214
|
+
missing = [n for n in wanted if n not in have] if have else []
|
|
1215
|
+
if what == "encoder":
|
|
1216
|
+
missing = [n for n in missing if selected(n)] # asked last: only an encoder that would be refused is probed for
|
|
1217
|
+
if missing:
|
|
1218
|
+
name = missing[0]
|
|
1219
|
+
hint = filter_missing_hint(name) if what == "filter" else filter_missing_hint(name).replace(
|
|
1220
|
+
f"no {name} filter", f"no {name} encoder", 1)
|
|
1221
|
+
die(f"this ffmpeg build has no {name} {what}, which the command this run builds needs"
|
|
1222
|
+
+ (f" (also missing: {', '.join(missing[1:])})" if missing[1:] else ""),
|
|
1223
|
+
code=1, kind="ffmpeg", hint=hint,
|
|
1224
|
+
error_extra={"reason": reason, "details": {"name": name}})
|
|
1225
|
+
|
|
1226
|
+
|
|
1113
1227
|
EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
|
|
1114
1228
|
|
|
1115
1229
|
|
|
@@ -1904,6 +2018,7 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
1904
2018
|
_check_no_overwrite_input(cmd)
|
|
1905
2019
|
_check_output_path(cmd)
|
|
1906
2020
|
_check_container_codecs(cmd)
|
|
2021
|
+
_check_build_components(cmd)
|
|
1907
2022
|
_check_existing_output(cmd)
|
|
1908
2023
|
ctx.commands.append(_cmdline(cmd))
|
|
1909
2024
|
if not quiet:
|
|
@@ -2128,6 +2243,16 @@ def _has_non_av_streams(cmd: Sequence[str]) -> bool:
|
|
|
2128
2243
|
return not kinds or bool(kinds & {"subtitle", "data", "attachment"})
|
|
2129
2244
|
|
|
2130
2245
|
|
|
2246
|
+
def ffmpeg_failure_extra(stderr: str) -> Dict[str, Any]:
|
|
2247
|
+
"""The die() keywords that carry the cause ffmpeg named in `stderr` -- a missing encoder or
|
|
2248
|
+
filter, a full disk, an unreadable input -- as `hint` and the error's `reason`/`details`, or {}
|
|
2249
|
+
when it named none (#402): a tool that composes its own failure message (cut.py's re-encode, a
|
|
2250
|
+
measurement pass) reports the cause as run()'s own failures do. Used as
|
|
2251
|
+
`die(msg, kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))`, through the caller's own die."""
|
|
2252
|
+
cls = classify_ffmpeg_failure(stderr or "")
|
|
2253
|
+
return {"hint": cls[1], "error_extra": {"reason": cls[0], "details": cls[2]}} if cls else {}
|
|
2254
|
+
|
|
2255
|
+
|
|
2131
2256
|
def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
|
|
2132
2257
|
"""Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
|
|
2133
2258
|
the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
|
|
@@ -2543,7 +2668,7 @@ def ffmpeg_encoders() -> set:
|
|
|
2543
2668
|
refusal or a CPU note built on the empty set names the read failure, not a missing encoder."""
|
|
2544
2669
|
global _ENCODERS
|
|
2545
2670
|
if _ENCODERS is None:
|
|
2546
|
-
_ENCODERS = _read_ff_list("-encoders",
|
|
2671
|
+
_ENCODERS = _read_ff_list("-encoders", _ENCODER_ROW)
|
|
2547
2672
|
return _ENCODERS
|
|
2548
2673
|
|
|
2549
2674
|
|
|
@@ -2565,7 +2690,7 @@ def ffmpeg_filters() -> set:
|
|
|
2565
2690
|
flag-column width of FFmpeg 6/7 (three) and 8+ (two) does not matter."""
|
|
2566
2691
|
global _FILTERS
|
|
2567
2692
|
if _FILTERS is None:
|
|
2568
|
-
_FILTERS = _read_ff_list("-filters",
|
|
2693
|
+
_FILTERS = _read_ff_list("-filters", _FILTER_ROW)
|
|
2569
2694
|
return _FILTERS
|
|
2570
2695
|
|
|
2571
2696
|
|
|
@@ -2605,12 +2730,13 @@ def ffmpeg_list_error(flag: str) -> Optional[str]:
|
|
|
2605
2730
|
return None if cache else _LIST_ERRORS.get(flag)
|
|
2606
2731
|
|
|
2607
2732
|
|
|
2608
|
-
def _read_ff_list(flag: str, pattern: str) -> set:
|
|
2733
|
+
def _read_ff_list(flag: str, pattern: str, quiet: bool = False) -> set:
|
|
2609
2734
|
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
2610
2735
|
# --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
|
|
2611
2736
|
# (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
|
|
2612
2737
|
limit = _probe_limit()
|
|
2613
|
-
|
|
2738
|
+
if not quiet:
|
|
2739
|
+
_LIST_ERRORS.pop(flag, None)
|
|
2614
2740
|
names: set = set()
|
|
2615
2741
|
why = None
|
|
2616
2742
|
try:
|
|
@@ -2625,6 +2751,8 @@ def _read_ff_list(flag: str, pattern: str) -> set:
|
|
|
2625
2751
|
why = f"{tool} {flag} did not answer within {limit:g} s"
|
|
2626
2752
|
except (OSError, subprocess.SubprocessError) as exc:
|
|
2627
2753
|
why = f"{tool} {flag} could not run: {exc}"
|
|
2754
|
+
if why and quiet:
|
|
2755
|
+
return set()
|
|
2628
2756
|
if why:
|
|
2629
2757
|
_LIST_ERRORS[flag] = why
|
|
2630
2758
|
what = {"-encoders": "encoder", "-decoders": "decoder", "-bsfs": "bitstream filter"}.get(flag, "filter")
|
|
@@ -2692,10 +2820,23 @@ def parse_json(text: str) -> Any:
|
|
|
2692
2820
|
if isinstance(value, (dict, list)):
|
|
2693
2821
|
if depth > JSON_MAX_DEPTH:
|
|
2694
2822
|
raise JsonTooDeep(f"nested more than {JSON_MAX_DEPTH} levels deep")
|
|
2823
|
+
if isinstance(value, dict):
|
|
2824
|
+
stack.extend((k, depth) for k in value)
|
|
2695
2825
|
stack.extend((v, depth + 1) for v in (value.values() if isinstance(value, dict) else value))
|
|
2826
|
+
elif isinstance(value, str) and _SURROGATE.search(value):
|
|
2827
|
+
# A264: "\ud83d" alone is valid JSON and no text: json reads it as a lone surrogate,
|
|
2828
|
+
# which no UTF-8 file, argument or stdout can encode -- the run died on the first write
|
|
2829
|
+
# with a UnicodeEncodeError traceback and nothing on stdout
|
|
2830
|
+
bad = _SURROGATE.search(value).group(0)
|
|
2831
|
+
raise ValueError(f"the string \"{value[:60].encode('ascii', 'backslashreplace').decode()}\" holds \\u{ord(bad):04x}, "
|
|
2832
|
+
"half of a UTF-16 surrogate pair with no other half: it is no character, so no file can "
|
|
2833
|
+
"hold it -- write the character itself, or both halves of its \\u escape")
|
|
2696
2834
|
return data
|
|
2697
2835
|
|
|
2698
2836
|
|
|
2837
|
+
_SURROGATE = re.compile("[\ud800-\udfff]")
|
|
2838
|
+
|
|
2839
|
+
|
|
2699
2840
|
def read_json(path: "os.PathLike[str] | str") -> Any:
|
|
2700
2841
|
"""parse_json() of a UTF-8 file. Raises OSError for a file that cannot be read and ValueError
|
|
2701
2842
|
(UnicodeDecodeError is one) for anything else, so `except (OSError, ValueError)` covers it."""
|
|
@@ -142,11 +142,15 @@ def start_tc_frames(tc: Dict[str, Any], rate: Fraction) -> int:
|
|
|
142
142
|
|
|
143
143
|
def _tc_label(frames: int, rate: Fraction, drop: bool) -> str:
|
|
144
144
|
"""`frames` as hh:mm:ss:ff at the nominal integer rate; drop-frame (`;`) for an NTSC source
|
|
145
|
-
whose own timecode is drop-frame, so the label is the one the media itself shows.
|
|
145
|
+
whose own timecode is drop-frame, so the label is the one the media itself shows. Timecode
|
|
146
|
+
wraps at 24 hours as the media's own does (output_timecode_args() counts the same day): a
|
|
147
|
+
source starting at 23:59:50:00 is labelled 00:00:00:00 ten seconds in, not 24:00:00:00, which
|
|
148
|
+
no editor reads (A266)."""
|
|
146
149
|
nominal = int(round(float(rate)))
|
|
147
150
|
if not _drop_frame(drop, rate):
|
|
148
|
-
return _tc(frames, nominal)
|
|
151
|
+
return _tc(frames % (nominal * 86400), nominal) if nominal >= 1 else _tc(frames, nominal)
|
|
149
152
|
d = nominal // 15
|
|
153
|
+
frames %= (nominal * 600 - d * 9) * 144
|
|
150
154
|
per_10, per_min = nominal * 600 - d * 9, nominal * 60 - d
|
|
151
155
|
tens, rem = divmod(frames, per_10)
|
|
152
156
|
frames += d * 9 * tens + (d * ((rem - d) // per_min) if rem > d else 0)
|
|
@@ -376,6 +380,19 @@ def _tc(frames: int, fps_int: int) -> str:
|
|
|
376
380
|
return f"{h:02d}:{m:02d}:{s:02d}:{f:02d}"
|
|
377
381
|
|
|
378
382
|
|
|
383
|
+
# CMX 3600 numbers events in a three-digit field, and record timecode runs from 01:00:00:00 within
|
|
384
|
+
# one 24-hour day.
|
|
385
|
+
EDL_MAX_EVENTS = 999
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _edl_text(value: Any) -> str:
|
|
389
|
+
"""Caller text (a file name, a clip or chapter name, the title) on one EDL line. The format is
|
|
390
|
+
one statement per line with no escape, so a line break -- any str.splitlines() boundary -- or
|
|
391
|
+
another control character in a name added lines, or whole events, that parse_error() then
|
|
392
|
+
accepted (A261). Each becomes a space."""
|
|
393
|
+
return "".join(" " if (ch in "\x85\u2028\u2029" or ord(ch) < 32 or ord(ch) == 127) else ch for ch in str(value))
|
|
394
|
+
|
|
395
|
+
|
|
379
396
|
def to_edl(tl: Dict[str, Any]) -> str:
|
|
380
397
|
"""CMX 3600, non-drop timecode at the nominal integer rate, record starting at 01:00:00:00.
|
|
381
398
|
Every event is reel AX (the convention for file-based media) with the file named in
|
|
@@ -386,16 +403,24 @@ def to_edl(tl: Dict[str, Any]) -> str:
|
|
|
386
403
|
from where the previous one stopped to where the next dissolve begins."""
|
|
387
404
|
fps_int = int(round(float(tl["rate"])))
|
|
388
405
|
hour = fps_int * 3600
|
|
406
|
+
rate = tl["rate"]
|
|
389
407
|
clips = tl["clips"]
|
|
390
408
|
d = tl["transition_frames"]
|
|
391
409
|
# record range of each event: starts `trim_head` before its visible start (the dissolve
|
|
392
410
|
# window), ends where the next event starts
|
|
393
411
|
starts = [c["record_in"] - c["trim_head"] for c in clips]
|
|
394
412
|
ends = starts[1:] + [tl["total"]]
|
|
395
|
-
|
|
413
|
+
events = len(clips) + (1 if tl["music"] else 0)
|
|
414
|
+
if events > EDL_MAX_EVENTS:
|
|
415
|
+
# A265: f"{n:03d}" wrote event 1000 as four digits, outside the field
|
|
416
|
+
raise TimelineError(f"an EDL numbers its events in three digits (at most {EDL_MAX_EVENTS}); this timeline has "
|
|
417
|
+
f"{events} -- export .fcpxml or .otio, or split the project")
|
|
418
|
+
if hour + tl["total"] > fps_int * 86400:
|
|
419
|
+
raise TimelineError(f"the timeline ({float(Fraction(tl['total']) / rate):.0f}s) runs past 24:00:00:00 when the "
|
|
420
|
+
"EDL's record starts at 01:00:00:00 -- export .fcpxml or .otio, or split the project")
|
|
421
|
+
lines = [f"TITLE: {_edl_text(tl['name'])}", "FCM: NON-DROP FRAME", ""]
|
|
396
422
|
n = 0
|
|
397
423
|
prev_src_end = ""
|
|
398
|
-
rate = tl["rate"]
|
|
399
424
|
|
|
400
425
|
def src_tc(c: Dict[str, Any], seq_frames: int) -> str:
|
|
401
426
|
"""The source timecode `seq_frames` (sequence frames of source media) after the clip's in
|
|
@@ -415,13 +440,13 @@ def to_edl(tl: Dict[str, Any]) -> str:
|
|
|
415
440
|
f"{_tc(rec_in, fps_int)} {_tc(rec_in, fps_int)}")
|
|
416
441
|
lines.append(f"{n:03d} AX {chan:<5} D {d:03d} {s_in} {s_out} "
|
|
417
442
|
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
418
|
-
lines.append(f"* FROM CLIP NAME: {clips[i - 1]['name']}")
|
|
419
|
-
lines.append(f"* TO CLIP NAME: {c['name']}")
|
|
443
|
+
lines.append(f"* FROM CLIP NAME: {_edl_text(clips[i - 1]['name'])}")
|
|
444
|
+
lines.append(f"* TO CLIP NAME: {_edl_text(c['name'])}")
|
|
420
445
|
else:
|
|
421
446
|
lines.append(f"{n:03d} AX {chan:<5} C {s_in} {s_out} "
|
|
422
447
|
f"{_tc(rec_in, fps_int)} {_tc(rec_out, fps_int)}")
|
|
423
|
-
lines.append(f"* FROM CLIP NAME: {c['name']}")
|
|
424
|
-
lines.append(f"* SOURCE FILE: {c['src']}")
|
|
448
|
+
lines.append(f"* FROM CLIP NAME: {_edl_text(c['name'])}")
|
|
449
|
+
lines.append(f"* SOURCE FILE: {_edl_text(c['src'])}")
|
|
425
450
|
if abs(c["speed"] - 1.0) > 1e-6:
|
|
426
451
|
# the speed in source frames per second, at the rate the source labels are counted in
|
|
427
452
|
own_fps = int(round(float(c.get("rate") or rate)))
|
|
@@ -433,11 +458,11 @@ def to_edl(tl: Dict[str, Any]) -> str:
|
|
|
433
458
|
m = tl["music"]
|
|
434
459
|
lines.append(f"{n:03d} AX A2 C {_tc(0, fps_int)} {_tc(m['length'], fps_int)} "
|
|
435
460
|
f"{_tc(hour, fps_int)} {_tc(hour + m['length'], fps_int)}")
|
|
436
|
-
lines.append(f"* FROM CLIP NAME: {m['name']}")
|
|
437
|
-
lines.append(f"* SOURCE FILE: {m['src']}")
|
|
461
|
+
lines.append(f"* FROM CLIP NAME: {_edl_text(m['name'])}")
|
|
462
|
+
lines.append(f"* SOURCE FILE: {_edl_text(m['src'])}")
|
|
438
463
|
lines.append("")
|
|
439
464
|
for mk in tl["markers"]:
|
|
440
|
-
lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {mk['title']}")
|
|
465
|
+
lines.append(f"* LOC: {_tc(hour + mk['at'], fps_int)} WHITE {_edl_text(mk['title'])}")
|
|
441
466
|
return "\n".join(lines).rstrip("\n") + "\n"
|
|
442
467
|
|
|
443
468
|
|
|
@@ -459,6 +484,14 @@ def _url(path: str) -> str:
|
|
|
459
484
|
return Path(os.path.abspath(path)).as_uri()
|
|
460
485
|
|
|
461
486
|
|
|
487
|
+
def _xml_text(value: Any) -> str:
|
|
488
|
+
"""Caller text as XML 1.0 can hold it: a control character other than tab, line feed and
|
|
489
|
+
carriage return (and U+FFFE/U+FFFF) has no representation at all, not even escaped, so a clip
|
|
490
|
+
or chapter name carrying one made the FCPXML not well-formed (the sibling of A261). Each
|
|
491
|
+
becomes a space."""
|
|
492
|
+
return "".join(" " if ((ord(ch) < 32 and ch not in "\t\n\r") or ch in "\ufffe\uffff") else ch for ch in str(value))
|
|
493
|
+
|
|
494
|
+
|
|
462
495
|
def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
463
496
|
"""FCPXML 1.10 (Final Cut Pro 10.6+, DaVinci Resolve): one library/event/project, a spine of
|
|
464
497
|
asset-clips with cross dissolves between them, the music bed as a connected clip on lane -1
|
|
@@ -466,7 +499,7 @@ def to_fcpxml(tl: Dict[str, Any]) -> str:
|
|
|
466
499
|
sequence has its own format (r1); each video asset has its source's, and a clip of another
|
|
467
500
|
aspect states its spatial conform (adjust-conform) for frame.fit."""
|
|
468
501
|
rate = tl["rate"]
|
|
469
|
-
q = lambda s: _xml_escape(
|
|
502
|
+
q = lambda s: _xml_escape(_xml_text(s), {'"': """}) # noqa: E731
|
|
470
503
|
fd = _t(1, rate)
|
|
471
504
|
res: List[str] = []
|
|
472
505
|
ids: Dict[Any, str] = {} # a format's (width, height, frameDuration) or an asset's path -> its id
|
package/scripts/_contract.py
CHANGED
|
@@ -105,7 +105,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
105
105
|
optional=[HDR_X265, {"capability": "filter:delogo", "when": "--mode delogo"}],
|
|
106
106
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
107
107
|
"waveform": dict(role="execution", inputs=["audio or video asset (audio track only)"], outputs=["generated waveform or spectrum visualization video artifact"],
|
|
108
|
-
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"}
|
|
108
|
+
required=FF + [X264, AAC, "filter:showwaves"], optional=[{"capability": "filter:showspectrum", "when": "--style spectrum"},
|
|
109
|
+
{"capability": "filter:drawtext", "when": "--title (graphics.py draws it)"},
|
|
110
|
+
{"capability": "filter:subtitles", "when": "--srt (caption.py burns it)"}],
|
|
109
111
|
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
110
112
|
"sphere": dict(role="execution", inputs=["360/spherical video asset"], outputs=["flat rectilinear video artifact of the chosen viewport"],
|
|
111
113
|
required=FF + [X264, AAC, "filter:v360"], optional=[HDR_X265],
|
|
@@ -133,7 +135,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
133
135
|
{"capability": "filter:scdet", "when": "--auto-chapters --from scenes|both"}],
|
|
134
136
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
135
137
|
"loop": dict(role="execution", inputs=["video asset"], outputs=["video artifact repeated to the requested count or duration (--boomerang: played forward then backward, silent)"],
|
|
136
|
-
required=FF + [X264, AAC], optional=[{"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
138
|
+
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:reverse", "when": "--boomerang"}, {"capability": "filter:loop", "when": "--boomerang"}, {"capability": "filter:xfade", "when": "--crossfade"}, {"capability": "encoder:ffv1", "when": "--crossfade"}, {"capability": "encoder:flac", "when": "--crossfade"}],
|
|
137
139
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
138
140
|
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
139
141
|
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
@@ -158,7 +160,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
158
160
|
required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, {"capability": "filter:chromakey", "when": "--chromakey"}, HDR_X265],
|
|
159
161
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
160
162
|
"graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
|
|
161
|
-
required=FF + [X264, AAC, "filter:drawtext"],
|
|
163
|
+
required=FF + [X264, AAC, "filter:drawtext"],
|
|
164
|
+
optional=[HDR_X265, {"capability": "filter:ass", "when": "--text-render ass, a script drawtext cannot shape, or emoji drawn through libass or as PNGs"}],
|
|
162
165
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
163
166
|
"sync": dict(role="analysis_and_execution", inputs=["reference recording (video or audio)", "second recording (video or audio)"], outputs=["offset / drift JSON on stdout", "aligned artifact with --replace-audio / --trim-second / --fix-drift -o"],
|
|
164
167
|
required=FF, optional=[{"capability": AAC, "when": "writing a video container"}] + AUDIO_OUT,
|
|
@@ -201,7 +204,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
201
204
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
202
205
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
203
206
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores and dnxhr-* use pcm_s16le, copy stream-copies, gif has no audio)"},
|
|
204
|
-
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
207
|
+
{"capability": X265, "when": "preset h265 / youtube-hdr"}, {"capability": "encoder:libsvtav1", "when": "preset youtube-av1 (libaom-av1 is the fallback)"}, {"capability": "encoder:prores_ks", "when": "preset prores"}, {"capability": "encoder:dnxhd", "when": "preset dnxhr-lb / dnxhr-sq / dnxhr-hq / dnxhr-hqx / dnxhr-444"}, {"capability": "encoder:prores_ks", "when": "preset prores4444 / prores4444xq / key-fill"}, {"capability": "encoder:qtrle", "when": "preset qtrle"}, {"capability": "encoder:libvpx-vp9", "when": "preset vp9-alpha"}, {"capability": "encoder:hap", "when": "preset hap-alpha (an ffmpeg built with snappy)"}, {"capability": "filter:alphaextract", "when": "an alpha preset (the alpha is read back)"},
|
|
205
208
|
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur or --fit blur"},
|
|
206
209
|
{"capability": "filter:eq", "when": "--fit blur (the background copy is dimmed)"},
|
|
207
210
|
{"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
|
|
@@ -423,7 +426,7 @@ DIAGNOSTICS_SCHEMA = {
|
|
|
423
426
|
# failure it is). Every refuse() call in scripts/ names one of these (a test reads the sources).
|
|
424
427
|
ERROR_REASONS = {
|
|
425
428
|
"disk_full": "kind ffmpeg: ffmpeg reported \"No space left on device\" while writing; the hint says to free space or write elsewhere; details: {}",
|
|
426
|
-
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command
|
|
429
|
+
"filter_missing": "kind ffmpeg: this ffmpeg build lacks a filter the command names: for a command run() is given (not a run_analysis() measurement pass), refused before ffmpeg runs, dry runs included, when the build's filter list was read and lacks it, else ffmpeg's own \"No such filter\"; details: {name}; the hint points at doctor",
|
|
427
430
|
"input_incomplete": "kind ffmpeg: the input has no index (\"moov atom not found\"): a recording cut off or a copy not finished; --repair cannot rebuild it; details: {}",
|
|
428
431
|
"input_unreadable": "kind ffmpeg: ffmpeg could not read the input as media (\"Invalid data found when processing input\"); details: {}",
|
|
429
432
|
"ffmpeg_unsupported": "kind missing_tool: the ffmpeg/ffprobe on PATH reports a version older than 6.1, which 3.0.0 no longer supports (5.x was deprecated in 2.14.0); details: {version, required}",
|
|
@@ -453,7 +456,7 @@ ERROR_REASONS = {
|
|
|
453
456
|
"plan_field_invalid": "render.py PLAN: inputs, unbound_inputs, verify (objects), commands (strings) or cwd has the wrong JSON type; "
|
|
454
457
|
"details: {plan, plan_sha256, checked_at, field, field_type, audit_log}",
|
|
455
458
|
"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}",
|
|
456
|
-
"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
|
|
459
|
+
"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 a command run() is given names an encoder the build lacks for a stream the output keeps (an HDR source kept as HEVC, --codec hevc, a preset's encoder; not -c:a for a source with no sound, or with -an): refused before ffmpeg runs, dry runs included, when the build's encoder list was read and lacks it, else ffmpeg's own \"Unknown encoder\" (kind ffmpeg; details: {name}; the hint points at doctor)",
|
|
457
460
|
"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: {}",
|
|
458
461
|
"encoder_list_unreadable": "--codec av1/prores: the build's encoder list could not be read, so whether it has the encoder is unknown (kind ffmpeg); details: {codec, encoders, error}",
|
|
459
462
|
"audio_too_short": "kind input: loudness.py: the audio has a true peak but is shorter than one 400 ms gating block, so it has no integrated loudness to normalise from (not silence); details: {input_tp}",
|
|
@@ -483,7 +486,8 @@ DIAGNOSTIC_CODES = {
|
|
|
483
486
|
"av1_encoder_libaom": ("fallbacks", "--codec av1 on a build without libsvtav1: libaom-av1 encoded instead; {wanted, used}"),
|
|
484
487
|
"crf_clamped": ("warnings", "the CRF handed to the encoder was above its maximum and was capped (the HEVC HDR line adds 2); {encoder, requested, asked, used}"),
|
|
485
488
|
# fonts and text
|
|
486
|
-
"emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
|
|
489
|
+
"emoji_not_drawn": ("warnings", "caption.py / graphics.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; also graphics.py --text-render drawtext, which has no font fallback; {count, reason}"),
|
|
490
|
+
"emoji_mono_drawn_in_colour": ("warnings", "caption.py / graphics.py --emoji mono on a build whose libass draws the emoji face in colour: nothing makes the glyph monochrome, so the result's emoji.mode is color (graphics.py --text-render drawtext draws none: mode none, emoji_not_drawn)"),
|
|
487
491
|
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
488
492
|
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
|
|
489
493
|
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
@@ -497,9 +501,10 @@ DIAGNOSTIC_CODES = {
|
|
|
497
501
|
"font_path_not_ascii": ("warnings", "a --font-file under a non-ASCII path could not be given an ASCII form on Windows; drawtext may not open it; {font}"),
|
|
498
502
|
"fonts_dir_lacks_script": ("fallbacks", "no face in --fonts-dir covers the text's script, so one was picked by script; {fonts_dir, script}"),
|
|
499
503
|
"font_variant_missing": ("fallbacks", "--lang named Traditional Chinese but no Traditional face is installed, so a Simplified one draws it (some glyph forms differ); {lang, file}"),
|
|
500
|
-
"emoji_monochrome": ("warnings", "emoji drawn
|
|
504
|
+
"emoji_monochrome": ("warnings", "caption.py / graphics.py: emoji drawn monochrome through libass (no colour path here). overlay.py --text: emoji left to the one font drawtext loads, which has no fallback, so a font without the glyph draws an empty box"),
|
|
505
|
+
"words_outside_input": ("warnings", "silence.py --filler --words: word timings that start at or after the end of the input (a transcript of another file, or times in milliseconds); {count, words, duration}"),
|
|
501
506
|
"emoji_stripped": ("warnings", "overlay.py --emoji none: emoji removed from the drawn text; {mode}"),
|
|
502
|
-
"emoji_assets_missing": ("warnings", "--emoji-assets has no PNG for these clusters; the text
|
|
507
|
+
"emoji_assets_missing": ("warnings", "--emoji-assets has no PNG for these clusters; they stay in the text for libass, and the message says whether libass draws an emoji glyph on this build; {missing}"),
|
|
503
508
|
"drawtext_newline_glyph": ("warnings", "graphics.py --text-render drawtext on FFmpeg 8.0-8.0.1/8.1.0: a box is drawn at each line break of a multi-line label; {text_render}"),
|
|
504
509
|
"timeline_parse_failed": ("warnings", "render.py --export-timeline: the written file does not read back as its format (the parse step is ok: false); {path, format, error}"),
|
|
505
510
|
# captions
|
|
@@ -751,7 +756,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
751
756
|
"guides": {"type": "array", "description": "--guides only: each outline drawn on the PNG, {guide: action|title, fraction (0.93 / 0.90 of the frame, SMPTE ST 2046-1), of: frame} or {guide: 4:3, window, action: 0.90, title: 0.80}; drawn on the preview only"}}
|
|
752
757
|
elif name == "render":
|
|
753
758
|
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"], "description": "check.py's document for a \"check\" stage with a platform, \"content\": true, or both; when check.py printed none, a failure document with error.reason check_output_unparsed"},
|
|
754
|
-
"cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
|
|
759
|
+
"cache": {"type": ["object", "null"], "description": "--cache: {dir, ffmpeg, hits, misses, miss_reasons: [{stage, reason: not_cached|sidecar_missing|artifact_missing|sidecar_corrupt|artifact_truncated|no_output_path|destination_not_owned, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
|
|
755
760
|
"caption": {"type": ["object", "null"], "description": "the caption stage's own block, forwarded verbatim from caption.py (cue-layout counts and fit-size keys); null without a caption stage"},
|
|
756
761
|
"snap": {"type": ["object", "null"], "description": "a project's \"snap\": cut.py's snap block for the first snapped clip, with every clip's in clips; null when nothing was snapped"},
|
|
757
762
|
"intermediate_preset": {"type": ["string", "null"], "description": "2.15: the x264/x265 preset the stages before an encoding export ran at (--intermediate-preset, default superfast since 2.16); null when no stage received it (no encoding export reached, --fast, --stop-after before the export, a copy export, or nothing but the export encoded)"},
|
|
@@ -763,7 +768,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
763
768
|
"tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
|
|
764
769
|
"timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
|
|
765
770
|
"transition_frames, music, markers, notes, and not_exported (what the project asks for that an "
|
|
766
|
-
"editor timeline cannot carry)"}
|
|
771
|
+
"editor timeline cannot carry)"},
|
|
772
|
+
"templates": {"type": "array", "description": "--list-templates --json: one row per shipped template, {template, "
|
|
773
|
+
"destination, frame: {width, height} or null (audio), fit, max_duration, lufs, safe: {top, bottom, "
|
|
774
|
+
"left, right}}; without --json the same rows are a table"}}
|
|
767
775
|
elif name == "verify":
|
|
768
776
|
extra = {"report": {"type": ["string", "null"]},
|
|
769
777
|
"files": {"type": "array", "description": "one entry per file: {file, probe, steps, skipped}. A step is {step, ok, seconds, error (one line, for the table)}, plus the tool's verified when it completed, and on a failure its own kind, reason, message, details and exit_code (reason step_timeout or step_output_unparsed when the tool printed no failure document); skipped lists the steps that do not apply as {step, reason: quick|no_video|no_audio|not_hdr|not_multichannel|input_unreadable, message}"},
|
package/scripts/batch.py
CHANGED
|
@@ -37,7 +37,8 @@ from _common import STATE, add_common, apply_common, child_args, die, emit, info
|
|
|
37
37
|
from _common.runner import (HW_FORCED_ENV, OWNED_ENV, _OutputLock, add_hw_orchestrator_args, copy_to_sibling, env_hw, read_json,
|
|
38
38
|
owned_identity, stop_children)
|
|
39
39
|
from _common.emit import absorb_stage_hw, audit, note, shown, steps_encoder_report, warn
|
|
40
|
-
from _common.decision import rebase_project_paths
|
|
40
|
+
from _common.decision import NO_DEFAULT_ENCODE_EXT, rebase_project_paths
|
|
41
|
+
from _common.runner import parse_json
|
|
41
42
|
|
|
42
43
|
HERE = Path(__file__).resolve().parent
|
|
43
44
|
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
|
|
@@ -51,9 +52,16 @@ ALLOWED_STEP_SCRIPTS = {p.name for p in HERE.glob("*.py") if not p.name.startswi
|
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
def file_key(path: Path) -> str:
|
|
55
|
+
"""A cheap change detector: the name, the size, the modification time in nanoseconds and the
|
|
56
|
+
first and last MiB. A rewrite of the middle of a file keeps its size and its ends, so the
|
|
57
|
+
modification time is what tells it apart: in whole seconds (up to 3.8.4) two writes within
|
|
58
|
+
one second shared a key, and the old output was served as cached. A file whose mtime is reset
|
|
59
|
+
to the earlier value (``touch -r``, a copy that preserves it) or a filesystem that keeps
|
|
60
|
+
whole seconds (FAT keeps two) still shares one; hashing every byte would cost a read of the
|
|
61
|
+
whole file per item per run (docs/design-decisions.md, "After 3.8.4")."""
|
|
54
62
|
st = path.stat()
|
|
55
63
|
h = hashlib.sha1(usedforsecurity=False) # a change detector, not a secret
|
|
56
|
-
h.update(f"{path.name}|{st.st_size}|{
|
|
64
|
+
h.update(f"{path.name}|{st.st_size}|{st.st_mtime_ns}".encode())
|
|
57
65
|
with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
|
|
58
66
|
h.update(fh.read(1 << 20))
|
|
59
67
|
if st.st_size > 2 << 20:
|
|
@@ -86,6 +94,30 @@ def side_inputs_key(recipe: Dict[str, Any]) -> str:
|
|
|
86
94
|
return h.hexdigest()[:8]
|
|
87
95
|
|
|
88
96
|
|
|
97
|
+
def caseless_glob(pattern: str) -> str:
|
|
98
|
+
"""`pattern` with every letter outside a [...] class matched in either case: "*.mp4" finds
|
|
99
|
+
GOPR0001.MP4, as it already did on macOS's and Windows' default filesystems, so a recipe means
|
|
100
|
+
the same files on every platform (A82: on Linux a camera folder was an empty batch). pathlib's
|
|
101
|
+
case_sensitive= needs Python 3.12; 3.10 is the floor."""
|
|
102
|
+
out: List[str] = []
|
|
103
|
+
in_class = False
|
|
104
|
+
first = -1 # index of the class's first member: a "]" there is a member, not the end ("[]a]", "[!]a]")
|
|
105
|
+
for i, ch in enumerate(pattern):
|
|
106
|
+
if in_class:
|
|
107
|
+
out.append(ch)
|
|
108
|
+
if ch == "]" and i > first:
|
|
109
|
+
in_class = False
|
|
110
|
+
elif ch == "[":
|
|
111
|
+
first = i + 2 if pattern[i + 1:i + 2] == "!" else i + 1
|
|
112
|
+
in_class = "]" in pattern[first + 1:] # an unclosed [ ("[!]x") is a literal character to fnmatch
|
|
113
|
+
out.append(ch if in_class else "[[]")
|
|
114
|
+
elif ch.isalpha() and ch.lower() != ch.upper():
|
|
115
|
+
out.append(f"[{ch.lower()}{ch.upper()}]")
|
|
116
|
+
else:
|
|
117
|
+
out.append(ch)
|
|
118
|
+
return "".join(out)
|
|
119
|
+
|
|
120
|
+
|
|
89
121
|
def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
90
122
|
# A "project" recipe is just {"project": "<path>", "clip_key": N} -- the actual settings
|
|
91
123
|
# (export preset, captions, everything) live in the file at that path, not in this dict.
|
|
@@ -93,18 +125,57 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
|
93
125
|
# itself) left the key, and so every cache hit, unchanged: a preset swapped from "copy" to
|
|
94
126
|
# "x" (a real re-encode) still served the old cached output. Fold the referenced file's own
|
|
95
127
|
# content into the key so a content change invalidates the cache like any other edit would.
|
|
128
|
+
# main() calls this on every pass, so a --watch loop sees an edit made while it runs (A81).
|
|
96
129
|
project_content = ""
|
|
97
130
|
if recipe.get("project"):
|
|
98
131
|
try:
|
|
99
132
|
project_content = Path(recipe["project"]).read_text(encoding="utf-8")
|
|
100
133
|
except (OSError, UnicodeDecodeError) as exc:
|
|
101
134
|
# keyed on the recipe alone: an unreadable project cannot invalidate the cache by its
|
|
102
|
-
# content, so say so (each item that needs it then fails on reading it)
|
|
103
|
-
|
|
104
|
-
|
|
135
|
+
# content, so say so (each item that needs it then fails on reading it); once per run
|
|
136
|
+
entry = {"code": "recipe_project_unreadable", "path": str(recipe["project"])}
|
|
137
|
+
if not any(all(w.get(k) == v for k, v in entry.items()) for w in STATE.warnings if isinstance(w, dict)):
|
|
138
|
+
warn(f"recipe.project {recipe['project']} could not be read ({exc}); the cache key covers the recipe only",
|
|
139
|
+
"recipe_project_unreadable", path=str(recipe["project"]), error=str(exc))
|
|
140
|
+
else:
|
|
141
|
+
project_content += "\0" + project_files_key(recipe, project_content)
|
|
105
142
|
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode(), usedforsecurity=False).hexdigest()[:12]
|
|
106
143
|
|
|
107
144
|
|
|
145
|
+
def project_files_key(recipe: Dict[str, Any], text: str) -> str:
|
|
146
|
+
"""The files a project recipe's project names (music, cues, the brand and its logo and font,
|
|
147
|
+
overlay images and videos, the chapters file, every clip but the one each item replaces),
|
|
148
|
+
each keyed by its own content as file_key() keys a source (A80): an edited cue file or music
|
|
149
|
+
bed changes the output, so it has to change the key. The list is render.py's own
|
|
150
|
+
project_input_files(), read against the project file's directory as render reads it. A
|
|
151
|
+
project that does not parse adds nothing: each item then fails reading it."""
|
|
152
|
+
try:
|
|
153
|
+
proj = parse_json(text)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return ""
|
|
156
|
+
if not isinstance(proj, dict):
|
|
157
|
+
return ""
|
|
158
|
+
from render import project_input_files # here, not at the top: render.py imports this module
|
|
159
|
+
base = Path(str(recipe["project"])).resolve().parent
|
|
160
|
+
clips = proj.get("clips")
|
|
161
|
+
idx = int(recipe.get("clip_key") or 0) # check_recipe_types() has refused anything else
|
|
162
|
+
if isinstance(clips, list) and idx < len(clips) and isinstance(clips[idx], dict):
|
|
163
|
+
# the clip every item replaces with itself: its placeholder is no input of any item
|
|
164
|
+
proj = dict(proj, clips=[dict(c, src=None) if i == idx else c for i, c in enumerate(clips)])
|
|
165
|
+
|
|
166
|
+
def rel(p: Any) -> str:
|
|
167
|
+
return str(p) if os.path.isabs(str(p)) else str(base / str(p))
|
|
168
|
+
h = hashlib.sha1(usedforsecurity=False)
|
|
169
|
+
for name in sorted(set(project_input_files(proj, rel))):
|
|
170
|
+
path = Path(name)
|
|
171
|
+
try:
|
|
172
|
+
ident = file_key(path) if path.is_file() else "-"
|
|
173
|
+
except OSError:
|
|
174
|
+
ident = "-"
|
|
175
|
+
h.update(f"{name}\0{ident}\0".encode())
|
|
176
|
+
return h.hexdigest()
|
|
177
|
+
|
|
178
|
+
|
|
108
179
|
def gpu_key() -> str:
|
|
109
180
|
"""The GPU setting the steps resolve, as a suffix for the item cache key: a VideoToolbox file is
|
|
110
181
|
not the CPU's bytes, so a --hw output is never served to a --no-hw run or the other way round
|
|
@@ -222,6 +293,11 @@ def final_path(src: Path, recipe: Dict[str, Any], outdir: Path) -> Path:
|
|
|
222
293
|
# detection of its own; see one_pass()'s pre-flight check, which uses this same computation
|
|
223
294
|
# to catch that before any file is actually processed (and the earlier one silently clobbered).
|
|
224
295
|
final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
|
|
296
|
+
if not recipe.get("ext") and src.suffix.lower() in NO_DEFAULT_ENCODE_EXT:
|
|
297
|
+
# the #415 default-name rule (default_output()): the steps re-encode to x264/AAC, which a
|
|
298
|
+
# WebM cannot hold, so every .webm item was refused output_container_codecs (A83). A
|
|
299
|
+
# recipe that keeps WebM (a stream copy, export.py --preset vp9-alpha) sets "ext".
|
|
300
|
+
final_ext = "mp4"
|
|
225
301
|
steps = recipe.get("steps") or []
|
|
226
302
|
if not recipe.get("ext") and not recipe.get("project") and steps and not step_writes(steps[-1]) and not writes_nothing(recipe):
|
|
227
303
|
# the last step writes nothing, so what is delivered is an intermediate: its name says its
|
|
@@ -649,7 +725,6 @@ def main() -> int:
|
|
|
649
725
|
audit("batch_cache_reset", {"path": str(cache_path), "size": size, "error": str(exc)},
|
|
650
726
|
default_path=str(outdir / ".ffskill-audit.jsonl"))
|
|
651
727
|
cache = {}
|
|
652
|
-
rkey = recipe_key(recipe) + gpu_key()
|
|
653
728
|
glob = recipe.get("glob") or "*"
|
|
654
729
|
|
|
655
730
|
# --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
|
|
@@ -706,6 +781,7 @@ def main() -> int:
|
|
|
706
781
|
# first seen, last modified at least one interval ago; until then its row is
|
|
707
782
|
# skipped: "settling", not cached, and the next pass looks again.
|
|
708
783
|
last_seen: Dict[str, "tuple[int, int]"] = {}
|
|
784
|
+
shown_empty = {"done": False}
|
|
709
785
|
|
|
710
786
|
def settle_poll(files: List[Path]) -> "set":
|
|
711
787
|
if not args.watch:
|
|
@@ -728,7 +804,12 @@ def main() -> int:
|
|
|
728
804
|
return unsettled
|
|
729
805
|
|
|
730
806
|
def one_pass() -> List[Dict[str, Any]]:
|
|
731
|
-
files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
807
|
+
files = sorted(p for p in folder.glob(caseless_glob(glob)) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
808
|
+
if not files and not shown_empty["done"]:
|
|
809
|
+
# a batch of nothing completed with total 0 and not a word (A82): say what matched nothing
|
|
810
|
+
shown_empty["done"] = True
|
|
811
|
+
note(f"no media file in {folder} matches the recipe's glob {glob!r} (matched without regard to case); "
|
|
812
|
+
"nothing was processed")
|
|
732
813
|
# Two different sources can compute the same final path (most often a fixed recipe "ext"
|
|
733
814
|
# collapsing e.g. clip.mp4 and clip.mov to the same clip_out.mp4) -- catch that before
|
|
734
815
|
# processing anything, rather than letting the later one silently overwrite the earlier
|
|
@@ -805,7 +886,9 @@ def main() -> int:
|
|
|
805
886
|
slots: "List[Optional[Dict[str, Any]]]" = [None] * len(files)
|
|
806
887
|
pending: List[tuple] = []
|
|
807
888
|
cache_miss: Dict[int, Dict[str, Any]] = {}
|
|
808
|
-
|
|
889
|
+
# read on every pass: a --watch loop serves nothing from an edited project.json, cue file or
|
|
890
|
+
# music bed (computed once before the loop, it did: A81)
|
|
891
|
+
pass_key = recipe_key(recipe) + gpu_key() + side_inputs_key(recipe)
|
|
809
892
|
polled = settle_poll(files)
|
|
810
893
|
for i, src in enumerate(files):
|
|
811
894
|
if str(src) in polled:
|