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/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
- if snap_spec and str(snap_spec.get("to") or "") not in ("", "none", "beats"):
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
 
@@ -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:
@@ -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:
@@ -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 --color; waveform additionally
14
- takes --waveform-mode (how each sample is drawn) and --split-channels
15
- (stereo drawn as two separate lanes instead of summed to one).
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 {}