ffmpeg-skill 3.8.5 → 3.8.7
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/mcp/server.py +3 -2
- 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 +26 -8
- package/references/workflow.md +2 -2
- package/scripts/_common/decision.py +6 -1
- package/scripts/_common/runner.py +46 -5
- package/scripts/_contract.py +84 -7
- package/scripts/audio.py +38 -5
- package/scripts/batch.py +10 -0
- package/scripts/caption.py +51 -1
- package/scripts/color.py +6 -0
- package/scripts/cropdetect.py +6 -1
- package/scripts/cut.py +5 -0
- package/scripts/export.py +1 -1
- package/scripts/fit.py +5 -0
- package/scripts/graphics.py +20 -1
- package/scripts/look.py +7 -0
- package/scripts/loudness.py +8 -0
- package/scripts/metadata.py +3 -0
- package/scripts/overlay.py +18 -1
- package/scripts/render.py +50 -5
- package/scripts/scenes.py +14 -0
- package/scripts/sequence.py +4 -0
- package/scripts/silence.py +3 -0
- package/scripts/sync.py +6 -0
- package/scripts/verify.py +4 -0
- package/scripts/waveform.py +13 -4
package/scripts/render.py
CHANGED
|
@@ -275,6 +275,15 @@ def template_project(name: str, args) -> Dict[str, Any]:
|
|
|
275
275
|
("--brand", args.brand), ("--chapters", args.chapters), ("--image", args.image)):
|
|
276
276
|
if path and not os.path.isfile(path):
|
|
277
277
|
die(f"{flag}: file not found: {path}")
|
|
278
|
+
audiogram = tpl.get("audiogram")
|
|
279
|
+
if isinstance(audiogram, dict) and "$IMAGE" in audiogram.values() and not args.image and not args.write_project:
|
|
280
|
+
# A89: without --image the whole audiogram block was dropped as an unfilled placeholder,
|
|
281
|
+
# so the dry run planned a render with no picture and the real one failed at the export
|
|
282
|
+
# ("input has no video stream"). --write-project still writes the project to edit.
|
|
283
|
+
die(f"--template {name} draws the sound over a still picture and needs --image FILE "
|
|
284
|
+
"(a local file: nothing is fetched and no cover art is invented)",
|
|
285
|
+
hint='for a plain colour behind the waveform, write the project with --write-project and give it '
|
|
286
|
+
'"audiogram": {"background": COLOUR}')
|
|
278
287
|
proj, _ = fill_template(tpl, values)
|
|
279
288
|
if args.fit:
|
|
280
289
|
proj.setdefault("frame", {})["fit"] = args.fit
|
|
@@ -364,6 +373,8 @@ def render_pack(names: List[str], args) -> int:
|
|
|
364
373
|
("--intermediate-preset", args.intermediate_preset)):
|
|
365
374
|
if value:
|
|
366
375
|
argv += option_args(flag, value)
|
|
376
|
+
if STATE.audit_log:
|
|
377
|
+
argv += option_args("--audit-log", STATE.audit_log) # every destination's records, in the one file asked for
|
|
367
378
|
info(f"→ pack: {name}")
|
|
368
379
|
proc = run_tool(argv + child_args() + ["--json"])
|
|
369
380
|
try:
|
|
@@ -445,7 +456,7 @@ def check_keys(obj: Any, schema: str, label: str) -> None:
|
|
|
445
456
|
else f" (valid keys: {', '.join(sorted(valid))})"))
|
|
446
457
|
|
|
447
458
|
|
|
448
|
-
def check_overlay(ov: Any, label: str) -> None:
|
|
459
|
+
def check_overlay(ov: Any, label: str, rendered: bool = True) -> None:
|
|
449
460
|
"""#326: a video layer is one kind of overlay, as overlay.py's --image/--text/--logo/--video
|
|
450
461
|
are one mutually exclusive choice, and the chromakey* keys only act on it. A logo, image and
|
|
451
462
|
text in one entry still draw the first of them, as before; only a combination with the new
|
|
@@ -461,6 +472,12 @@ def check_overlay(ov: Any, label: str) -> None:
|
|
|
461
472
|
keyed = [k for k in ("chromakey", "chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
|
|
462
473
|
if keyed:
|
|
463
474
|
die(f"{label}: {keyed[0]} keys a colour out of a video layer and needs \"video\" (overlay.py --chromakey needs --video)")
|
|
475
|
+
if rendered and ov.get("video") and ov.get("fade"):
|
|
476
|
+
# A171: overlay.py --video has no fade; the layer was composited with a hard edge. Only a
|
|
477
|
+
# run that composites the layer refuses it: an export or a preview that stops before the
|
|
478
|
+
# overlays never read the entry, and completed
|
|
479
|
+
die(f"{label}: fade fades an image, logo or text layer; a video layer is shown from start to end "
|
|
480
|
+
"without one (drop \"fade\" from this entry)", kind="input")
|
|
464
481
|
if ov.get("chromakey") is None:
|
|
465
482
|
tuned = [k for k in ("chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
|
|
466
483
|
if tuned:
|
|
@@ -598,7 +615,8 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
598
615
|
for i, item in enumerate(items if isinstance(items, list) else []):
|
|
599
616
|
check_keys(item, f"{name}[]", f"{name}[{i}]")
|
|
600
617
|
if name == "overlays":
|
|
601
|
-
check_overlay(item, f"overlays[{i}]"
|
|
618
|
+
check_overlay(item, f"overlays[{i}]",
|
|
619
|
+
rendered=not timeline and _reaches(READ_AT["overlays"], stop_after))
|
|
602
620
|
if name != "clips":
|
|
603
621
|
continue
|
|
604
622
|
# every clip stage starts from rel(c["src"]): without one it was a KeyError traceback
|
|
@@ -616,7 +634,29 @@ def validate_project(proj: Dict[str, Any], timeline: bool = False, stop_after: O
|
|
|
616
634
|
# the render reads a clip's snap only on a clip it cuts (in/out), the export never
|
|
617
635
|
if not timeline and (item.get("in") is not None or item.get("out") is not None):
|
|
618
636
|
require_object(item["snap"], f"clips[{i}].snap")
|
|
637
|
+
# A93: only the project's own snap.to was checked, so a clip's "beat" (or an
|
|
638
|
+
# unsupported grid) was read as "not beats" and the cut rendered unsnapped
|
|
639
|
+
check_snap_to(item["snap"], f"clips[{i}].snap")
|
|
619
640
|
check_keys(item["snap"], "snap", f"clips[{i}].snap")
|
|
641
|
+
if not timeline and "audiogram" in read and proj.get("audiogram") and isinstance(clips, list) and len(clips) > 1:
|
|
642
|
+
# A92: the audiogram stage draws the first clip's sound and the render carries on from
|
|
643
|
+
# that one file: every other clip was cut and then left out of the output without a word
|
|
644
|
+
die(f"audiogram: an audiogram is drawn from one audio source, and this project has {len(clips)} clips "
|
|
645
|
+
"(only the first would be in the output); join them first (join.py) and name the joined file as the one clip")
|
|
646
|
+
chapters = proj.get("chapters")
|
|
647
|
+
if chapters and not isinstance(chapters, (str, list)):
|
|
648
|
+
# A94: a number or an object was handed to metadata.py as a file name by the last stage,
|
|
649
|
+
# after the whole render ("chapters file not found: .../5"). false and 0 are "no chapters",
|
|
650
|
+
# as the stage's own `if ch:` reads them, and stay that
|
|
651
|
+
die(f'chapters: must be a chapters file path or a list of {{"at": TIME, "title": STR}}, got '
|
|
652
|
+
f"{type(chapters).__name__} {chapters!r:.60}")
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def check_snap_to(snap: Any, label: str) -> None:
|
|
656
|
+
"""Refuse a snap grid other than beats, for the project's `snap` and for a clip's own."""
|
|
657
|
+
if isinstance(snap, dict) and str(snap.get("to") or "") not in ("", "none", "beats"):
|
|
658
|
+
die(f'{label}.to: only "beats" (or "none") is a beat grid this skill can measure, got '
|
|
659
|
+
f'{snap.get("to")!r}', kind="input")
|
|
620
660
|
|
|
621
661
|
|
|
622
662
|
_LAST_DOC: Dict[str, Any] = {} # the JSON document the most recent sh() child printed
|
|
@@ -1298,6 +1338,13 @@ def main() -> int:
|
|
|
1298
1338
|
if len(names) > 1:
|
|
1299
1339
|
if args.write_project:
|
|
1300
1340
|
die("--write-project writes one project; name a single template")
|
|
1341
|
+
# A85: a pack runs one full render per destination and handed none of these to them,
|
|
1342
|
+
# so --stop-after did not stop, --cache was not read and --work/--keep kept nothing
|
|
1343
|
+
for flag, value in (("--stop-after", args.stop_after), ("--cache", args.cache), ("--from", args.from_stage),
|
|
1344
|
+
("--work", args.work), ("--keep", args.keep), ("--export-timeline", args.export_timeline)):
|
|
1345
|
+
if value:
|
|
1346
|
+
die(f"{flag} applies to one render, and --template {args.template} is a pack of {len(names)} "
|
|
1347
|
+
f"({', '.join(names)}), each a full render of its own: name a single template to use {flag}")
|
|
1301
1348
|
return render_pack(names, args)
|
|
1302
1349
|
proj: Dict[str, Any] = template_project(names[0], args)
|
|
1303
1350
|
base = Path.cwd()
|
|
@@ -1444,9 +1491,7 @@ def main() -> int:
|
|
|
1444
1491
|
# so a project that does not name "snap" builds the command line 1.16 built.
|
|
1445
1492
|
snap_spec = proj.get("snap") or {}
|
|
1446
1493
|
snap_reports: List[Dict[str, Any]] = []
|
|
1447
|
-
|
|
1448
|
-
die(f'snap.to: only "beats" (or "none") is a beat grid this skill can measure, got '
|
|
1449
|
-
f'{snap_spec.get("to")!r}', kind="input")
|
|
1494
|
+
check_snap_to(snap_spec, "snap")
|
|
1450
1495
|
|
|
1451
1496
|
# ---- clips
|
|
1452
1497
|
# 2.2.1: every clip source is checked before the first one is cut, and every problem is named
|
package/scripts/scenes.py
CHANGED
|
@@ -146,6 +146,20 @@ def main() -> int:
|
|
|
146
146
|
add_common(ap)
|
|
147
147
|
args = ap.parse_args()
|
|
148
148
|
apply_common(args)
|
|
149
|
+
if args.highlights < 0:
|
|
150
|
+
die(f"--highlights must be 0 or more (the number of ranges to propose), got {args.highlights}")
|
|
151
|
+
if not args.highlights:
|
|
152
|
+
# A145: these shape or write the --highlights picks; without it they were accepted and
|
|
153
|
+
# nothing was written (--edl FILE completed with no file)
|
|
154
|
+
for flag, given in (("--edl", args.edl is not None), ("--target", args.target is not None),
|
|
155
|
+
("--max-scene", args.max_scene != ap.get_default("max_scene"))):
|
|
156
|
+
if given:
|
|
157
|
+
die(f"{flag} applies to the ranges --highlights proposes, and --highlights was not given; "
|
|
158
|
+
"add --highlights N")
|
|
159
|
+
if args.target is not None and args.target <= 0:
|
|
160
|
+
die(f"--target must be more than 0 seconds, got {args.target:g}")
|
|
161
|
+
if args.max_scene <= 0:
|
|
162
|
+
die(f"--max-scene must be more than 0 seconds, got {args.max_scene:g}")
|
|
149
163
|
if args.highlights: # the EDL is written only with --highlights
|
|
150
164
|
guard_sidecar(args.edl, args.input, siblings=(args.sheet,))
|
|
151
165
|
|
package/scripts/sequence.py
CHANGED
|
@@ -96,6 +96,10 @@ def main() -> int:
|
|
|
96
96
|
|
|
97
97
|
if args.fps <= 0:
|
|
98
98
|
die("--fps must be > 0")
|
|
99
|
+
for flag, value in (("--width", args.width), ("--height", args.height)):
|
|
100
|
+
# A109: 0 read as "not given" and a negative size reached ffmpeg (kind ffmpeg)
|
|
101
|
+
if value is not None and value < 1:
|
|
102
|
+
die(f"{flag} must be a positive number of pixels, got {value}")
|
|
99
103
|
try:
|
|
100
104
|
rate = exact_rate(args.fps) # 29.97 is 30000/1001, as every other tool reads it
|
|
101
105
|
except TimelineError as exc:
|
package/scripts/silence.py
CHANGED
|
@@ -627,6 +627,9 @@ def main() -> int:
|
|
|
627
627
|
if not math.isfinite(args.margin) or args.margin < 0:
|
|
628
628
|
# nan made inside_margin() drop every silence and re-encode the whole file unchanged
|
|
629
629
|
die(f"--margin must be a finite number of seconds, 0 or more (got {args.margin:g})", kind="input")
|
|
630
|
+
if args.min_silence < 0:
|
|
631
|
+
# A52: a negative length reached silencedetect, which refused it (kind ffmpeg)
|
|
632
|
+
die(f"--min-silence must be 0 or more seconds, got {args.min_silence:g}", kind="input")
|
|
630
633
|
for flag, value in (("--margin-lead", args.margin_lead), ("--margin-tail", args.margin_tail)):
|
|
631
634
|
if value is not None and (not math.isfinite(value) or value < 0):
|
|
632
635
|
die(f"{flag} must be a finite number of seconds, 0 or more (got {value:g})", kind="input")
|
package/scripts/sync.py
CHANGED
|
@@ -299,6 +299,12 @@ def main() -> int:
|
|
|
299
299
|
add_common(ap)
|
|
300
300
|
args = ap.parse_args()
|
|
301
301
|
apply_common(args)
|
|
302
|
+
# A190: with a search range of 0 or below no lag is tried, and the run reported an offset
|
|
303
|
+
# (0.012 s at confidence 0) for a correlation it never made
|
|
304
|
+
if args.max_offset <= 0:
|
|
305
|
+
die(f"--max-offset must be more than 0 seconds (the largest offset searched), got {args.max_offset:g}")
|
|
306
|
+
if args.analyze_seconds <= 0:
|
|
307
|
+
die(f"--analyze-seconds must be more than 0 seconds, got {args.analyze_seconds:g}")
|
|
302
308
|
if args.analyze_seconds > 900:
|
|
303
309
|
die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
|
|
304
310
|
args.sources = [args.second] + list(args.more_sources)
|
package/scripts/verify.py
CHANGED
|
@@ -163,6 +163,10 @@ def main() -> int:
|
|
|
163
163
|
STATE.dry_run = False
|
|
164
164
|
info("--dry-run is ignored by verify.py: its steps run and write their outputs")
|
|
165
165
|
|
|
166
|
+
if args.seconds <= 0:
|
|
167
|
+
# A190: every step was handed a cut of no length and failed, which read as the tools'
|
|
168
|
+
# fault ("9 of 11 verification steps failed") rather than the flag's
|
|
169
|
+
die(f"--seconds must be more than 0 (the length of the test cut), got {args.seconds:g}")
|
|
166
170
|
files = collect(args.paths)
|
|
167
171
|
tmp = None
|
|
168
172
|
if args.out:
|
package/scripts/waveform.py
CHANGED
|
@@ -10,9 +10,10 @@ generated fresh, there is no source picture involved.
|
|
|
10
10
|
--style waveform draws the amplitude over time; --style spectrum draws a
|
|
11
11
|
frequency-over-time heatmap instead, which reads more information out of
|
|
12
12
|
dense mixes at the cost of being less immediately readable to a general
|
|
13
|
-
audience. Both accept --width/--height and --
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
audience. Both accept --width/--height and --split-channels (stereo drawn as
|
|
14
|
+
two separate lanes instead of summed to one); waveform additionally takes
|
|
15
|
+
--color and --waveform-mode (how each sample is drawn). The spectrum is drawn
|
|
16
|
+
in showspectrum's own colour map, so --color with --style spectrum is refused.
|
|
16
17
|
|
|
17
18
|
Examples:
|
|
18
19
|
python3 waveform.py podcast.wav -o waveform.mp4
|
|
@@ -54,7 +55,7 @@ def main() -> int:
|
|
|
54
55
|
ap.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
|
|
55
56
|
ap.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
|
|
56
57
|
ap.add_argument("--fps", type=float, default=25.0, help="output frame rate (default 25)")
|
|
57
|
-
ap.add_argument("--color", default="lime", help="channel colour(s), pipe-separated per channel, e.g. 'lime' or 'cyan|magenta' (default lime)")
|
|
58
|
+
ap.add_argument("--color", default="lime", help="--style waveform only: channel colour(s), pipe-separated per channel, e.g. 'lime' or 'cyan|magenta' (default lime)")
|
|
58
59
|
ap.add_argument("--background", default="black", help="background colour (default black)")
|
|
59
60
|
ap.add_argument("--waveform-mode", choices=WAVEFORM_MODES, default="line", help="--style waveform only: how each sample is drawn (default line)")
|
|
60
61
|
ap.add_argument("--split-channels", action="store_true", help="draw each channel in its own lane instead of summing to one")
|
|
@@ -97,6 +98,14 @@ def main() -> int:
|
|
|
97
98
|
args.width, args.height = frame["w"], frame["h"]
|
|
98
99
|
args.fps = float(PLATFORMS[args.platform].get("fps") or args.fps)
|
|
99
100
|
info(f"--platform {args.platform}: {args.width}x{args.height} @ {args.fps:g}fps")
|
|
101
|
+
if args.style == "spectrum" and args.color != ap.get_default("color"):
|
|
102
|
+
# A144: showspectrum takes no colour, so the flag was accepted and the heatmap drawn in
|
|
103
|
+
# its own colour map (a brand's primary colour is a default and is not refused)
|
|
104
|
+
die(f"--color {args.color} colours the waveform's channels; --style spectrum is drawn in showspectrum's own "
|
|
105
|
+
"colour map and takes no colour", hint="drop --color, or use --style waveform")
|
|
106
|
+
if args.style == "spectrum" and args.waveform_mode != ap.get_default("waveform_mode"):
|
|
107
|
+
die(f"--waveform-mode {args.waveform_mode} is how --style waveform draws each sample; --style spectrum has no "
|
|
108
|
+
"such mode", hint="drop --waveform-mode, or use --style waveform")
|
|
100
109
|
brand = load_brand(args.brand) if args.brand else None
|
|
101
110
|
if brand:
|
|
102
111
|
bc = brand.get("colors") or {}
|