ffmpeg-skill 3.8.6 → 3.8.8
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 +3 -3
- package/SKILL.md +3 -3
- package/docs/contract.md +29 -20
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +9 -6
- package/references/flags.md +2 -2
- package/references/gotchas.md +10 -4
- package/references/scripts.md +59 -12
- package/references/workflow.md +2 -2
- package/scripts/_common/runner.py +33 -3
- package/scripts/_common/timeline.py +45 -12
- package/scripts/_contract.py +88 -8
- package/scripts/batch.py +91 -8
- package/scripts/export.py +1 -1
- package/scripts/graphics.py +14 -1
- package/scripts/metadata.py +4 -0
- package/scripts/render.py +211 -36
- package/scripts/report.py +27 -1
- package/scripts/sequence.py +5 -1
- package/templates/audiogram.json +9 -0
|
@@ -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
|
@@ -239,6 +239,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
239
239
|
# on PATH). "analysis_only": ffmpeg still decodes/measures the input under --dry-run, but nothing
|
|
240
240
|
# is encoded and no file is written. Every other tool with the flag runs no ffmpeg at all.
|
|
241
241
|
DRY_RUN_ANALYSIS = {
|
|
242
|
+
# #466 A193: probe was "none" with an ffprobe note while --analyze decoded with ffmpeg
|
|
243
|
+
"probe": "read-only tool; --dry-run changes nothing: ffprobe still runs, and only with --analyze ffmpeg decodes the first 20 s of each video input to sample picture levels",
|
|
242
244
|
"sync": "audio is decoded to find the offset; the aligned output is not written",
|
|
243
245
|
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
244
246
|
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
@@ -255,7 +257,6 @@ DRY_RUN_ANALYSIS = {
|
|
|
255
257
|
"overlay": "only for a multi-line --text on FFmpeg 8.0, 8.0.1 or 8.1.0: one drawtext measures each line on a generated one-frame picture (the input is not read), so the planned per-line positions are the ones a run uses; otherwise no ffmpeg runs and the composited output is never written",
|
|
256
258
|
}
|
|
257
259
|
DRY_RUN_NOTES = {
|
|
258
|
-
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
259
260
|
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
260
261
|
}
|
|
261
262
|
|
|
@@ -581,7 +582,7 @@ def alpha_result_schema(name: str) -> Dict[str, Any]:
|
|
|
581
582
|
out["alpha_codec"]["description"] += " (an RGB source that is neither HDR nor BT.2020, on a prores4444/vp9 line has no matrix to keep: it is converted to BT.709 and tagged so; an HDR or BT.2020 one is not: FFmpeg's own conversion makes its YUV, as before)"
|
|
582
583
|
out["alpha_dropped"] = {"type": "boolean", "description": "true when the source's alpha is used (a frame below opaque, or a stored alpha plane that could not be read) and the output does not keep it (color.py --retag too, when its copy fell back to an H.264 re-encode): without --keep-alpha the `alpha` verification step fails (reason alpha_dropped, attribution artifact, with an alpha_dropped warning); with --drop-alpha there is no such step and a note says the alpha was dropped on purpose. Absent on a source without alpha"}
|
|
583
584
|
if name == "color":
|
|
584
|
-
out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back unchanged; the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
|
|
585
|
+
out["alpha_codec"]["description"] += ". --correct / --lut only: the grade works on the colour (at 4:4:4) and the source's alpha is merged back onto that 8-bit picture (an 8-bit alpha unchanged; a 10- or 12-bit ProRes 4444 alpha keeps 8 bits of precision); the `alpha` step compares the written alpha with the source's frame by frame, tolerance 2 of 255 (comparison {ok, paired, worst_delta, worst_frame}; frames {expected, measured}; written {codec, pix_fmt, alpha_plane})"
|
|
585
586
|
else:
|
|
586
587
|
out["alpha_codec"]["description"] += ". Drawn on the input's alpha at 4:4:4 (layers composited with their alpha, text drawn into it); the `alpha` step (property alpha_at_least_source) passes when the written stream stores an alpha plane, every frame's alpha is at or above the source's (tolerance 2 of 255) and the output did not turn opaque in every frame the source was transparent in (reason: opaque_everywhere, alpha_below_source or no_alpha_plane when it fails)"
|
|
587
588
|
out["alpha_codec"]["description"] += ". A source that stores an alpha plane whose alpha could not be read is never taken as opaque: its step fails with reason source_alpha_unmeasured"
|
|
@@ -608,6 +609,60 @@ NON_AV_STREAM_TOOLS = frozenset({"audio", "broll", "color", "cut", "deinterlace"
|
|
|
608
609
|
"sphere", "straighten"})
|
|
609
610
|
|
|
610
611
|
|
|
612
|
+
# Top-level keys a tool emits that its own branch in output_schema() does not list (#466 A194: the
|
|
613
|
+
# schemas were written by hand and left these out). tests/test_contract.py's
|
|
614
|
+
# test_every_emitted_top_level_key_is_in_the_output_schema runs each tool and fails on a key that
|
|
615
|
+
# is in neither.
|
|
616
|
+
EMITTED_KEYS: Dict[str, Dict[str, Any]] = {
|
|
617
|
+
"cropdetect": {
|
|
618
|
+
"file": {"type": "string", "description": "the input path, as given"},
|
|
619
|
+
"source_width": {"type": "integer", "description": "the displayed frame's width (a 90/270 degree rotation swaps the stored size; a non-square-pixel source is squared at its display aspect): the frame crop and motion_centre are in"},
|
|
620
|
+
"source_height": {"type": "integer", "description": "the displayed frame's height, as source_width"},
|
|
621
|
+
"crop": {"type": ["object", "null"], "description": "{width, height, x, y}: the rectangle cropdetect read most often over the sampled windows, in source pixels (the full frame when there are no bars); null when it produced no reading"},
|
|
622
|
+
"confidence": {"type": "number", "description": "present with a crop: the share of readings (0..1) that gave that rectangle"},
|
|
623
|
+
"motion_centre": {"type": "array", "description": "--motion-centre only: [{time, x, y, x_frac, y_frac, motion}] per second over the same windows; x/y null for a window with no measured motion. Report only"}},
|
|
624
|
+
"graphics": {
|
|
625
|
+
"template": {"type": "string", "description": "the --template drawn"},
|
|
626
|
+
"text_renderer": {"enum": ["ass", "drawtext"], "description": "what drew the text: libass (a generated .ass) or drawtext"},
|
|
627
|
+
"script": {"type": "string", "description": "the writing system detected in the text, which picks the font (latin, han, ...)"},
|
|
628
|
+
"ass": {"type": "string", "description": "text_renderer ass only: the generated .ass path (--write-ass, else <output>_gfx.ass); under --dry-run the path that would be written"},
|
|
629
|
+
"emoji": {"type": "object", "description": "text with emoji only: {mode, count, clusters, assets, missing, overlays}"},
|
|
630
|
+
"broken_inside_word": {"type": "integer", "description": "present when above 0: words hard-sliced because they did not fit a line alone"}},
|
|
631
|
+
"loudness": {
|
|
632
|
+
"loudnorm_mode": {"enum": ["linear", "dynamic"], "description": "--dry-run only (a real run reports it in result): the mode loudnorm's planned pass 2 runs in for the measured input"}},
|
|
633
|
+
"join": {
|
|
634
|
+
"reencoded": {"type": "boolean", "description": "--copy --transition none only: false, the clips were joined by stream copy"},
|
|
635
|
+
"audio_substituted": {"type": "array", "items": {"type": "string"}, "description": "a re-encoded video join: the clips with no audio stream, whose span of the join is generated silence (notes says the same); [] when every clip has sound"},
|
|
636
|
+
"inputs_duration": {"type": ["number", "null"], "description": "the clips' own total length, seconds"},
|
|
637
|
+
"transition_shortening": {"type": ["number", "null"], "description": "how much the transitions took out of inputs_duration, seconds"}},
|
|
638
|
+
"multicam": {
|
|
639
|
+
"inputs": {"type": "array", "items": {"type": "string"}, "description": "the input paths as given, reference first; a camera number in cuts indexes this list"}},
|
|
640
|
+
"broll": {
|
|
641
|
+
"cutaways": {"type": "array", "description": "[{insert, at, end, from}] per cutaway: the B clip, where it starts and ends on A's timeline (seconds), and where in B it starts"},
|
|
642
|
+
"audio": {"enum": ["a", "b", "mix"], "description": "--audio: what plays under a cutaway"}},
|
|
643
|
+
"grid": {
|
|
644
|
+
"cols": {"type": "integer"}, "rows": {"type": "integer"},
|
|
645
|
+
"clips": {"type": "integer", "description": "how many input clips the grid holds"}},
|
|
646
|
+
"scenes": {
|
|
647
|
+
"highlights": {"type": "array", "description": "--highlights only: [{start, end, duration}] the proposed ranges"},
|
|
648
|
+
"highlights_total": {"type": "number", "description": "--highlights only: the ranges' total seconds"},
|
|
649
|
+
"highlights_rank_by": {"enum": ["audio", "duration"], "description": "--highlights only: --rank-by"},
|
|
650
|
+
"sheet": {"type": "string", "description": "--sheet only: the contact sheet path (under --dry-run the path that would be written; nothing is drawn)"}},
|
|
651
|
+
"batch": {
|
|
652
|
+
"jobs": {"type": "integer", "description": "the parallelism actually applied"},
|
|
653
|
+
"jobs_requested": {"type": ["integer", "null"], "description": "the --jobs asked for (auto resolved to a number)"},
|
|
654
|
+
"wall_seconds": {"type": "number", "description": "the batch's wall clock"},
|
|
655
|
+
"item_seconds_total": {"type": "number", "description": "the sum of the per-item times"},
|
|
656
|
+
"timed_out": {"type": "boolean", "description": "whether the shared --timeout budget ran out (a timed-out item's row carries skipped: timeout)"}},
|
|
657
|
+
"render": {
|
|
658
|
+
"template": {"type": "string", "description": "--write-project only: the template the written project was filled from"},
|
|
659
|
+
"pack": {"type": "array", "description": "--template naming several destinations (a social pack) only: one row per destination {platform, file, path, size_bytes, duration, check, guidance, ok}; output is then the <stem>_pack.md table"},
|
|
660
|
+
"outputs": {"type": "array", "items": {"type": "string"}, "description": "a social pack only: each destination's output path"}},
|
|
661
|
+
"report": {
|
|
662
|
+
"pack": {"type": "array", "description": "--pack only: the pack table's rows as {column: cell} objects, as read from the file"}},
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
|
|
611
666
|
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
612
667
|
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
613
668
|
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
@@ -618,7 +673,15 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
618
673
|
"timecode": {"type": "object", "description": "3.0.0: the source's start timecode {value, drop_frame, source}: the first HH:MM:SS[:;.]FF timecode tag from the video stream, then a tmcd data stream, then the container; all three null when none carries one"},
|
|
619
674
|
"chapters": {"type": "array", "description": "the container's chapter markers [{index, start, end, title}]"},
|
|
620
675
|
"subtitle_stream_details": {"type": "array", "description": "every subtitle stream in file order [{index (the n of -map 0:s:n), codec, language, title, default (the stream's default disposition)}]"},
|
|
621
|
-
"keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"}
|
|
676
|
+
"keyframes": {"type": "array", "description": "--keyframes-near only: one entry per time [{at, window, keyframes (the video keyframes within window seconds of it), before (the nearest of those at or before it, or null when none is within the window), after (at or after it, likewise)}], seconds from the file's start; a failed keyframe read fails the run (kind ffmpeg)"},
|
|
677
|
+
"start_time": {"type": ["number", "null"], "description": "where the container's timeline starts, seconds (an MPEG-TS file often starts above 0)"},
|
|
678
|
+
"bitrate": {"type": ["integer", "null"], "description": "the container's overall bit rate, bits per second, as ffprobe reads it; null when it states none"},
|
|
679
|
+
"subtitle_streams": {"type": "integer", "description": "how many subtitle streams the file carries"},
|
|
680
|
+
"data_streams": {"type": "integer", "description": "how many data streams (a tmcd timecode track, telemetry) the file carries"},
|
|
681
|
+
"stream_counts": {"type": "object", "description": "{video, audio}: picture streams (a cover picture is not one) and sound streams"},
|
|
682
|
+
"tags": {"type": "object", "description": "the container's title, artist, album, comment, date and genre tags that are set, keys lower-cased"},
|
|
683
|
+
"audio_streams": {"type": "array", "description": "every audio stream in file order [{index (the n of -map 0:a:n), codec, channels, channel_layout, sample_rate, language, title}]"},
|
|
684
|
+
"levels": {"type": "object", "description": "--analyze only, on an input with a video stream: picture levels sampled at 2 fps over the first 20 s, on an 8-bit scale inferred from the values measured (a 10-bit or deeper picture whose luma never passes code 255 is read as 8-bit and left unscaled) {scale, y_min, y_max, y_avg, y_low_p5, y_high_p95, saturation_avg, looks_like_log, note}; {error} when no frame was analysed. Measured under --dry-run too"}},
|
|
622
685
|
"additionalProperties": True}
|
|
623
686
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
624
687
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "the ffmpeg command lines this run planned or ran to write its output (render.py and waveform.py: their stages' too); a measurement pass is listed only where the tool records it (e.g. loudness.py pass 1, check.py, stabilize.py pass 1), and batch.py lists none of its steps' (each results row's step documents carry their own)"},
|
|
@@ -688,7 +751,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
688
751
|
"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"}}
|
|
689
752
|
elif name == "render":
|
|
690
753
|
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"},
|
|
691
|
-
"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)}"},
|
|
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|destination_not_owned, ...}], saved_seconds, entries, would_hit (--dry-run)}"},
|
|
692
755
|
"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"},
|
|
693
756
|
"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"},
|
|
694
757
|
"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)"},
|
|
@@ -700,7 +763,10 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
700
763
|
"tool_result": {"type": "object", "description": "plan execution: the tool's own --json document"},
|
|
701
764
|
"timeline": {"type": "object", "description": "--export-timeline: format, rate, duration, frames, clips, transition, "
|
|
702
765
|
"transition_frames, music, markers, notes, and not_exported (what the project asks for that an "
|
|
703
|
-
"editor timeline cannot carry)"}
|
|
766
|
+
"editor timeline cannot carry)"},
|
|
767
|
+
"templates": {"type": "array", "description": "--list-templates --json: one row per shipped template, {template, "
|
|
768
|
+
"destination, frame: {width, height} or null (audio), fit, max_duration, lufs, safe: {top, bottom, "
|
|
769
|
+
"left, right}}; without --json the same rows are a table"}}
|
|
704
770
|
elif name == "verify":
|
|
705
771
|
extra = {"report": {"type": ["string", "null"]},
|
|
706
772
|
"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}"},
|
|
@@ -781,7 +847,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
781
847
|
"drift_ppm": {"type": "array", "items": {"type": ["number", "null"]}, "description": "--fix-drift: per input, the drift measured against the reference (the reference itself 0, the clock); null for a camera whose drift was not measured, with the reason in drift_skipped_reason (also a diagnostics.skipped entry, drift_correction_skipped)"},
|
|
782
848
|
"drift_skipped_reason": {"type": "array", "items": {"enum": ["too_short", "low_confidence", None]}, "description": "--fix-drift: per input, why its drift was not measured (too_short: no tail window far enough from the head; low_confidence: the tail did not correlate), null when it was and for the reference"}}
|
|
783
849
|
elif name == "join":
|
|
784
|
-
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
850
|
+
extra = {"mode": {"enum": ["video", "audio", "copy"], "description": "copy: --copy --transition none wrote a stream copy"}, "clips": {"type": "integer"}, "transition": {"type": "string"},
|
|
785
851
|
"expected_duration": {"type": ["number", "null"], "description": "clip lengths minus the transitions; null under --dry-run while an input is pending (its length is unknown)"},
|
|
786
852
|
"frame_rate": {"type": "string", "description": "video mode only: the join's frame rate as an exact fraction (\"30000/1001\"), the rate every clip length and xfade offset is counted in"},
|
|
787
853
|
"clip_frames": {"type": "array", "items": {"type": "integer"}, "description": "video mode only: per clip, the whole frames of frame_rate it holds in the join (its picture's, or the frames that cover a sound running more than a frame past it); its sound is cut to the samples those frames last. A pending --dry-run input counts 0"},
|
|
@@ -802,6 +868,8 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
802
868
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present with `silent`: which tracks were silent and how to refuse them"},
|
|
803
869
|
"timing": {"type": "object", "description": "#486: {tempo, atempo, length_samples, sample_rate}: the --tempo factor (null without it), the atempo stages (each 0.5..2.0, [] at tempo 1) whose product it is, --length-samples (null without it) and the sample rate that count is in (null when not measured yet); with --length-samples a length_samples verification step counts the written track's decoded samples (exact for PCM/FLAC/ALAC, within tolerance_samples, one codec frame, for a lossy codec)"},
|
|
804
870
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, channel (--channel N, the 0-based channel kept, else null), sample_rate (--sample-rate, else null; both are read back from the output as the channels / sample_rate verification steps), effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
871
|
+
for key, prop in EMITTED_KEYS.get(name, {}).items():
|
|
872
|
+
extra.setdefault(key, prop)
|
|
805
873
|
if name in ("overlay", "graphics", "caption", "grid"):
|
|
806
874
|
extra["font_used"] = FONT_USED_SCHEMA
|
|
807
875
|
if name in ALPHA_LINE_TOOLS:
|
|
@@ -1672,6 +1740,18 @@ def _merge_optional(base: List[Dict[str, Any]], extra: List[Dict[str, Any]]) ->
|
|
|
1672
1740
|
return out
|
|
1673
1741
|
|
|
1674
1742
|
|
|
1743
|
+
_SENTENCE_END = re.compile(r"(?<!\be\.g)(?<!\bi\.e)(?<!\betc)[.!?](?=\s|$)")
|
|
1744
|
+
|
|
1745
|
+
|
|
1746
|
+
def summary_sentence(docstring: str) -> str:
|
|
1747
|
+
"""The docstring's first sentence, its wrapped lines joined (#466 A195: the first physical line
|
|
1748
|
+
cut 16 descriptions mid-phrase). A first paragraph with no full stop is taken whole."""
|
|
1749
|
+
para = re.split(r"\n\s*\n", docstring.strip(), maxsplit=1)[0]
|
|
1750
|
+
joined = " ".join(line.strip() for line in para.splitlines())
|
|
1751
|
+
end = _SENTENCE_END.search(joined)
|
|
1752
|
+
return joined[:end.end()] if end else joined
|
|
1753
|
+
|
|
1754
|
+
|
|
1675
1755
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
1676
1756
|
if name not in TOOL_META:
|
|
1677
1757
|
# a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
|
|
@@ -1696,7 +1776,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1696
1776
|
"id": f"{SKILL_ID}/{name}",
|
|
1697
1777
|
"name": name,
|
|
1698
1778
|
"version": version,
|
|
1699
|
-
"description": (parser.description or "")
|
|
1779
|
+
"description": summary_sentence(parser.description or ""),
|
|
1700
1780
|
"executable": f"scripts/{name}.py",
|
|
1701
1781
|
# 1.20.0: SKILL.md's own request-table rows for this tool, machine-readable
|
|
1702
1782
|
"examples": skill_examples().get(name, []),
|
|
@@ -2079,7 +2159,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
2079
2159
|
"hidden .<output>.ffskill-audit.jsonl beside the output, and a cache event's .ffskill-audit.jsonl in the "
|
|
2080
2160
|
"cache directory (render.py --cache DIR) or the batch's output_dir. Never written under --dry-run; a write that "
|
|
2081
2161
|
"fails is a diagnostics.warnings entry (audit_log_unwritable), never a failure"},
|
|
2082
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported)
|
|
2162
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "something the run needs is not installed: ffmpeg or ffprobe not on PATH, or older than the supported 6.1 (error.reason ffmpeg_unsupported) (both exit 127); an encoder this build lacks for --codec, an export.py preset or an alpha line (encoder_missing); the libvpx-vp9 decoder for a VP9 source with an alpha layer (decoder_missing); no local speech-to-text engine for --transcribe (asr_engine_missing), or a named --engine that is not installed (these exit 1)","timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT; verify.py's --timeout is per step, default 600 s) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), a render.py --template social pack with a destination that failed (pack rows attached; a destination that timed out or was interrupted gives that kind instead), batch.py items (results attached), verify.py steps (files attached), color.py --retag (output written, but ffprobe does not read the requested colour tags on it), loudness.py (output written, its true peak still above --tp after the re-encodes; result attached), join.py --copy (the stream-copied join does not hold the clips it was given; it is not moved to the output path, verification attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
|
|
2083
2163
|
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
2084
2164
|
},
|
|
2085
2165
|
"capabilities": caps,
|
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:
|
package/scripts/export.py
CHANGED
|
@@ -484,7 +484,7 @@ def main() -> int:
|
|
|
484
484
|
ap.add_argument("--max-mb", type=float, metavar="MB",
|
|
485
485
|
help="fit the file under MB megabytes (MiB): a two-pass x264 encode at the video bitrate the "
|
|
486
486
|
"running time leaves after the audio, measured afterwards (H.264 presets; not with --crf or --hw)")
|
|
487
|
-
ap.add_argument("--normalize", action="store_true", help="
|
|
487
|
+
ap.add_argument("--normalize", action="store_true", help=f"the platform presets ({', '.join(sorted(PLATFORM_OF))}): when the written file misses the platform's loudness spec, run loudness.py on it (audio re-encoded, video copied) so one export delivers")
|
|
488
488
|
ap.add_argument("--alpha-mode", choices=["straight", "premultiplied"],
|
|
489
489
|
help="alpha presets (prores4444, prores4444xq, qtrle, vp9-alpha, hap-alpha): write the colour straight "
|
|
490
490
|
"(default, as decoded) or premultiplied by the alpha; written to the result as alpha_mode")
|
package/scripts/graphics.py
CHANGED
|
@@ -55,6 +55,9 @@ from _common.emit import input_length # noqa: E402
|
|
|
55
55
|
TEXT = "@TEXT@"
|
|
56
56
|
BOX = "@BOX@" # a box's place in the filter chain, followed by its index (graphics.main fill_box)
|
|
57
57
|
TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug", "sticker", "hook", "meme"]
|
|
58
|
+
# --dry-run on an input that does not exist yet, with no --end: how long after --start the planned
|
|
59
|
+
# graphic ends (its length is unknown; the plan says so in a note)
|
|
60
|
+
PENDING_PLACEHOLDER_SECONDS = 10.0
|
|
58
61
|
|
|
59
62
|
|
|
60
63
|
def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
|
|
@@ -208,7 +211,17 @@ def main(force_route: Optional[str] = None) -> int:
|
|
|
208
211
|
info(f"--platform {args.platform}: safe margins top {m_top} / bottom {m_bottom} / left {m_left} / right {m_right} px")
|
|
209
212
|
fps = meta["video"].get("fps")
|
|
210
213
|
s = time_arg(args.start, "--start", fps) if args.start else 0.0
|
|
211
|
-
|
|
214
|
+
if args.end:
|
|
215
|
+
e = time_arg(args.end, "--end", fps)
|
|
216
|
+
elif meta.get("dry_run"):
|
|
217
|
+
# #466 A202: an input an earlier --dry-run stage would write is probe()'s stub, whose
|
|
218
|
+
# length is unknown, not 0 s: planning to "the clip's end" refused with "--end must be
|
|
219
|
+
# after --start". The plan ends the graphic at a stated placeholder instead
|
|
220
|
+
e = s + PENDING_PLACEHOLDER_SECONDS
|
|
221
|
+
note(f"{args.input} does not exist yet, so its length is unknown: the planned graphic ends at a placeholder "
|
|
222
|
+
f"{e:g} s; a real run ends it at the clip's end (or pass --end)")
|
|
223
|
+
else:
|
|
224
|
+
e = input_length(meta) # no --end is the clip's end (#491)
|
|
212
225
|
# the picture's own length first: sound that runs on keeps the container open past the last frame
|
|
213
226
|
picture_len = meta["video"].get("duration") or dur
|
|
214
227
|
if picture_len and s >= picture_len:
|
package/scripts/metadata.py
CHANGED
|
@@ -52,6 +52,10 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
|
|
|
52
52
|
# chapter files carry no fps, so hh:mm:ss:ff needs its @fps suffix; time_arg() says so
|
|
53
53
|
start = time_arg(parts[0], f"{path}:{n}")
|
|
54
54
|
title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
|
|
55
|
+
if "\x00" in title:
|
|
56
|
+
# FFmpeg ends a metadata value at NUL: "A\x00B" was written as "A", verified (the
|
|
57
|
+
# sibling of A260 in render.py's inline chapters)
|
|
58
|
+
die(f"{path}:{n}: the chapter title holds a NUL character, which a chapter title cannot carry; remove it")
|
|
55
59
|
if entries and start <= entries[-1]["start"]:
|
|
56
60
|
die(f"{path}:{n}: chapter at {start:g}s does not come after the previous one at {entries[-1]['start']:g}s")
|
|
57
61
|
if duration and start >= duration:
|