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.
- package/README.md +14 -11
- package/SKILL.md +6 -1
- package/docs/contract.md +21 -8
- package/package.json +1 -1
- package/references/gotchas.md +15 -0
- package/references/scripts.md +127 -7
- package/scripts/_common/__init__.py +4 -4
- package/scripts/_common/alpha.py +465 -0
- package/scripts/_common/decision.py +153 -15
- package/scripts/_common/probe.py +175 -9
- package/scripts/_common/runner.py +3 -2
- package/scripts/_contract.py +69 -17
- package/scripts/_smartcut.py +430 -0
- package/scripts/background.py +33 -6
- package/scripts/check.py +64 -1
- package/scripts/color.py +147 -30
- package/scripts/cut.py +99 -8
- package/scripts/export.py +12 -81
- package/scripts/graphics.py +51 -10
- package/scripts/overlay.py +49 -6
- package/scripts/probe.py +47 -1
- package/scripts/scenes.py +46 -11
package/scripts/graphics.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 +=
|
|
757
|
-
|
|
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
|
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
-
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
|
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:
|