ffmpeg-skill 3.2.1 → 3.2.3

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.
@@ -24,6 +24,8 @@ Examples:
24
24
  python3 graphics.py reel.mp4 --template sticker --text "NEW" --position top-right --platform tiktok
25
25
  python3 graphics.py reel.mp4 --template hook --title "How I cut this in one command" --duration 3
26
26
  python3 graphics.py clip.mp4 --template meme --top "when the render" --bottom "finally finishes"
27
+ python3 background.py --transparent --duration 4 --width 1920 --height 1080 -o canvas.mov
28
+ python3 graphics.py canvas.mov --template lower-third --name "Ada Lovelace" --keep-alpha -o lower_third.mov
27
29
  """
28
30
  import argparse
29
31
  import os
@@ -41,6 +43,8 @@ from _common import (guard_sidecar, require_filter, aac_args, add_common, brand_
41
43
  EMOJI_ASSET_HINT, text_width_em, drawtext_shaping, wrap_text, WRAP_MODES,
42
44
  SAFE_WIDTH_FRACTION, drawtext_draws_newline_glyph, is_multiline, _DRAWTEXT_PENDING)
43
45
  from _common.emit import note, warn
46
+ from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
47
+ report as alpha_report, source_alpha, tag_args, video_line)
44
48
  from _common.fonts import font_choice, record_font_choice
45
49
  from _common.probe import display_size, square_pixels, square_pixels_note
46
50
  from _ass_overlay import text_overlay_ass, EMOJI_SENTINEL
@@ -128,6 +132,7 @@ def main(force_route: Optional[str] = None) -> int:
128
132
  emo.add_argument("--emoji-max", type=int, default=60, help="most emoji overlays one run may build (default 60)")
129
133
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
130
134
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
135
+ add_alpha_args(ap)
131
136
  add_common(ap)
132
137
  args = ap.parse_args()
133
138
  apply_common(args)
@@ -166,6 +171,10 @@ def main(force_route: Optional[str] = None) -> int:
166
171
  meta = probe(args.input)
167
172
  if not meta.get("video"):
168
173
  die("input has no video stream")
174
+ # --keep-alpha: drawn on the source's alpha (a transparent canvas from background.py
175
+ # --transparent, or any alpha clip), written in an alpha line with the source's tags kept
176
+ alpha_codec = check_alpha_args(args, args.output)
177
+ src_alpha = source_alpha(args, meta, args.input)
169
178
  audio_streams = meta.get("audio_streams") or []
170
179
  if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
171
180
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
@@ -221,7 +230,7 @@ def main(force_route: Optional[str] = None) -> int:
221
230
  has chroma. overlay places a 4:2:0 picture on even rows and columns only: a box that starts on
222
231
  an odd one is placed one earlier with a transparent first column/row (`left`, `top`), so it
223
232
  covers the rows and columns the drawbox did."""
224
- conv = rgb_chain_to_bt709(meta) if draws_chroma([colour], meta) else ""
233
+ conv = rgb_chain_to_bt709(meta) if (alpha_codec or draws_chroma([colour], meta)) else ""
225
234
  if conv:
226
235
  converted.append(colour)
227
236
  edge = f",pad={w + left}:{h + top}:{left}:{top}:color=black@0" if left or top else ""
@@ -488,7 +497,8 @@ def main(force_route: Optional[str] = None) -> int:
488
497
  h = max(3, int(base * 0.008))
489
498
  fps = meta['video'].get('fps') or 30
490
499
  fc.append(f"{layer_src(primary, 1.0, W, h)}[pb]")
491
- if draws_chroma([bg], meta):
500
+ if draws_chroma([bg], meta) or alpha_codec:
501
+ # a layer (overlay composites the alpha too; drawbox leaves it as it was)
492
502
  fc.append(f"{layer_src(bg, 0.5, W, h, 0, (H - h) % 2)}[pbg]")
493
503
  fc.append(f"[0:v][pbg]overlay=x=0:y={(H - h) // 2 * 2}:{en}:eof_action=pass[v1]")
494
504
  else:
@@ -603,7 +613,7 @@ def main(force_route: Optional[str] = None) -> int:
603
613
  "(FFmpeg 8.0-8.0.1 and 8.1.0; fixed in 8.0.2/8.1.1): drop --text-render drawtext to render it "
604
614
  "through libass, or upgrade FFmpeg", "drawtext_newline_glyph", text_render="drawtext")
605
615
 
606
- output = args.output or default_output(args.input, "gfx")
616
+ output = args.output or default_output(args.input, "gfx", default_ext(alpha_codec) if alpha_codec else None)
607
617
  ass_path = None
608
618
  emoji_result = None
609
619
  if route == "ass":
@@ -700,7 +710,10 @@ def main(force_route: Optional[str] = None) -> int:
700
710
  # The boxes with chroma (layers) go under the text, and the picture they and a converted colour
701
711
  # source are overlaid on gets its BT.709 label (an untagged one, tags only), so 7.1+'s overlay
702
712
  # converts neither picture to the other's matrix. The text is drawn in BT.709 (drawn_chain).
703
- as_layers = any(draws_chroma([b[0]], meta) for b in boxes)
713
+ # On an alpha line every box is a layer: overlay composites the box's alpha over the picture's,
714
+ # while drawbox paints the colour and leaves the alpha as it was (a box on a transparent canvas
715
+ # stayed invisible)
716
+ as_layers = bool(alpha_codec) or any(draws_chroma([b[0]], meta) for b in boxes)
704
717
  drawboxes = []
705
718
  for colour, alpha, geo, x, y, w, h, enable, x_expr in boxes:
706
719
  if as_layers:
@@ -723,8 +736,16 @@ def main(force_route: Optional[str] = None) -> int:
723
736
  fc = [f"[0:v]{base_label}null[gbase]"] + [c.replace("[0:v]", "[gbase]") for c in fc]
724
737
  if fc and square:
725
738
  fc = [f"[0:v]{square}[gsq]"] + [c.replace("[0:v]", "[gsq]") for c in fc]
726
-
727
- cmd = ffmpeg_base() + ["-i", args.input]
739
+ if alpha_codec:
740
+ # the picture is drawn on at 4:4:4 with its alpha plane, and every layer is overlaid at
741
+ # 4:4:4 (overlay's default is 4:2:0); drawtext and libass (alpha=1) draw into the alpha too
742
+ fc = [c.replace(":eof_action=pass", ":eof_action=pass:format=yuv444") for c in fc]
743
+ if fc:
744
+ fc = ["[0:v]format=yuva444p[ka0]"] + [c.replace("[0:v]", "[ka0]") for c in fc]
745
+ square = "format=yuva444p" + ("," + square if square else "")
746
+ finish = finish_chain(alpha_codec, args.alpha_mode, meta) if alpha_codec else ""
747
+
748
+ cmd = ffmpeg_base() + (decoder_args_for(meta, args.input) if alpha_codec else []) + ["-i", args.input]
728
749
  if route == "ass" or emoji_overlays:
729
750
  chains = list(fc) if fc else [f"[0:v]{(square + ',') if square else ''}{text or 'null'}[vout]"]
730
751
  last = "vout"
@@ -733,6 +754,8 @@ def main(force_route: Optional[str] = None) -> int:
733
754
  fdir = ass_fonts_dir()
734
755
  if fdir:
735
756
  vf += f":fontsdir={escape_filter_path(fdir)}"
757
+ if alpha_codec:
758
+ vf += ":alpha=1"
736
759
  vf = drawn_chain(vf, meta, [el.get(k) for el in elements for k in ("color", "outline_color", "box_color")],
737
760
  labelled=bool(base_label))
738
761
  chains.append(f"[{last}]{vf}[vtxt]")
@@ -746,16 +769,31 @@ def main(force_route: Optional[str] = None) -> int:
746
769
  if asset not in STATE.plan_inputs:
747
770
  STATE.plan_inputs.append(asset)
748
771
  if eo:
772
+ if alpha_codec:
773
+ # at 4:4:4 like every other layer here (overlay's default 4:2:0 subsamples the picture)
774
+ eo = [re.sub(r"(overlay=x=.*?:enable='[^']*')\[", r"\1:format=yuv444[", c) for c in eo]
749
775
  chains += eo
750
776
  last = "vfinal"
777
+ if finish:
778
+ chains.append(f"[{last}]{finish}[ka_v]")
779
+ last = "ka_v"
751
780
  cmd += ["-filter_complex", ";".join(chains), "-map", f"[{last}]",
752
781
  "-map", f"0:a:{args.audio_stream}?"]
753
782
  elif fc:
754
- cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", f"0:a:{args.audio_stream}?"]
783
+ if finish:
784
+ fc.append(f"[vout]{finish}[ka_v]")
785
+ cmd += ["-filter_complex", ";".join(fc), "-map", "[ka_v]" if finish else "[vout]", "-map", f"0:a:{args.audio_stream}?"]
786
+ else:
787
+ cmd += ["-vf", ",".join(p for p in (square, text, finish) if p) if finish else (square + "," if square else "") + text,
788
+ "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
789
+ if alpha_codec:
790
+ cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta)
791
+ cmd += audio_line(alpha_codec) if meta.get("audio") else ["-an"]
792
+ if STATE.hw:
793
+ note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
755
794
  else:
756
- cmd += ["-vf", (square + "," if square else "") + text, "-map", "0:v:0", "-map", f"0:a:{args.audio_stream}?"]
757
- cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
758
- cmd += aac_args() if meta.get("audio") else ["-an"]
795
+ cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
796
+ cmd += aac_args() if meta.get("audio") else ["-an"]
759
797
  dropped_streams = run_keeping_subtitles(cmd, output)
760
798
  r = probe(output, role="output")
761
799
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {args.template})")
@@ -770,6 +808,9 @@ def main(force_route: Optional[str] = None) -> int:
770
808
  extra["emoji"] = emoji_result
771
809
  if sliced_atoms:
772
810
  extra["broken_inside_word"] = len(sliced_atoms)
811
+ # drawn layers add coverage: with --keep-alpha the written alpha is at or above the source's
812
+ # in every frame; without it, an output that lost a used alpha fails the `alpha` step
813
+ alpha_report(args, alpha_codec, src_alpha, output, extra, composite=True)
773
814
  emit(output, **extra)
774
815
  return 0
775
816
 
@@ -23,6 +23,10 @@ Examples:
23
23
  python3 overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480 --opacity 0.9
24
24
  python3 overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00 --chromakey-similarity 0.15
25
25
  python3 overlay.py talk.mp4 --video cues.mov --position 0,0 --margin 0 --place 0-2.5@12 --place 2.5-6@40
26
+ python3 overlay.py canvas.mov --image logo.png --position center --keep-alpha -o logo_layer.mov
27
+
28
+ A VP9 --video layer stored with an alpha layer (a WebM from export.py --preset vp9-alpha) is decoded
29
+ with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 decoder drops it).
26
30
  """
27
31
  import argparse
28
32
  import re
@@ -33,6 +37,8 @@ from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as
33
37
  from _common import still_input_args # noqa: E402
34
38
  from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
35
39
  from _common.emit import note, warn
40
+ from _common.alpha import (add_alpha_args, audio_line, check_alpha_args, decoder_args_for, default_ext, finish_chain,
41
+ has_alpha_plane, report as alpha_report, source_alpha, tag_args, video_line)
36
42
  from _common.probe import display_size, square_pixels, square_pixels_note
37
43
  from _common.wrap import wrap_text
38
44
  from _common import text_width_em
@@ -278,6 +284,7 @@ def main() -> int:
278
284
  enc = ap.add_argument_group("encoding")
279
285
  enc.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
280
286
  enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
287
+ add_alpha_args(ap)
281
288
  add_common(ap)
282
289
  args = ap.parse_args()
283
290
  apply_common(args)
@@ -345,6 +352,10 @@ def main() -> int:
345
352
  meta = probe(args.input)
346
353
  if not meta.get("video"):
347
354
  die("input has no video stream")
355
+ # --keep-alpha: composited onto the main input's alpha (a transparent canvas from background.py
356
+ # --transparent, or any alpha clip) and written in an alpha line with the source's tags kept
357
+ alpha_codec = check_alpha_args(args, args.output)
358
+ src_alpha = source_alpha(args, meta, args.input)
348
359
  audio_streams = meta.get("audio_streams") or []
349
360
  if audio_streams and not (0 <= args.audio_stream < len(audio_streams)):
350
361
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
@@ -395,7 +406,7 @@ def main() -> int:
395
406
  if args.place and (args.start or args.end or args.fade):
396
407
  die("--place sets when each piece shows: drop --start / --end / --fade")
397
408
 
398
- output = args.output or default_output(args.input, "overlay")
409
+ output = args.output or default_output(args.input, "overlay", default_ext(alpha_codec) if alpha_codec else None)
399
410
  enable = enable_expr(start, end)
400
411
  cmd = ffmpeg_base() + ["-i", args.input]
401
412
 
@@ -468,7 +479,8 @@ def main() -> int:
468
479
  chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
469
480
  elif args.scale:
470
481
  chain.append(f"scale={args.scale}:-2")
471
- chain.append("format=yuva420p")
482
+ # an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
483
+ chain.append("format=yuva444p" if alpha_codec else "format=yuva420p")
472
484
  if args.chromakey:
473
485
  chain.append(f"chromakey={args.chromakey}:{args.chromakey_similarity:g}:{args.chromakey_blend:g}")
474
486
  if args.opacity < 1:
@@ -477,11 +489,18 @@ def main() -> int:
477
489
  ov = f"overlay={x}:{y}:format=auto"
478
490
  if enable:
479
491
  ov += f":enable='{enable}'"
480
- cmd = ffmpeg_base() + ["-i", args.input, "-i", args.video]
492
+ # a VP9 layer stored with an alpha layer is decoded with libvpx-vp9: FFmpeg's own decoder
493
+ # drops the alpha, and the layer covered the picture as an opaque rectangle
494
+ cmd = ffmpeg_base() + ["-i", args.input] + decoder_args_for(pip_meta, args.video) + ["-i", args.video]
481
495
  # both pictures converted by their own colour tags to the BT.709 the output is tagged with;
482
496
  # beside a converted one an untagged one gets its BT.709 label, tags only, so 7.1+'s
483
497
  # overlay does not convert one to the other's "unknown" (BT.601) matrix
484
498
  base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
499
+ if pip and has_alpha_plane(pip_meta, args.video):
500
+ # an RGB layer with alpha (qtrle, PNG in .mov, a graphics.py/background.py alpha line) is
501
+ # converted to BT.709 YUV with its alpha plane: the 4:4:4 the conversion ends in had none,
502
+ # and the layer covered the picture as an opaque rectangle
503
+ pip = pip.replace("format=yuv444p", "format=yuva444p")
485
504
  if base or pip:
486
505
  base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
487
506
  # overlay mixes samples: a full-range picture is taken to limited range (#410)
@@ -541,13 +560,37 @@ def main() -> int:
541
560
  if square and "-filter_complex" in cmd:
542
561
  i = cmd.index("-filter_complex") + 1
543
562
  cmd[i] = f"[0:v]{square}[sq];" + cmd[i].replace("[0:v]", "[sq]")
544
- cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
545
- cmd += aac_args() if meta.get("audio") else ["-an"]
563
+ if alpha_codec:
564
+ # the main picture keeps its alpha plane at 4:4:4, every layer is overlaid at 4:4:4 (overlay
565
+ # composites the alpha when the main picture has one) and drawtext draws into the alpha too;
566
+ # the line's pixel format ends the graph so nothing after it can drop the plane
567
+ finish = finish_chain(alpha_codec, args.alpha_mode, meta)
568
+ first = cmd.index("-i")
569
+ cmd[first:first] = decoder_args_for(meta, args.input)
570
+ if "-filter_complex" in cmd:
571
+ i = cmd.index("-filter_complex") + 1
572
+ cmd[i] = ("[0:v]format=yuva444p[ka0];" + cmd[i].replace("[0:v]", "[ka0]").replace(":format=auto", ":format=yuv444")
573
+ + f";[out]{finish}[ka_v]")
574
+ cmd[cmd.index("[out]")] = "[ka_v]"
575
+ else:
576
+ i = cmd.index("-vf") + 1
577
+ cmd[i] = f"format=yuva444p,{cmd[i]},{finish}"
578
+ cmd += video_line(alpha_codec) + tag_args(meta) + cfr_args(meta)
579
+ cmd += audio_line(alpha_codec) if meta.get("audio") else ["-an"]
580
+ if STATE.hw:
581
+ note(f"--hw: --alpha-codec {alpha_codec} stays on the CPU (no VideoToolbox encoder writes it with its alpha)")
582
+ else:
583
+ cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
584
+ cmd += aac_args() if meta.get("audio") else ["-an"]
546
585
  dropped_streams = run_keeping_subtitles(cmd, output)
547
586
  if not STATE.dry_run:
548
587
  result = probe(output, role="output")
549
588
  info(f"wrote {output} ({fmt_secs(result['duration'])})")
550
- emit(output, dropped_non_av_streams=dropped_streams, **({"font_used": font_used} if args.text else {}))
589
+ extra = {"dropped_non_av_streams": dropped_streams, **({"font_used": font_used} if args.text else {})}
590
+ # a layer adds coverage: with --keep-alpha the written alpha is at or above the main input's in
591
+ # every frame; without it, an output that lost the main input's used alpha fails the `alpha` step
592
+ alpha_report(args, alpha_codec, src_alpha, output, extra, composite=True)
593
+ emit(output, **extra)
551
594
  return 0
552
595
 
553
596
 
package/scripts/probe.py CHANGED
@@ -8,11 +8,38 @@ Examples:
8
8
  python3 probe.py input.mp4
9
9
  python3 probe.py a.mp4 b.mov --compact
10
10
  python3 probe.py input.mp4 --field duration
11
+ python3 probe.py input.mp4 --keyframes-near 12.4,47 --keyframe-window 5
12
+
13
+ --keyframes-near T[,T...] adds `keyframes`: for each time, the video keyframes within
14
+ --keyframe-window seconds of it, and the nearest one at or before it and at or after it -- the
15
+ points a stream copy can start at, and where cut.py --smart's re-encode around a cut ends.
11
16
  """
12
17
  import argparse
18
+ import math
13
19
  import sys
14
20
 
15
- from _common import add_common, analyze_levels, apply_common, print_json, probe
21
+ from _common import add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
22
+
23
+
24
+ def keyframe_candidates(result: dict, times: list, window: float) -> list:
25
+ """For each time (seconds from the file's start), the keyframes within `window` of it and the
26
+ nearest of those at or before / at or after it (null when none is within the window), on the
27
+ same clock (the file's start_time subtracted). The picture is the stream probe() reports (`V`:
28
+ not a cover picture). A failed read is an error, not an empty neighbourhood."""
29
+ origin = float(result.get("start_time") or 0.0)
30
+ out = []
31
+ for t in times:
32
+ # keyframe_times() reads past hi (an interval's end is exclusive), so one on the edge is kept
33
+ read = keyframe_times(result["file"], origin + t - window, origin + t + window, "V:0")
34
+ if read is None:
35
+ die(f"ffprobe could not read the keyframes of {result['file']} around {t:g}s", kind="ffmpeg")
36
+ # the read starts at the keyframe at or before the window, which may lie outside it
37
+ keys = [k for k in (round(k - origin, 6) for k in read) if t - window - 1e-6 <= k <= t + window + 1e-6]
38
+ before = [k for k in keys if k <= t + 1e-6]
39
+ after = [k for k in keys if k >= t - 1e-6]
40
+ out.append({"at": t, "window": window, "keyframes": keys, "before": before[-1] if before else None,
41
+ "after": after[0] if after else None})
42
+ return out
16
43
 
17
44
 
18
45
  def main() -> int:
@@ -21,11 +48,27 @@ def main() -> int:
21
48
  ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
22
49
  ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
23
50
  ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
51
+ ap.add_argument("--keyframes-near", metavar="T[,T...]",
52
+ help="also list the video keyframes around these times (seconds, mm:ss or hh:mm:ss.ms): the ones within "
53
+ "--keyframe-window and the nearest before and after each, as `keyframes`")
54
+ ap.add_argument("--keyframe-window", type=float, default=5.0, metavar="S",
55
+ help="--keyframes-near: seconds either side of each time to read (default 5)")
24
56
  add_common(ap) # --json / --dry-run / --fast / --progress accepted for uniformity; output is JSON already
25
57
  args = ap.parse_args()
26
58
  apply_common(args)
27
59
 
60
+ if not math.isfinite(args.keyframe_window) or args.keyframe_window <= 0:
61
+ die(f"--keyframe-window must be a positive, finite number of seconds, got {args.keyframe_window:g}")
62
+ if args.keyframes_near is not None and not any(t.strip() for t in args.keyframes_near.split(",")):
63
+ die("--keyframes-near takes one or more times (e.g. 12.4,47), got none")
28
64
  results = [probe(p) for p in args.inputs]
65
+ if args.keyframes_near:
66
+ for r in results:
67
+ fps = (r.get("video") or {}).get("fps")
68
+ times = [time_arg(t.strip(), "--keyframes-near", fps) for t in args.keyframes_near.split(",") if t.strip()]
69
+ if any(not math.isfinite(t) or t < 0 for t in times):
70
+ die("--keyframes-near takes finite times at or after 0")
71
+ r["keyframes"] = keyframe_candidates(r, times, args.keyframe_window) if r.get("video") else []
29
72
  if args.analyze:
30
73
  for r in results:
31
74
  if r.get("video"):
@@ -58,6 +101,9 @@ def main() -> int:
58
101
  line += f" | audio {a.get('codec')} {a.get('channels')}ch {a.get('sample_rate')}Hz"
59
102
  else:
60
103
  line += " | no audio"
104
+ for k in r.get("keyframes") or []:
105
+ before, after = ("-" if x is None else f"{x:g}" for x in (k["before"], k["after"]))
106
+ line += f" | keyframes near {k['at']:g}s: {before}/{after} ({len(k['keyframes'])} within {k['window']:g}s)"
61
107
  print(line)
62
108
  return 0
63
109
 
package/scripts/scenes.py CHANGED
@@ -31,6 +31,7 @@ from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
31
31
  from _common.emit import note, record_fallback # noqa: E402
32
32
  from _common.color import bt709_label_filter # noqa: E402
33
33
  from _common.probe import square_pixels # noqa: E402
34
+ from _common import dry_run_input_pending # noqa: E402
34
35
 
35
36
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
36
37
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -148,8 +149,25 @@ def main() -> int:
148
149
  guard_sidecar(args.edl, args.input, siblings=(args.sheet,))
149
150
 
150
151
  meta = probe(args.input)
151
- if not meta.get("video"):
152
- die("input has no video stream")
152
+ # Under --dry-run in a render.py/batch.py plan the input may be an earlier stage's output that
153
+ # was never written: every measurement is skipped (diagnostics.skipped measurement_input_pending)
154
+ # and the document keeps its shape with nothing measured, as loudness.py/cut.py do -- not an
155
+ # ffmpeg failure that stops the plan from being written.
156
+ pending = dry_run_input_pending(args.input)
157
+ # An audio-only file (WAV/FLAC/MP3 ...) is measured too (after 3.2.0): the beat grid, the loudness
158
+ # peaks and the speech ratio are measurements of the sound, and a music bed is usually
159
+ # delivered without a picture. It has no picture to cut, so it is one scene from 0 to its
160
+ # duration -- the same answer a video with no cut in it gets -- and the result says
161
+ # audio_only: true. What needs a picture (--sheet, --shots) is refused, not skipped.
162
+ audio_only = not meta.get("video")
163
+ if audio_only:
164
+ if not meta.get("audio"):
165
+ die("input has no video or audio stream")
166
+ picture_flags = [f for f, on in (("--sheet", args.sheet), ("--shots", args.shots)) if on]
167
+ if picture_flags:
168
+ die(f"{' and '.join(picture_flags)} need{'s' if len(picture_flags) == 1 else ''} a video stream; "
169
+ "this file is audio-only (input has no video stream). --beats, --audio-peaks, --speech "
170
+ "and --highlights measure the sound alone.", kind="input")
153
171
  # Only parsed when it is going to be used: --beat-range is a --beats flag, and a run that
154
172
  # never asked for beats should not be able to die on one.
155
173
  beat_range = parse_beat_range(args.beat_range) if args.beats else (60.0, 200.0)
@@ -159,18 +177,22 @@ def main() -> int:
159
177
  if args.beat_step <= 0:
160
178
  die("--beat-step must be greater than 0", kind="input")
161
179
  dur = meta.get("duration") or 0.0
162
- cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
180
+ cuts = [0.0] if (audio_only or pending) else detect_scenes(args.input, args.threshold, args.min_scene, dur, args.ratio)
181
+ has_audio = bool(meta.get("audio")) and not pending
163
182
  bounds = cuts + [dur]
164
183
  step_s = 0.5
165
184
  # With --beats the file is decoded once, at the finer rate, and both envelopes come from that
166
185
  # one pass: the 0.5 s scene blocks are an exact multiple of the 10 ms onset blocks.
167
186
  beat_rate = 22050
168
187
  need_fine = args.beats or args.audio_peaks or args.speech
169
- fine_samples = decode_pcm_mono(args.input, beat_rate, check=False) if (need_fine and meta.get("audio")) else None
188
+ # --beats decodes with check=True: a decode that fails is an ffmpeg failure, not an empty
189
+ # envelope reported as "no steady pulse in this audio" (the scene scoring alone tolerates it
190
+ # and runs on the picture)
191
+ fine_samples = decode_pcm_mono(args.input, beat_rate, check=bool(args.beats)) if (need_fine and has_audio) else None
170
192
  if fine_samples is not None:
171
193
  env = audio_envelope(args.input, step_s, rate=beat_rate, samples=fine_samples)
172
194
  else:
173
- env = audio_envelope(args.input, step_s) if meta.get("audio") else []
195
+ env = audio_envelope(args.input, step_s) if has_audio else []
174
196
 
175
197
  scenes = []
176
198
  for i in range(len(bounds) - 1):
@@ -192,6 +214,8 @@ def main() -> int:
192
214
  peaks.sort(key=lambda p: p["time"])
193
215
 
194
216
  result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
217
+ if audio_only:
218
+ result["audio_only"] = True
195
219
  info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
196
220
 
197
221
  if args.shots:
@@ -203,7 +227,7 @@ def main() -> int:
203
227
  info(f"--shots: {len(shots)} shots (" + ", ".join(f"{v} {k}" for k, v in sorted(counts.items())) + ")")
204
228
 
205
229
  if args.audio_peaks:
206
- if not meta.get("audio"):
230
+ if not has_audio:
207
231
  result["audio_peaks_db"] = []
208
232
  info("--audio-peaks: no audio stream, nothing to measure")
209
233
  else:
@@ -212,7 +236,7 @@ def main() -> int:
212
236
  info(f"--audio-peaks: {len(result['audio_peaks_db'])} peaks")
213
237
 
214
238
  if args.speech:
215
- if not meta.get("audio"):
239
+ if not has_audio:
216
240
  result["speech"] = []
217
241
  info("--speech: no audio stream, nothing to measure")
218
242
  else:
@@ -224,9 +248,15 @@ def main() -> int:
224
248
  # A beat grid is a measurement of the music's periodicity, not a statement about where a
225
249
  # cut belongs. scenes.py reports what it measured, including a low confidence: reporting a
226
250
  # weak measurement is honest, and only a tool that CHANGES a file refuses to act on one.
227
- fine = rms_envelope(fine_samples or [], max(1, int(round(beat_rate * args.beat_step))))
228
- grid = beat_grid(fine, args.beat_step, bpm_range=beat_range,
229
- min_confidence=args.min_confidence, duration=dur)
251
+ # The envelope's real step is a whole number of samples: 220 / 22050 = 9.977 ms for the
252
+ # default 10 ms. Timing the onsets as if it were 10 ms stretched every measured time by
253
+ # 0.23 % -- 68 ms by the end of a 30 s track, the "40-60 ms off" a click track showed --
254
+ # so the grid is timed (and step_s reported) at the step the envelope actually has.
255
+ beat_block = max(1, round(beat_rate * args.beat_step))
256
+ fine = rms_envelope(fine_samples or [], beat_block)
257
+ grid = beat_grid(fine, beat_block / beat_rate, bpm_range=beat_range,
258
+ min_confidence=args.min_confidence, duration=dur,
259
+ pcm=fine_samples, pcm_rate=beat_rate)
230
260
  result["beats"] = grid["beats"]
231
261
  result["beat_grid"] = {
232
262
  # The regular grid AND the subset a measured onset supports. A tool that MOVES
@@ -238,8 +268,13 @@ def main() -> int:
238
268
  "onsets": len(grid["onsets"]), "supported": grid["supported"],
239
269
  "unsupported": grid["unsupported"], "method": grid["method"],
240
270
  "step_s": grid["step_s"], "range_bpm": grid["range_bpm"], "usable": grid["usable"],
271
+ # after 3.2.0, additive: how each onset was timed below the envelope step
272
+ # ("pcm-window-ratio": placed on the decoded samples; null: at the step)
273
+ "onset_refinement": grid["onset_refinement"],
241
274
  }
242
- if grid["tempo_bpm"] is None:
275
+ if pending:
276
+ info("--beats: [dry-run] the input does not exist yet, nothing measured")
277
+ elif grid["tempo_bpm"] is None:
243
278
  info(f"--beats: no steady pulse in this audio (confidence {grid['confidence']:.2f}) -- "
244
279
  "speech, ambience or rubato has no tempo to measure")
245
280
  else: