ffmpeg-skill 3.8.15 → 3.8.17
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/docs/contract.md +3 -3
- package/mcp/server.py +143 -21
- package/package.json +1 -1
- package/references/gotchas.md +12 -2
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/alpha.py +1 -0
- package/scripts/_common/color.py +30 -1
- package/scripts/_common/decision.py +57 -0
- package/scripts/_common/emit.py +55 -6
- package/scripts/_common/probe.py +14 -1
- package/scripts/_common/runner.py +452 -80
- package/scripts/_common/wrap.py +24 -8
- package/scripts/_contract.py +18 -2
- package/scripts/batch.py +7 -1
- package/scripts/broll.py +5 -2
- package/scripts/caption.py +11 -7
- package/scripts/color.py +3 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +9 -3
- package/scripts/deinterlace.py +12 -7
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +23 -4
- package/scripts/fit.py +45 -7
- package/scripts/freeze.py +9 -7
- package/scripts/graphics.py +7 -4
- package/scripts/loop.py +7 -2
- package/scripts/metadata.py +5 -2
- package/scripts/overlay.py +92 -4
- package/scripts/pad.py +7 -2
- package/scripts/proxy.py +2 -2
- package/scripts/redact.py +5 -2
- package/scripts/render.py +24 -12
- package/scripts/silence.py +2 -2
- package/scripts/speedramp.py +49 -14
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +2 -2
- package/scripts/straighten.py +2 -2
- package/scripts/verify.py +32 -2
package/scripts/overlay.py
CHANGED
|
@@ -29,13 +29,14 @@ A VP9 --video layer stored with an alpha layer (a WebM from export.py --preset v
|
|
|
29
29
|
with libvpx-vp9, so its transparency reaches the composite (FFmpeg's own VP9 decoder drops it).
|
|
30
30
|
"""
|
|
31
31
|
import argparse
|
|
32
|
+
import ast
|
|
32
33
|
import re
|
|
33
34
|
import sys
|
|
34
35
|
from typing import Any, Dict, List, Optional
|
|
35
36
|
|
|
36
37
|
from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
|
|
37
38
|
from _common import still_input_args # noqa: E402
|
|
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, overlay_format, 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, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
|
|
39
|
+
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, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, cfr_filter, cfr_at_input, with_cfr, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
|
|
39
40
|
from _common.emit import note, warn
|
|
40
41
|
from _common.color import is_rgb
|
|
41
42
|
from _common.decision import length_output_t, window_expr, windowed_overlay_x
|
|
@@ -86,6 +87,56 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
|
|
|
86
87
|
return x, y
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
def _scaled_height(w: int, src_w: int, src_h: int, factor: int) -> int:
|
|
91
|
+
"""The height scale=W:-1 (factor 1) or scale=W:-2 (factor 2) gives a src_w x src_h picture:
|
|
92
|
+
av_rescale(w, ih, iw * factor) * factor, rounded half away from zero as FFmpeg rounds it."""
|
|
93
|
+
den = src_w * factor
|
|
94
|
+
return max(factor, (2 * w * src_h + den) // (2 * den) * factor)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def on_grid(x: str, y: str, main: "tuple", layer: "tuple") -> "Optional[tuple]":
|
|
98
|
+
"""Where the layer goes so its top-left pixel is at (x, y) in luma, x and y being the overlay
|
|
99
|
+
expressions position_exprs() gives, as overlay evaluates them (the integer part).
|
|
100
|
+
|
|
101
|
+
overlay places a layer on a 4:2:0 picture on the chroma grid: an odd x or y is taken down to
|
|
102
|
+
the even one before it (#512: --position 11,13 drew the logo at 10,12, and a centred or
|
|
103
|
+
right-aligned layer whose position came out odd moved the same way). So the layer gets one
|
|
104
|
+
transparent column/row on the left/top where x/y is odd, and is overlaid at the even position
|
|
105
|
+
before it: its luma lands where it was asked, and the chroma sample on that edge is shared
|
|
106
|
+
with the pixel outside it. Returns (x_even, y_even, pad filter or ""), or None when a size is
|
|
107
|
+
unknown (a dry run's pending input): the overlay then gets the expressions, as before."""
|
|
108
|
+
(mw, mh), (lw, lh) = main, layer
|
|
109
|
+
if not (mw and mh and lw and lh):
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
def arith(node: Any) -> float:
|
|
113
|
+
# the four operations on whole numbers position_exprs() writes, and nothing else
|
|
114
|
+
if isinstance(node, ast.Expression):
|
|
115
|
+
return arith(node.body)
|
|
116
|
+
if isinstance(node, ast.Constant) and isinstance(node.value, int):
|
|
117
|
+
return float(node.value)
|
|
118
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.USub, ast.UAdd)):
|
|
119
|
+
return -arith(node.operand) if isinstance(node.op, ast.USub) else arith(node.operand)
|
|
120
|
+
if isinstance(node, ast.BinOp) and isinstance(node.op, (ast.Add, ast.Sub, ast.Mult, ast.Div)):
|
|
121
|
+
a, b = arith(node.left), arith(node.right)
|
|
122
|
+
return {ast.Add: a + b, ast.Sub: a - b, ast.Mult: a * b}.get(type(node.op), a / b if b else 0.0)
|
|
123
|
+
raise ValueError(f"not a position: {ast.dump(node)}")
|
|
124
|
+
|
|
125
|
+
def value(expr: str, big: int, small: int, axis: str) -> Optional[int]:
|
|
126
|
+
num = re.sub(axis.upper(), str(big), re.sub(axis, str(small), expr))
|
|
127
|
+
try:
|
|
128
|
+
return int(arith(ast.parse(num, mode="eval"))) # the integer part, as overlay's (int) cast takes it
|
|
129
|
+
except (SyntaxError, ValueError):
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
px, py = value(x, mw, lw, "w"), value(y, mh, lh, "h")
|
|
133
|
+
if px is None or py is None:
|
|
134
|
+
return None
|
|
135
|
+
ox, oy = px & 1, py & 1 # two's complement: -3 is one column right of -4
|
|
136
|
+
pad = f"pad=iw+{ox}:ih+{oy}:{ox}:{oy}:color=black@0" if ox or oy else ""
|
|
137
|
+
return px - ox, py - oy, pad
|
|
138
|
+
|
|
139
|
+
|
|
89
140
|
def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0) -> str:
|
|
90
141
|
"""--text broken into lines that fit between the frame's side margins at --font-size, by the
|
|
91
142
|
same phrase rules graphics.py and caption.py wrap with; text that fits comes back unchanged.
|
|
@@ -443,11 +494,22 @@ def main() -> int:
|
|
|
443
494
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
444
495
|
|
|
445
496
|
if args.image:
|
|
446
|
-
|
|
497
|
+
image_meta = probe(args.image)
|
|
498
|
+
refuse_not_a_still(args.image, image_meta, "--image",
|
|
447
499
|
"--video composites a moving layer; `look.py FILE --at T` writes one frame of it as a PNG")
|
|
448
500
|
chain: List[str] = ["format=rgba"]
|
|
501
|
+
# a still that stores non-square pixels (a PNG's pHYs, a JPEG's density) is made square at
|
|
502
|
+
# its display aspect first, as a --video layer is: its size and position are computed in
|
|
503
|
+
# that frame, and placed from it at its stored size a 100x100 PNG at SAR 2:1 drew 100 px
|
|
504
|
+
# wide at x=120 of a 320 px main for top-right, 100 px short of the edge
|
|
505
|
+
if square_pixels(image_meta.get("video")):
|
|
506
|
+
chain.append(square_pixels(image_meta.get("video")))
|
|
507
|
+
note(square_pixels_note(image_meta.get("video"), "the --image"))
|
|
508
|
+
iw, ih = display_size(image_meta.get("video"))
|
|
509
|
+
lw = int(vw * args.scale_percent / 100) if args.scale_percent else args.scale or iw
|
|
510
|
+
lh = _scaled_height(lw, iw, ih, 1) if lw != iw and iw and ih else ih
|
|
449
511
|
if args.scale_percent:
|
|
450
|
-
chain.append(f"scale={
|
|
512
|
+
chain.append(f"scale={lw}:-1")
|
|
451
513
|
elif args.scale:
|
|
452
514
|
chain.append(f"scale={args.scale}:-1")
|
|
453
515
|
if args.opacity < 1:
|
|
@@ -461,6 +523,11 @@ def main() -> int:
|
|
|
461
523
|
if end is not None and end > args.fade:
|
|
462
524
|
chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
|
|
463
525
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
526
|
+
grid = on_grid(x, y, (vw, vh), (lw, lh))
|
|
527
|
+
if grid:
|
|
528
|
+
# padded in RGBA, where a one-pixel offset is exact, before the layer is made YUV
|
|
529
|
+
x, y = grid[0], grid[1]
|
|
530
|
+
chain += [grid[2]] if grid[2] else []
|
|
464
531
|
# a 10-bit main on a 10-bit line is composited at 10 bits in YUV: format=auto with an RGBA
|
|
465
532
|
# still took the whole picture through 8-bit RGB (overlay_format)
|
|
466
533
|
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
@@ -510,8 +577,11 @@ def main() -> int:
|
|
|
510
577
|
if main_dur and at >= main_dur:
|
|
511
578
|
die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
|
|
512
579
|
chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
|
|
580
|
+
iw, ih = display_size(pip_meta["video"])
|
|
581
|
+
lw = int(vw * args.scale_percent / 100) if args.scale_percent else args.scale or iw
|
|
582
|
+
lh = _scaled_height(lw, iw, ih, 2) if (args.scale_percent or args.scale) and iw and ih else ih
|
|
513
583
|
if args.scale_percent:
|
|
514
|
-
chain.append(f"scale={
|
|
584
|
+
chain.append(f"scale={lw}:-2")
|
|
515
585
|
elif args.scale:
|
|
516
586
|
chain.append(f"scale={args.scale}:-2")
|
|
517
587
|
# an alpha line composites at 4:4:4: the layer is not taken through 4:2:0 on the way
|
|
@@ -544,6 +614,15 @@ def main() -> int:
|
|
|
544
614
|
# which came back BT.601 under the BT.709 tag (#333's defect, on this path)
|
|
545
615
|
chain.append(f"lut=y=val:u=val:v=val:a=val*{args.opacity:g}")
|
|
546
616
|
x, y = position_exprs(args.position, args.margin, text_mode=False, margins=safe_margins)
|
|
617
|
+
grid = on_grid(x, y, (vw, vh), (lw, lh))
|
|
618
|
+
if grid:
|
|
619
|
+
x, y = grid[0], grid[1]
|
|
620
|
+
if grid[2]:
|
|
621
|
+
# padded at 4:4:4, where a one-pixel offset is exact; overlay takes the layer back
|
|
622
|
+
# to the main's 4:2:0 on its own grid, as the layer is now aligned with it
|
|
623
|
+
if not any(c.endswith("yuva444p") or c.endswith("yuva444p10le") for c in chain):
|
|
624
|
+
chain.append("format=yuva444p10le" if deep else "format=yuva444p")
|
|
625
|
+
chain.append(grid[2])
|
|
547
626
|
# a --start/--end window is in the layer's position, which is evaluated on the main
|
|
548
627
|
# picture's frames; overlay's enable is evaluated on the layer's frames too
|
|
549
628
|
ov = f"overlay={windowed_overlay_x(x, enable) if enable else x}:{y}" + (overlay_format(meta) or ":format=auto")
|
|
@@ -646,6 +725,15 @@ def main() -> int:
|
|
|
646
725
|
else:
|
|
647
726
|
cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
|
|
648
727
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
728
|
+
if cfr_filter(meta):
|
|
729
|
+
# the conform of a VFR source is the fps filter (in a graph, on the main picture as it is
|
|
730
|
+
# decoded): the encoder line's -fps_mode cfr alone let a frame run up to two frames late (#512)
|
|
731
|
+
if "-filter_complex" in cmd:
|
|
732
|
+
i = cmd.index("-filter_complex") + 1
|
|
733
|
+
cmd[i] = cfr_at_input(cmd[i], meta)
|
|
734
|
+
else:
|
|
735
|
+
i = cmd.index("-vf") + 1
|
|
736
|
+
cmd[i] = with_cfr(cmd[i], meta)
|
|
649
737
|
dropped_streams = run_keeping_subtitles(cmd, output)
|
|
650
738
|
if not STATE.dry_run:
|
|
651
739
|
result = probe(output, role="output")
|
package/scripts/pad.py
CHANGED
|
@@ -18,7 +18,7 @@ import sys
|
|
|
18
18
|
|
|
19
19
|
from _common.emit import note, record_fallback
|
|
20
20
|
from _common.decision import silence_filter, tpad_length
|
|
21
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
21
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, drawn_chain, emit, EVEN_SCALE, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
def main() -> int:
|
|
@@ -51,7 +51,12 @@ def main() -> int:
|
|
|
51
51
|
tpad = f"tpad=start_duration={args.start:.3f}:stop_duration={args.end:.3f}:color="
|
|
52
52
|
# a --color with chroma lands in the BT.709 the output is tagged with (tpad's own YUV is BT.601
|
|
53
53
|
# before FFmpeg 7.1, and for an untagged picture after it); black is the same under every matrix
|
|
54
|
-
|
|
54
|
+
# the conform of a VFR source is the fps filter right after tpad, which pads by time: before
|
|
55
|
+
# it, the padding was rounded to the delivered grid on top of the frames' own rounding (37 ms
|
|
56
|
+
# late), and after a drawn chain's maskedmerge the last frame was lost (#512)
|
|
57
|
+
conform = with_cfr("", meta)
|
|
58
|
+
vf = drawn_chain(tpad + args.color + ("," + conform if conform else ""), meta, [args.color],
|
|
59
|
+
tpad + "black" + ("," + conform if conform else ""))
|
|
55
60
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
56
61
|
if has_audio:
|
|
57
62
|
# One -filter_complex, not a -vf and an -af: the ffmpeg scheduler of Ubuntu 26.04's 8.0.1 and
|
package/scripts/proxy.py
CHANGED
|
@@ -26,7 +26,7 @@ Examples:
|
|
|
26
26
|
import argparse
|
|
27
27
|
import sys
|
|
28
28
|
|
|
29
|
-
from _common import add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
29
|
+
from _common import add_common, apply_common, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, fmt_secs
|
|
30
30
|
from _common.emit import note
|
|
31
31
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
32
32
|
|
|
@@ -77,7 +77,7 @@ def main() -> int:
|
|
|
77
77
|
+ (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
|
|
78
78
|
output = args.output or default_output(args.input, "proxy")
|
|
79
79
|
|
|
80
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
|
|
80
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr((square + "," if square else "") + f"scale={out_w}:-2", meta, args.fps)]
|
|
81
81
|
cmd += video_args(meta, args.crf, "veryfast")
|
|
82
82
|
cmd += cfr_args(meta, args.fps)
|
|
83
83
|
cmd += ["-map", "0:v:0"] + (["-map", "0:a:0", "-c:a", "aac", "-b:a", "96k"] if has_audio else ["-an"])
|
package/scripts/redact.py
CHANGED
|
@@ -27,7 +27,7 @@ Examples:
|
|
|
27
27
|
import argparse
|
|
28
28
|
import sys
|
|
29
29
|
|
|
30
|
-
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
30
|
+
from _common import add_common, overlay_format, apply_common, aac_args, cfr_args, cfr_at_input, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
31
31
|
from _common.emit import note
|
|
32
32
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
33
33
|
|
|
@@ -98,7 +98,8 @@ def main() -> int:
|
|
|
98
98
|
output = args.output or default_output(args.input, "redact")
|
|
99
99
|
if args.mode == "delogo":
|
|
100
100
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf",
|
|
101
|
-
(square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
101
|
+
with_cfr((square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
|
|
102
|
+
meta, args.fps),
|
|
102
103
|
"-map", "0:v:0"]
|
|
103
104
|
else:
|
|
104
105
|
# exact=1: crop rounds an odd x/y down to the chroma grid of a 4:2:0 frame otherwise, and
|
|
@@ -127,6 +128,8 @@ def main() -> int:
|
|
|
127
128
|
f"pad={args.width + 2 * ox}:{args.height + 2 * oy}:{ox}:{oy}:color=black@0")
|
|
128
129
|
fc = (f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];"
|
|
129
130
|
f"[base][patched]overlay={args.x - ox}:{args.y - oy}{overlay_format(meta)}[out]")
|
|
131
|
+
# a VFR source (or --fps) is conformed by the fps filter as it is decoded (#512)
|
|
132
|
+
fc = cfr_at_input(fc, meta, args.fps)
|
|
130
133
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
|
|
131
134
|
if has_audio:
|
|
132
135
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
package/scripts/render.py
CHANGED
|
@@ -76,7 +76,7 @@ from export import PRESETS, PLATFORM_OF
|
|
|
76
76
|
from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
|
|
77
77
|
from _common import guard_sidecar, STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit as _emit, info, option_args, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version, X264_PRESETS
|
|
78
78
|
from _common.runner import HW_ENV, HW_FORCED_ENV, add_hw_orchestrator_args, child_exit_code, found_exe, kept_clause, kept_deliverables, mark_delivered, owned_identity, read_json, remember_written
|
|
79
|
-
from _common.emit import absorb_stage, audit, record_fallback, record_skip, refuse, warn
|
|
79
|
+
from _common.emit import absorb_stage, audit, is_output_shape_flag, record_fallback, record_skip, refuse, stderr_records, warn
|
|
80
80
|
from _common.decision import rebase_project_paths
|
|
81
81
|
import subprocess
|
|
82
82
|
from _contract import CONTRACT_VERSION
|
|
@@ -405,9 +405,7 @@ def render_pack(names: List[str], args) -> int:
|
|
|
405
405
|
doc = {}
|
|
406
406
|
# the child ran with --json, so its planned commands come back in the document: the pack
|
|
407
407
|
# is the one path that writes seven files, and --dry-run has to show all of them
|
|
408
|
-
for
|
|
409
|
-
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
410
|
-
STATE.commands.append(line[2:] if line.startswith("$ ") else line)
|
|
408
|
+
STATE.commands.extend(text for is_command, text in stderr_records(proc.stderr) if is_command)
|
|
411
409
|
STATE.commands.extend(str(c) for c in (doc.get("commands") or []))
|
|
412
410
|
absorb_stage(name, doc)
|
|
413
411
|
out = doc.get("output") or dest_out
|
|
@@ -806,9 +804,9 @@ def sh(script: str, *argv: Any, extra: List[str] = None, stage: str = None) -> s
|
|
|
806
804
|
cmd = [str(HERE / script)] + [str(a) for a in argv] + (extra or []) + own + child_args() + ["--json"]
|
|
807
805
|
info("→ " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd[:-1])))
|
|
808
806
|
proc = run_tool(cmd)
|
|
809
|
-
for line in proc.stderr
|
|
810
|
-
if
|
|
811
|
-
STATE.commands.append(line
|
|
807
|
+
for is_command, line in stderr_records(proc.stderr):
|
|
808
|
+
if is_command:
|
|
809
|
+
STATE.commands.append(line)
|
|
812
810
|
elif line.strip():
|
|
813
811
|
info(" " + line)
|
|
814
812
|
try:
|
|
@@ -1212,7 +1210,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1212
1210
|
os.chdir(plan["cwd"])
|
|
1213
1211
|
if not isinstance(plan.get("argv"), list):
|
|
1214
1212
|
_reject_plan(path, digest, "plan_argv_invalid", f"{path}: argv must be a list", argv_type=type(plan.get("argv")).__name__)
|
|
1215
|
-
|
|
1213
|
+
# a plan an earlier version recorded with --json-brief (A181): the tool is run for its full
|
|
1214
|
+
# document, as every plan written now asks for
|
|
1215
|
+
argv = [str(a) for a in plan["argv"] if not is_output_shape_flag(str(a))]
|
|
1216
1216
|
if STATE.dry_run:
|
|
1217
1217
|
for c in plan.get("commands") or []:
|
|
1218
1218
|
STATE.commands.append(c)
|
|
@@ -1226,9 +1226,9 @@ def execute_plan(plan: Dict[str, Any], path: str) -> int:
|
|
|
1226
1226
|
return 0
|
|
1227
1227
|
info(f"executing plan {path}: {tool} " + " ".join(argv))
|
|
1228
1228
|
proc = run_tool([str(script)] + argv + child_args() + ["--json"])
|
|
1229
|
-
for line in proc.stderr
|
|
1230
|
-
if line.startswith("
|
|
1231
|
-
STATE.commands.append(line
|
|
1229
|
+
for is_command, line in stderr_records(proc.stderr):
|
|
1230
|
+
if is_command and not line.startswith("[dry-run]"):
|
|
1231
|
+
STATE.commands.append(line)
|
|
1232
1232
|
elif line.strip():
|
|
1233
1233
|
info(" " + line)
|
|
1234
1234
|
try:
|
|
@@ -1326,7 +1326,19 @@ def export_timeline(proj: Dict[str, Any], rel, dest: str) -> int:
|
|
|
1326
1326
|
export = proj.get("export")
|
|
1327
1327
|
frame_from_preset(frame, export if isinstance(export, dict) else {})
|
|
1328
1328
|
fit = proj.get("fit")
|
|
1329
|
-
if isinstance(fit, dict) and
|
|
1329
|
+
if isinstance(fit, dict) and len(proj.get("clips") or []) == 1:
|
|
1330
|
+
# one clip: the render's fit stage reframes the clip's own picture with the fit object's
|
|
1331
|
+
# aspect, mode, sides and rate, the frame's only where the fit object has none (the
|
|
1332
|
+
# render's setdefault), so the sequence takes them by the same rule (#506: frame 16:9 with
|
|
1333
|
+
# fit 1:1 rendered a square and exported 1920x1080). Several clips are fitted inside the
|
|
1334
|
+
# join, which join.py sizes from the frame first; their sequence stays the frame's
|
|
1335
|
+
if fit.get("aspect") and aspect_ratio(str(fit["aspect"])) is None:
|
|
1336
|
+
die(f"--export-timeline: fit.aspect {fit['aspect']!r}: use W:H with whole numbers, like 16:9 "
|
|
1337
|
+
"(fit.py refuses it too)", kind="input")
|
|
1338
|
+
if fit.get("fit") is not None and fit["fit"] not in ("pad", "crop", "blur"):
|
|
1339
|
+
die(f"--export-timeline: fit.fit {fit['fit']!r}: use pad, crop or blur (fit.py's --fit)", kind="input")
|
|
1340
|
+
frame.update({k: fit[k] for k in ("aspect", "fit", "width", "height", "fps") if fit.get(k) is not None})
|
|
1341
|
+
elif isinstance(fit, dict) and fit.get("aspect") and frame.get("aspect") and aspect_ratio(frame["aspect"]) is None:
|
|
1330
1342
|
# the render hands fit.py the fit object's own aspect, never this one (only the preset
|
|
1331
1343
|
# match above reads it), and completes: the sequence is the frame's size without it
|
|
1332
1344
|
del frame["aspect"]
|
package/scripts/silence.py
CHANGED
|
@@ -29,7 +29,7 @@ from _common import (filler_spans, FILLER_WORDS, FILLER_AMBIGUOUS, FILLER_DISCOU
|
|
|
29
29
|
from _common import asr as _asr
|
|
30
30
|
from _common.emit import warn
|
|
31
31
|
from _common.asr import _dicts
|
|
32
|
-
from _common.runner import parse_json, replace_checked
|
|
32
|
+
from _common.runner import parse_json, replace_checked, same_file
|
|
33
33
|
from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
|
|
34
34
|
from _common.emit import refuse
|
|
35
35
|
from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
|
|
@@ -672,7 +672,7 @@ def main() -> int:
|
|
|
672
672
|
# --overwrite allows replacing an old map, never the source, the cut or the EDL
|
|
673
673
|
refuse_output_is_input(args.timemap, *[p for p in (args.input, args.words, args.filler_words) if p])
|
|
674
674
|
for flag, other in (("-o", args.output), ("--edl", args.edl), ("--plan", getattr(args, "plan", None))):
|
|
675
|
-
if other and
|
|
675
|
+
if other and same_file(other, args.timemap):
|
|
676
676
|
refuse("input", "timemap_path_taken",
|
|
677
677
|
f"--timemap {args.timemap} is the same file as {flag}; give the map its own path",
|
|
678
678
|
timemap=args.timemap, flag=flag, path=other)
|
package/scripts/speedramp.py
CHANGED
|
@@ -17,9 +17,10 @@ Examples:
|
|
|
17
17
|
"""
|
|
18
18
|
import argparse
|
|
19
19
|
import sys
|
|
20
|
+
from fractions import Fraction
|
|
20
21
|
from typing import List, Tuple
|
|
21
22
|
|
|
22
|
-
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
|
+
from _common.decision import av_trim, conform_filter, delivered_rate, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
23
24
|
from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
|
|
24
25
|
from _common.emit import input_length
|
|
25
26
|
|
|
@@ -85,7 +86,7 @@ def main() -> int:
|
|
|
85
86
|
has_audio = bool(meta.get("audio"))
|
|
86
87
|
output = args.output or default_output(args.input, "ramp")
|
|
87
88
|
|
|
88
|
-
vparts, aparts
|
|
89
|
+
vparts, aparts = [], []
|
|
89
90
|
# The coverage check above allows the last end to sit up to 0.5 s off the probed duration.
|
|
90
91
|
# That slack is for a caller who rounded the duration, not a reason to redraw the tail at
|
|
91
92
|
# the last factor. The end bound is omitted only when the stated end is the file's end
|
|
@@ -97,7 +98,12 @@ def main() -> int:
|
|
|
97
98
|
# the factor scaled that (+570 ms at 0.5x). The first segment starts at the first frame.
|
|
98
99
|
v0 = stream_starts(meta)[0]
|
|
99
100
|
rate = frame_rate(meta)
|
|
101
|
+
# on a VFR source the picture is delivered at the rate the encoder line forces (its average):
|
|
102
|
+
# closed on r_frame_rate, -r then resampled the joined picture and it drifted 113 ms late (#512)
|
|
103
|
+
out_rate = delivered_rate(meta) or rate
|
|
104
|
+
srate = int((meta.get("audio") or {}).get("sample_rate") or 0)
|
|
100
105
|
bounds = [v0] + [grid_at_or_after(end, v0, rate) for _, end, _ in segments[:-1]]
|
|
106
|
+
retimed = 0.0 # where the segment starts on the output's clock
|
|
101
107
|
for i, (start, end, factor) in enumerate(segments):
|
|
102
108
|
vlabel, alabel = f"v{i}", f"a{i}"
|
|
103
109
|
to_eof = i == len(segments) - 1 and last_runs_to_eof
|
|
@@ -107,23 +113,52 @@ def main() -> int:
|
|
|
107
113
|
die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
|
|
108
114
|
f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
|
|
109
115
|
vtrim, atrim = av_trim(bounds[i], seg_end, rate)
|
|
110
|
-
# the closing fps puts the sped-up frames back on the
|
|
116
|
+
# the closing fps puts the sped-up frames back on the delivered grid with its frame
|
|
111
117
|
# duration: concat starts the next segment after the last frame's duration, which setpts
|
|
112
118
|
# leaves at the unscaled 1/rate, so every 2x segment pushed the rest of the picture two
|
|
113
119
|
# frames late against the sound (and added those frames)
|
|
114
|
-
|
|
115
|
-
|
|
120
|
+
vchain = f"[0:v]{vtrim},setpts=PTS/{factor:.6f},{frame_durations(meta, out_rate)}"
|
|
121
|
+
achain = f"[0:a]{atrim},{atempo_chain(factor)}"
|
|
122
|
+
if out_rate:
|
|
123
|
+
# Each segment holds the frames (and samples) between its retimed ends on the whole
|
|
124
|
+
# output's grid, not its own length rounded: eight 0.5 s segments at 27 fps rounded
|
|
125
|
+
# to 14 frames each, concat advanced 14/27 s per segment and the last flash landed
|
|
126
|
+
# 130 ms late. The picture is sampled on the output's grid (an offset of under half
|
|
127
|
+
# a frame, and fps's start_time) and, but for a last segment that runs to the end,
|
|
128
|
+
# padded by a frame or two and cut to its count; the sound is padded or cut to its
|
|
129
|
+
# own count, so neither stream drifts across segments.
|
|
130
|
+
first = round(retimed * out_rate)
|
|
131
|
+
shift = first / out_rate - retimed
|
|
132
|
+
vchain = (f"[0:v]{vtrim},setpts=PTS/{factor:.6f}{-shift:+.6f}/TB,"
|
|
133
|
+
f"{frame_durations(meta, out_rate)}:start_time=0")
|
|
134
|
+
if seg_end is not None:
|
|
135
|
+
after = retimed + (seg_end - bounds[i]) / factor
|
|
136
|
+
vchain += f",tpad=stop=2:stop_mode=clone,trim=end_frame={round(after * out_rate) - first}"
|
|
137
|
+
if srate:
|
|
138
|
+
achain += f",{conform_filter(round(after * srate) - round(retimed * srate), srate)}"
|
|
139
|
+
retimed = after
|
|
140
|
+
vparts.append(f"{vchain}[{vlabel}]")
|
|
116
141
|
if has_audio:
|
|
117
|
-
aparts.append(f"
|
|
118
|
-
|
|
142
|
+
aparts.append(f"{achain}[{alabel}]")
|
|
143
|
+
|
|
144
|
+
# the two streams are joined by two concats: one concat over both started each segment after
|
|
145
|
+
# the longer of its two streams, so a picture a frame longer than its sound moved both late
|
|
146
|
+
n = len(segments)
|
|
147
|
+
# Every segment's picture is a whole count of frames on one grid, so the joined picture's
|
|
148
|
+
# timestamps are rebuilt from the running frame count: FFmpeg 6.1.1's video-only concat gave a
|
|
149
|
+
# one-frame segment no length (it takes a segment's end from its last frame's timestamp), so
|
|
150
|
+
# three one-frame segments put the rest of the picture a frame before its sound. The closing
|
|
151
|
+
# fps gives the frames their durations back (from 7.0 setpts leaves them 0, frame_durations)
|
|
152
|
+
regrid = ""
|
|
153
|
+
if out_rate:
|
|
154
|
+
grid = frame_durations(meta, out_rate)
|
|
155
|
+
q = Fraction(grid[len("fps="):]) if "/" in grid else Fraction(out_rate).limit_denominator(1001)
|
|
156
|
+
regrid = f",setpts=N*{q.denominator}/({q.numerator}*TB),{grid}"
|
|
157
|
+
fc = ";".join(vparts) + ";" + "".join(f"[v{i}]" for i in range(n)) + f"concat=n={n}:v=1:a=0{regrid}[outv]"
|
|
158
|
+
maps = ["-map", "[outv]"]
|
|
119
159
|
if has_audio:
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
maps = ["-map", "[outv]", "-map", "[outa]"]
|
|
123
|
-
else:
|
|
124
|
-
concat_inputs = "".join(f"[v{i}]" for i in range(len(segments)))
|
|
125
|
-
fc = ";".join(vparts) + f";{concat_inputs}concat=n={len(segments)}:v=1:a=0[outv]"
|
|
126
|
-
maps = ["-map", "[outv]"]
|
|
160
|
+
fc += ";" + ";".join(aparts) + ";" + "".join(f"[a{i}]" for i in range(n)) + f"concat=n={n}:v=0:a=1[outa]"
|
|
161
|
+
maps += ["-map", "[outa]"]
|
|
127
162
|
|
|
128
163
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc] + maps
|
|
129
164
|
cmd += video_args(meta, args.crf, args.preset)
|
package/scripts/sphere.py
CHANGED
|
@@ -33,7 +33,7 @@ import argparse
|
|
|
33
33
|
import math
|
|
34
34
|
import sys
|
|
35
35
|
|
|
36
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
36
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
|
|
37
37
|
from _common.emit import note
|
|
38
38
|
|
|
39
39
|
# v360's own AVOption names for the input projections real 360 cameras/exports actually
|
|
@@ -124,7 +124,7 @@ def main() -> int:
|
|
|
124
124
|
f"yaw={args.yaw:g}:pitch={args.pitch:g}:roll={args.roll:g}:"
|
|
125
125
|
f"h_fov={args.h_fov:g}:v_fov={args.v_fov:g}:"
|
|
126
126
|
f"w={args.width}:h={args.height}:interp={args.interp}")
|
|
127
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", v360, "-map", "0:v:0"]
|
|
127
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(v360, meta, args.fps), "-map", "0:v:0"]
|
|
128
128
|
if has_audio:
|
|
129
129
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
130
130
|
cmd += video_args(meta, args.crf, args.preset)
|
package/scripts/stabilize.py
CHANGED
|
@@ -29,7 +29,7 @@ from pathlib import Path
|
|
|
29
29
|
|
|
30
30
|
from _common.probe import PROBED_VIDEO
|
|
31
31
|
from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
|
|
32
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
32
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
33
33
|
|
|
34
34
|
|
|
35
35
|
def main() -> int:
|
|
@@ -104,7 +104,7 @@ def main() -> int:
|
|
|
104
104
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
105
105
|
# camera path makes no sense once every frame is locked to one fixed reference.
|
|
106
106
|
transform_vf += ":tripod=1"
|
|
107
|
-
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", transform_vf]
|
|
107
|
+
cmd = ffmpeg_base() + ["-i", args.input] + default_selection_maps(meta, output) + ["-vf", with_cfr(transform_vf, meta)]
|
|
108
108
|
cmd += video_args(meta, args.crf, args.preset)
|
|
109
109
|
cmd += cfr_args(meta)
|
|
110
110
|
if has_audio:
|
package/scripts/straighten.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import math
|
|
23
23
|
import sys
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common import add_common, apply_common, aac_args, cfr_args, with_cfr, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
26
|
from _common.emit import note
|
|
27
27
|
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
28
28
|
|
|
@@ -100,7 +100,7 @@ def main() -> int:
|
|
|
100
100
|
if square_pixels(meta["video"]):
|
|
101
101
|
rotate = square_pixels(meta["video"]) + "," + rotate
|
|
102
102
|
note(square_pixels_note(meta["video"]))
|
|
103
|
-
cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
|
|
103
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", with_cfr(rotate, meta, args.fps), "-map", "0:v:0"]
|
|
104
104
|
if has_audio:
|
|
105
105
|
cmd += ["-map", f"0:a:{args.audio_stream}?"]
|
|
106
106
|
cmd += video_args(meta, args.crf, args.preset)
|
package/scripts/verify.py
CHANGED
|
@@ -26,6 +26,7 @@ from pathlib import Path
|
|
|
26
26
|
from typing import Any, Dict, List, Optional, Tuple
|
|
27
27
|
|
|
28
28
|
from _common import STATE, add_common, apply_common, die, emit, guard_sidecar, info, probe, refuse_output_is_input, MEDIA_EXT as _MEDIA_EXT
|
|
29
|
+
from _common.runner import _stop_child, _unwatch, _watch
|
|
29
30
|
|
|
30
31
|
HERE = Path(__file__).resolve().parent
|
|
31
32
|
# _common's one list less the stills, as batch.py walks a folder: verify kept a list of its own,
|
|
@@ -140,13 +141,24 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
|
140
141
|
if not ok:
|
|
141
142
|
r.update(kind="verification", reason="hdr_lost", message=r["error"])
|
|
142
143
|
return r
|
|
144
|
+
child = subprocess.Popen([sys.executable, str(HERE / argv[0])] + argv[1:] + _forwarded(argv) + ["--json"], stdout=subprocess.PIPE,
|
|
145
|
+
stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
|
|
146
|
+
# watched like run_tool()'s child (review of #530): a SIGTERM or Ctrl-C to verify.py stops the
|
|
147
|
+
# step through stop_children() -- its own handler removes its partial output and lock --
|
|
148
|
+
# instead of leaving it running and writing after verify.py has exited
|
|
149
|
+
_watch(child, [])
|
|
143
150
|
try:
|
|
144
|
-
|
|
145
|
-
|
|
151
|
+
out, err = child.communicate(timeout=timeout or None)
|
|
152
|
+
proc = subprocess.CompletedProcess(child.args, child.returncode, out, err)
|
|
146
153
|
except subprocess.TimeoutExpired:
|
|
154
|
+
# stopped as run_tool() stops a sibling script past its ceiling (#465 A183's sweep): the
|
|
155
|
+
# SIGKILL subprocess.run() sent left the step's partial output and output lock in --out
|
|
156
|
+
_stop_child(child)
|
|
147
157
|
msg = f"timeout after {timeout:.0f}s"
|
|
148
158
|
return {"step": name, "ok": False, "seconds": round(time.time() - t0, 1), "error": msg,
|
|
149
159
|
"kind": "timeout", "reason": "step_timeout", "message": msg}
|
|
160
|
+
finally:
|
|
161
|
+
_unwatch(child)
|
|
150
162
|
try:
|
|
151
163
|
doc = json.loads(proc.stdout)
|
|
152
164
|
except ValueError:
|
|
@@ -198,10 +210,27 @@ def main() -> int:
|
|
|
198
210
|
files = collect(args.paths)
|
|
199
211
|
FORWARD.update(overwrite=STATE.overwrite, fast=STATE.fast)
|
|
200
212
|
tmp = None
|
|
213
|
+
started = {"step": False}
|
|
201
214
|
if args.out:
|
|
202
215
|
# made first, so a --report inside a new --out folder passes the guard below (#413 review)
|
|
203
216
|
outdir = Path(args.out)
|
|
217
|
+
made = [d for d in (outdir, *outdir.parents) if not d.exists()]
|
|
204
218
|
outdir.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
if made:
|
|
220
|
+
import atexit
|
|
221
|
+
|
|
222
|
+
def _remove_unused_dirs() -> None:
|
|
223
|
+
# a run refused before its first step (a --report guard) leaves no empty --out
|
|
224
|
+
# folder behind, as batch.py leaves no out/ (#465 A187's sibling); once a step has
|
|
225
|
+
# run the folder is the caller's, empty or not
|
|
226
|
+
if started["step"]:
|
|
227
|
+
return
|
|
228
|
+
for d in made: # deepest first
|
|
229
|
+
try:
|
|
230
|
+
d.rmdir()
|
|
231
|
+
except OSError:
|
|
232
|
+
pass
|
|
233
|
+
atexit.register(_remove_unused_dirs)
|
|
205
234
|
if args.report:
|
|
206
235
|
# a file this run writes itself (#408): not one of the media it verifies (a folder's
|
|
207
236
|
# files included), nor an existing file without --overwrite
|
|
@@ -301,6 +330,7 @@ def main() -> int:
|
|
|
301
330
|
refuse_output_is_input(out, *every_input, why=f"it is where verify writes the {name!r} step's output for {str(f)!r}")
|
|
302
331
|
|
|
303
332
|
results = []
|
|
333
|
+
started["step"] = True
|
|
304
334
|
for f, entry, plan, cues in jobs:
|
|
305
335
|
info(f"=== {f}")
|
|
306
336
|
if cues:
|