ffmpeg-skill 3.1.5 → 3.1.6
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 +4 -3
- package/package.json +1 -1
- package/references/ci-platform-pitfalls.md +3 -2
- package/references/gotchas.md +5 -1
- package/references/scripts.md +5 -4
- package/scripts/_common/__init__.py +7 -5
- package/scripts/_common/color.py +40 -1
- package/scripts/_common/decision.py +177 -10
- package/scripts/_common/emit.py +2 -0
- package/scripts/_common/probe.py +95 -2
- package/scripts/_common/runner.py +4 -1
- package/scripts/_common/timeline.py +5 -4
- package/scripts/_contract.py +2 -2
- package/scripts/broll.py +35 -14
- package/scripts/caption.py +10 -8
- package/scripts/color.py +37 -8
- package/scripts/crop.py +8 -3
- package/scripts/cropdetect.py +13 -9
- package/scripts/cut.py +4 -4
- package/scripts/export.py +11 -2
- package/scripts/fit.py +8 -4
- package/scripts/freeze.py +38 -10
- package/scripts/graphics.py +104 -67
- package/scripts/grid.py +49 -7
- package/scripts/insert.py +9 -1
- package/scripts/join.py +29 -19
- package/scripts/look.py +41 -12
- package/scripts/loop.py +45 -6
- package/scripts/multicam.py +16 -7
- package/scripts/overlay.py +68 -14
- package/scripts/proxy.py +8 -4
- package/scripts/redact.py +10 -5
- package/scripts/reverse.py +27 -2
- package/scripts/scenes.py +16 -5
- package/scripts/sequence.py +6 -2
- package/scripts/silence.py +61 -30
- package/scripts/speedramp.py +17 -5
- package/scripts/straighten.py +17 -5
- package/scripts/sync.py +8 -2
- package/scripts/waveform.py +8 -3
package/scripts/_contract.py
CHANGED
|
@@ -276,7 +276,7 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
276
276
|
"speedramp": dict(video="always", audio="always", note="setpts/atempo per segment always forces a re-encode of both streams"),
|
|
277
277
|
"broll": dict(video="always", audio="conditional", note="the overlay graph always re-encodes the video stream; A's audio is stream-copied under --audio a and re-encoded to AAC under --audio b/mix"),
|
|
278
278
|
"metadata": dict(video="never", audio="never", note="-c copy on every stream; only the container's chapters and tags change -- --auto-chapters decodes to measure, but still writes with -c copy"),
|
|
279
|
-
"loop": dict(video="always", audio="conditional", note="
|
|
279
|
+
"loop": dict(video="always", audio="conditional", note="the repeats (the concat demuxer, the file listed once per repeat) always re-encode both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
|
|
280
280
|
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
281
281
|
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
282
282
|
"reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
|
|
@@ -454,7 +454,7 @@ DIAGNOSTIC_CODES = {
|
|
|
454
454
|
"audit_log_unwritable": ("warnings", "an audit record could not be appended; never fails the run; {path, event, error}"),
|
|
455
455
|
"plan_unbound_inputs": ("warnings", "render.py PLAN: inputs that did not exist when the plan was made, so were not checked; {paths}"),
|
|
456
456
|
# the runner
|
|
457
|
-
"odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones (the frame loses a row or column); {output, scale}"),
|
|
457
|
+
"odd_dimensions_scaled_even": ("fallbacks", "an encode that failed on odd dimensions was run again scaled to even ones, or a graph the retry cannot reach (broll.py's filter_complex) was scaled to even ones up front (the frame loses a row or column); {output, scale}"),
|
|
458
458
|
"measurement_input_pending": ("skipped", "--dry-run: a measurement skipped because its input is an earlier stage's output that does not exist yet; {path}"),
|
|
459
459
|
"non_av_streams_dropped": ("fallbacks", "the subtitle/data streams could not be copied, so the encode ran again without them (dropped_non_av_streams: true); {output, returncode, error: the first attempt's last stderr lines}; export.py adds dropped: the stream types this retry leaves out (data alone first, then subtitle too)"),
|
|
460
460
|
"ffmpeg_list_unreadable": ("warnings", "`ffmpeg -encoders` / `-bsfs` could not be read; what needs the list says so instead of 'the build lacks it'; {list, tool, error}"),
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,10 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import
|
|
24
|
+
from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
|
|
25
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, as_limited, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, range_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
|
|
26
|
+
from _common.emit import note, record_fallback
|
|
27
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
25
28
|
|
|
26
29
|
|
|
27
30
|
def main() -> int:
|
|
@@ -55,10 +58,20 @@ def main() -> int:
|
|
|
55
58
|
if not meta_a.get("video"):
|
|
56
59
|
die("A-roll has no video stream")
|
|
57
60
|
dur_a = meta_a.get("duration") or 0.0
|
|
58
|
-
|
|
59
|
-
#
|
|
60
|
-
|
|
61
|
-
|
|
61
|
+
# the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one, and
|
|
62
|
+
# onto a non-square-pixel A-roll squared at its display aspect (#411)
|
|
63
|
+
w, h = display_size(meta_a["video"])
|
|
64
|
+
a_chain = square_pixels(meta_a["video"])
|
|
65
|
+
if a_chain:
|
|
66
|
+
note(square_pixels_note(meta_a["video"], "the A-roll"))
|
|
67
|
+
if (w % 2 or h % 2) and w and h:
|
|
68
|
+
# an odd-sized A-roll cannot be encoded 4:2:0 at its own size, and the run-time even-scale
|
|
69
|
+
# retry does not reach a filter_complex: through 3.1.3 this failed as kind "ffmpeg" (#411)
|
|
70
|
+
ew, eh = max(2, w - w % 2), max(2, h - h % 2) # never 0, which scale reads as "keep" (#416 review)
|
|
71
|
+
record_fallback(f"the A-roll is {w}x{h}, which 4:2:0 cannot hold: scaled to {ew}x{eh}",
|
|
72
|
+
"odd_dimensions_scaled_even", output=args.output or default_output(args.input, "broll"), scale=f"scale={ew}:{eh}")
|
|
73
|
+
a_chain = (a_chain + "," if a_chain else "") + f"scale={ew}:{eh},setsar=1"
|
|
74
|
+
w, h = ew, eh
|
|
62
75
|
fps = meta_a["video"].get("fps") or 30.0
|
|
63
76
|
has_audio_a = bool(meta_a.get("audio"))
|
|
64
77
|
|
|
@@ -72,13 +85,13 @@ def main() -> int:
|
|
|
72
85
|
meta_b = probe(path)
|
|
73
86
|
if not meta_b.get("video"):
|
|
74
87
|
die(f"{path} has no video stream")
|
|
75
|
-
at = time_arg(args.at[i], "--at", fps)
|
|
76
|
-
start_b = time_arg(per(args.from_, i, "0"), "--from", fps)
|
|
88
|
+
at = time_arg(args.at[i], "--at", fps, source=meta_a)
|
|
89
|
+
start_b = time_arg(per(args.from_, i, "0"), "--from", fps, source=meta_b)
|
|
77
90
|
if args.end:
|
|
78
|
-
end = time_arg(per(args.end, i, "0"), "--end", fps)
|
|
91
|
+
end = time_arg(per(args.end, i, "0"), "--end", fps, source=meta_a)
|
|
79
92
|
length = end - at
|
|
80
93
|
else:
|
|
81
|
-
length = time_arg(per(args.duration, i, "4"), "--duration", fps)
|
|
94
|
+
length = time_arg(per(args.duration, i, "4"), "--duration", fps, source=meta_a)
|
|
82
95
|
if length <= 0:
|
|
83
96
|
die(f"cutaway {i + 1}: length must be > 0 (at {at:g}s, got {length:g}s)")
|
|
84
97
|
if dur_a and at >= dur_a:
|
|
@@ -110,16 +123,19 @@ def main() -> int:
|
|
|
110
123
|
convert = [matrix_chain(m, meta_a) for m in [meta_a] + [c["meta"] for c in cutaways]]
|
|
111
124
|
if any(convert) or draws_chroma([args.pad_color], meta_a):
|
|
112
125
|
convert = [cv or label_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
|
|
113
|
-
|
|
114
|
-
|
|
126
|
+
# overlay mixes samples: a full-range clip over or under a limited one is taken to limited range (#410)
|
|
127
|
+
convert = [cv + range_chain(m, meta_a) for cv, m in zip(convert, [meta_a] + [c["meta"] for c in cutaways])]
|
|
128
|
+
if convert[0] or a_chain:
|
|
129
|
+
parts.append(f"[0:v]{convert[0]}{a_chain or 'null'}[a709]")
|
|
115
130
|
cur = "[a709]"
|
|
116
131
|
pad = f"pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color="
|
|
117
132
|
for i, c in enumerate(cutaways):
|
|
118
133
|
geo = (f"scale={w}:{h}:force_original_aspect_ratio=decrease,"
|
|
119
|
-
+ drawn_chain(pad + args.pad_color, c["meta"], [args.pad_color], pad + "black", line_meta=meta_a,
|
|
134
|
+
+ drawn_chain(pad + args.pad_color, as_limited(c["meta"]), [args.pad_color], pad + "black", line_meta=meta_a,
|
|
120
135
|
tag=f"pad{i}", labelled=True))
|
|
136
|
+
b_sq = (square_pixels(c["meta"]["video"]) + ",") if square_pixels(c["meta"]["video"]) else ""
|
|
121
137
|
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
122
|
-
f"{convert[i + 1]}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
138
|
+
f"{convert[i + 1]}{b_sq}{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('bt2020_or_hdr') else 'yuv420p'}[b{i}]")
|
|
123
139
|
parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
|
|
124
140
|
cur = f"[v{i}]"
|
|
125
141
|
vout = cur
|
|
@@ -137,7 +153,12 @@ def main() -> int:
|
|
|
137
153
|
parts.append(f"[0:a:0]aformat=sample_rates=48000:channel_layouts=stereo{gate}[a0]")
|
|
138
154
|
layers.append("[a0]")
|
|
139
155
|
for i, c in enumerate(cutaways):
|
|
140
|
-
|
|
156
|
+
# B's sound is shifted by the same constant as its picture, whose first frame (the
|
|
157
|
+
# first at or after --from) lands on --at; rebasing the sound to its own first
|
|
158
|
+
# sample moved a track that starts 0.3 s late 0.279 s early (#409)
|
|
159
|
+
v0b = stream_starts(c["meta"])[0]
|
|
160
|
+
first = max(v0b, grid_at_or_after(c["from"], v0b, frame_rate(c["meta"])))
|
|
161
|
+
parts.append(f"[{i + 1}:a:0]{av_trim(first, first + c['length'])[1]},"
|
|
141
162
|
f"aformat=sample_rates=48000:channel_layouts=stereo,adelay={int(c['at'] * 1000)}|{int(c['at'] * 1000)}[ab{i}]")
|
|
142
163
|
layers.append(f"[ab{i}]")
|
|
143
164
|
parts.append(f"{''.join(layers)}amix=inputs={len(layers)}:normalize=0:dropout_transition=0,atrim=duration={dur_a:.3f}[aout]")
|
package/scripts/caption.py
CHANGED
|
@@ -45,7 +45,7 @@ from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_unit
|
|
|
45
45
|
resolve as resolve_platform)
|
|
46
46
|
from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
|
|
47
47
|
from _common.emit import refuse # noqa: E402
|
|
48
|
-
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
48
|
+
from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, drawn_chain, label_chain, layer_base_chain, load_brand, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs, guard_sidecar
|
|
49
49
|
# The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
|
|
50
50
|
# The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
|
|
51
51
|
# shares them). They stay caption.py's public names -- every caller and test that reached for
|
|
@@ -53,6 +53,7 @@ from _common import require_filter, emoji_filter_chain, EMOJI_ASSET_HINT, emoji_
|
|
|
53
53
|
from _common import asr as _asr
|
|
54
54
|
from _common.asr import ENGINE_CHOICES
|
|
55
55
|
from _common.emit import warn
|
|
56
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
56
57
|
from _common.fonts import font_choice, record_font_choice
|
|
57
58
|
from _common import (ASR_INSTALL_HINT, die_no_engine, parse_srt, transcribe, whisper_word_timings,
|
|
58
59
|
write_srt)
|
|
@@ -1139,9 +1140,9 @@ def main() -> int:
|
|
|
1139
1140
|
|
|
1140
1141
|
play_w = play_h = None
|
|
1141
1142
|
if meta and meta.get("video"):
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1143
|
+
# the displayed frame: rotation applied, non-square pixels squared (#411) -- the burn
|
|
1144
|
+
# below draws on the square-pixel picture, so a cue is not stretched by the player's SAR
|
|
1145
|
+
play_w, play_h = display_size(meta["video"])
|
|
1145
1146
|
# A --plan or --dry-run written before the input exists has no geometry to fit against, and a
|
|
1146
1147
|
# plan that describes a different FontSize from the run that executes it is not a plan. When
|
|
1147
1148
|
# --platform names a destination, that destination's frame IS the geometry the real run will
|
|
@@ -1589,9 +1590,7 @@ def main() -> int:
|
|
|
1589
1590
|
args._word_timings = whisper_word_timings(srt_path)
|
|
1590
1591
|
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
1591
1592
|
claim([(ass_path, "ASS")])
|
|
1592
|
-
w, h = meta["video"]
|
|
1593
|
-
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
1594
|
-
w, h = h, w
|
|
1593
|
+
w, h = display_size(meta["video"])
|
|
1595
1594
|
if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
|
|
1596
1595
|
write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
|
|
1597
1596
|
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
@@ -1681,9 +1680,12 @@ def main() -> int:
|
|
|
1681
1680
|
vf = drawn_chain(vf, meta, [args.color, args.outline_color]
|
|
1682
1681
|
+ ([args.highlight_color, args.upcoming_color] if args.karaoke else []))
|
|
1683
1682
|
|
|
1683
|
+
if square_pixels((meta or {}).get("video")):
|
|
1684
|
+
vf = square_pixels(meta["video"]) + "," + vf
|
|
1685
|
+
side_notes.append(square_pixels_note(meta["video"]))
|
|
1684
1686
|
cmd = ffmpeg_base() + ["-i", args.input]
|
|
1685
1687
|
chains, emoji_inputs = (emoji_filter_chain(emoji_plan or {}, "vsub", "vout", to_yuv=rgb_chain_to_bt709(meta),
|
|
1686
|
-
base_chain=
|
|
1688
|
+
base_chain=layer_base_chain(meta))
|
|
1687
1689
|
if emoji_plan else ([], []))
|
|
1688
1690
|
if chains:
|
|
1689
1691
|
for spec in emoji_inputs:
|
package/scripts/color.py
CHANGED
|
@@ -43,7 +43,7 @@ CORRECTION = {
|
|
|
43
43
|
"exposure": (0.0, -3.0, 3.0, "stops"), # exposure filter's own full range (linear-domain stops)
|
|
44
44
|
"contrast": (1.0, 0.0, 2.0, "x"), # eq filter; 0=flat grey, 1=unchanged, 2=double contrast
|
|
45
45
|
"saturation": (1.0, 0.0, 2.0, "x"), # eq filter; 0=grayscale, 1=unchanged, 2=double saturation
|
|
46
|
-
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (
|
|
46
|
+
"temperature": (6500.0, 2000.0, 12000.0, "K"), # colortemperature filter; 6500=unchanged (no term: the filter itself tints at 6500)
|
|
47
47
|
"tint": (0.0, -1.0, 1.0, "x"), # mapped to colorbalance midtones, see correction_chain()
|
|
48
48
|
"gamma": (1.0, 0.1, 10.0, "x"), # eq filter's own gamma option; 1=unchanged (its own default)
|
|
49
49
|
"lift": (0.0, -1.0, 1.0, "x"), # colorbalance shadows (rs=gs=bs); 0=unchanged
|
|
@@ -116,11 +116,13 @@ def correction_chain(args: argparse.Namespace) -> str:
|
|
|
116
116
|
die(f"--levels-out-black {out_black} must be less than --levels-out-white {out_white}")
|
|
117
117
|
|
|
118
118
|
gm, rm, bm = -tint, tint / 2.0, tint / 2.0
|
|
119
|
-
rgb = [
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
119
|
+
rgb = [f"exposure=exposure={exposure:g}"]
|
|
120
|
+
if temperature != CORRECTION["temperature"][0]:
|
|
121
|
+
# colortemperature at 6500 K is not the identity it is documented as here: it multiplies by
|
|
122
|
+
# its own approximation of that white point, which tinted grey 808080 to 7E7E7B (#410), so
|
|
123
|
+
# "unchanged" adds no term at all
|
|
124
|
+
rgb.append(f"colortemperature=temperature={temperature:g}")
|
|
125
|
+
rgb.append(f"colorbalance=rs={lift:g}:gs={lift:g}:bs={lift:g}:rm={rm:g}:gm={gm:g}:bm={bm:g}:rh={gain:g}:gh={gain:g}:bh={gain:g}")
|
|
124
126
|
terms = [_rgb_stage(rgb), f"eq=contrast={contrast:g}:saturation={saturation:g}:gamma={gamma:g}"]
|
|
125
127
|
rgb2 = []
|
|
126
128
|
if (in_black, in_white, out_black, out_white) != (0, 255, 0, 255):
|
|
@@ -156,6 +158,31 @@ def _rgb_stage(filters: list) -> str:
|
|
|
156
158
|
+ ",scale=out_color_matrix=bt601,format=yuv420p")
|
|
157
159
|
|
|
158
160
|
|
|
161
|
+
# swscale's names for the non-BT.709 matrices a --codec prores re-encode keeps (it does not convert)
|
|
162
|
+
_SWS_MATRIX = {"smpte170m": "bt601", "bt470bg": "bt601", "smpte240m": "smpte240m", "fcc": "fcc"}
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def lut_legs(meta=None) -> "tuple":
|
|
166
|
+
"""(to RGB, back to YUV) around --lut's lut3d: explicit BT.709 conversions through float RGB,
|
|
167
|
+
the matrix the picture holds when it reaches them (an untagged or BT.709 source as it is, a
|
|
168
|
+
BT.601 or RGB one after x264_args(source=...)'s conversion), limited range on the way out.
|
|
169
|
+
|
|
170
|
+
lut3d takes RGB, and left to libavfilter the way in honoured the frame's BT.709 tag while the
|
|
171
|
+
way back used swscale's BT.601 default, so even an identity LUT shifted every colour (2FAE4E to
|
|
172
|
+
249A4B, #410); --correct had its legs pinned for the same reason in #159. A LUT maps the RGB the
|
|
173
|
+
picture really shows, so unlike --correct's working-space pair (bt601 both ways, see
|
|
174
|
+
_rgb_stage) these legs use the BT.709 the picture is: an identity LUT then returns every sample
|
|
175
|
+
as --lut-strength 0, which applies none, does (6.1, BT.709/BT.601/untagged, limited and full
|
|
176
|
+
range, 8- and 10-bit). The input range is read from the frames, as every scale here reads it."""
|
|
177
|
+
# --codec prores keeps a BT.601 (or other non-BT.709) source's matrix and tags instead of
|
|
178
|
+
# converting it (decision.video_args), so the legs use that matrix (#416 review)
|
|
179
|
+
matrix = "bt709"
|
|
180
|
+
if STATE.codec == "prores":
|
|
181
|
+
matrix = _SWS_MATRIX.get(((meta or {}).get("video") or {}).get("color_space"), "bt709")
|
|
182
|
+
return (f"scale=in_color_matrix={matrix},format=gbrpf32le",
|
|
183
|
+
f"scale=out_color_matrix={matrix}:out_range=tv,format=yuv420p")
|
|
184
|
+
|
|
185
|
+
|
|
159
186
|
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
160
187
|
v = meta["video"]
|
|
161
188
|
trc = v.get("color_transfer") or "smpte2084"
|
|
@@ -442,6 +469,7 @@ def main() -> int:
|
|
|
442
469
|
if not (0.0 <= args.lut_strength <= 1.0):
|
|
443
470
|
die(f"--lut-strength {args.lut_strength:g} is outside 0..1")
|
|
444
471
|
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
|
472
|
+
to_rgb, to_yuv = lut_legs(meta)
|
|
445
473
|
if args.lut_strength == 0.0:
|
|
446
474
|
# 0 means "no LUT at all" -- without this branch, 0 (falling outside the open interval
|
|
447
475
|
# below) landed in the same "apply the LUT at full strength" fallback as an out-of-range
|
|
@@ -450,9 +478,10 @@ def main() -> int:
|
|
|
450
478
|
vf = "format=yuv420p"
|
|
451
479
|
elif args.lut_strength < 1.0:
|
|
452
480
|
# blend graded and original
|
|
453
|
-
|
|
481
|
+
# blended in RGB, both pictures through the same two legs
|
|
482
|
+
vf = f"{to_rgb},split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},{to_yuv}"
|
|
454
483
|
else:
|
|
455
|
-
vf = f"{lut},
|
|
484
|
+
vf = f"{to_rgb},{lut},{to_yuv}"
|
|
456
485
|
output = args.output or default_output(args.input, "lut")
|
|
457
486
|
tag = "lut"
|
|
458
487
|
|
package/scripts/crop.py
CHANGED
|
@@ -22,6 +22,8 @@ import argparse
|
|
|
22
22
|
import sys
|
|
23
23
|
|
|
24
24
|
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs
|
|
25
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
26
|
+
from _common.emit import note
|
|
25
27
|
|
|
26
28
|
|
|
27
29
|
def main() -> int:
|
|
@@ -51,15 +53,18 @@ def main() -> int:
|
|
|
51
53
|
meta = probe(args.input)
|
|
52
54
|
if not meta.get("video"):
|
|
53
55
|
die("input has no video stream")
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
56
|
+
# the rectangle is in the displayed frame cropdetect.py reports: rotation applied, and a
|
|
57
|
+
# non-square-pixel source squared at its display aspect first (#411)
|
|
58
|
+
sw, sh = display_size(meta["video"])
|
|
57
59
|
if args.x + args.width > sw or args.y + args.height > sh:
|
|
58
60
|
die(f"crop rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
|
|
59
61
|
has_audio = bool(meta.get("audio"))
|
|
60
62
|
|
|
61
63
|
output = args.output or default_output(args.input, "crop")
|
|
62
64
|
vf = [f"crop={args.width}:{args.height}:{args.x}:{args.y}", "setsar=1"]
|
|
65
|
+
if square_pixels(meta["video"]):
|
|
66
|
+
vf.insert(0, square_pixels(meta["video"]))
|
|
67
|
+
note(square_pixels_note(meta["video"]))
|
|
63
68
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", ",".join(vf)]
|
|
64
69
|
cmd += video_args(meta, args.crf, args.preset)
|
|
65
70
|
cmd += cfr_args(meta, args.fps)
|
package/scripts/cropdetect.py
CHANGED
|
@@ -34,7 +34,8 @@ from collections import Counter
|
|
|
34
34
|
from typing import Dict, List, Optional, Tuple
|
|
35
35
|
|
|
36
36
|
from _common import add_common, apply_common, die, emit, info, print_json, probe, require_tool, run_analysis, decode_gray_frames, frame_flow
|
|
37
|
-
from _common.emit import warn
|
|
37
|
+
from _common.emit import note, warn
|
|
38
|
+
from _common.probe import display_size, square_pixels, square_pixels_note
|
|
38
39
|
|
|
39
40
|
MOTION_CENTRE_FPS = 2.0
|
|
40
41
|
MOTION_CENTRE_W, MOTION_CENTRE_H = 64, 36
|
|
@@ -43,10 +44,11 @@ CROP_RE = re.compile(r"crop=(\d+):(\d+):(\d+):(\d+)")
|
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
def detect(path: str, seconds: float, samples: int, limit: float, round_to: int, duration: float,
|
|
46
|
-
failed: "Optional[List[Dict]]" = None) -> List[Tuple[int, int, int, int]]:
|
|
47
|
+
failed: "Optional[List[Dict]]" = None, square: str = "") -> List[Tuple[int, int, int, int]]:
|
|
47
48
|
"""Crop rectangles from `samples` windows spread over the file. A window ffmpeg cannot decode
|
|
48
49
|
is skipped, and appended to `failed` as {start, seconds, error} when the caller passes a list,
|
|
49
|
-
so the result can say how many of the readings it stands on.
|
|
50
|
+
so the result can say how many of the readings it stands on. `square` is the source's
|
|
51
|
+
square_pixels() scale, run first so the rectangle is in the frame crop.py crops (#411)."""
|
|
50
52
|
ffmpeg = require_tool("ffmpeg")
|
|
51
53
|
per_window = max(0.5, seconds / max(1, samples))
|
|
52
54
|
rects: List[Tuple[int, int, int, int]] = []
|
|
@@ -55,7 +57,7 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
|
|
|
55
57
|
start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
|
|
56
58
|
start = max(0.0, start)
|
|
57
59
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
|
|
58
|
-
"-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
60
|
+
"-vf", (square + "," if square else "") + f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
|
|
59
61
|
proc = run_analysis(cmd, check=False)
|
|
60
62
|
if proc.returncode != 0:
|
|
61
63
|
# One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
|
|
@@ -144,16 +146,18 @@ def main() -> int:
|
|
|
144
146
|
meta = probe(args.input)
|
|
145
147
|
if not meta.get("video"):
|
|
146
148
|
die("input has no video stream")
|
|
147
|
-
sw, sh = meta["video"]["width"], meta["video"]["height"]
|
|
148
149
|
# cropdetect and the grey decode both see the frame after ffmpeg applies rotation, so the
|
|
149
150
|
# rectangle and the motion points are in that frame. Reporting the stored size made a
|
|
150
|
-
# 176x320 crop look taller than a 320x180 source.
|
|
151
|
-
|
|
152
|
-
|
|
151
|
+
# 176x320 crop look taller than a 320x180 source. A non-square-pixel source is measured
|
|
152
|
+
# squared at its display aspect, the frame crop.py then crops (#411).
|
|
153
|
+
sw, sh = display_size(meta["video"])
|
|
154
|
+
if square_pixels_note(meta["video"]):
|
|
155
|
+
note(square_pixels_note(meta["video"]))
|
|
153
156
|
duration = meta.get("duration") or 0.0
|
|
154
157
|
|
|
155
158
|
failed: List[Dict] = []
|
|
156
|
-
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed
|
|
159
|
+
rects = detect(args.input, args.seconds, args.samples, args.limit, args.round_to, duration, failed,
|
|
160
|
+
square_pixels(meta["video"]))
|
|
157
161
|
# windows_failed: a window ffmpeg could not decode was skipped without a word, so a
|
|
158
162
|
# crop measured on one window of five read as well-founded as one measured on all five
|
|
159
163
|
result: Dict = {"file": args.input, "source_width": sw, "source_height": sh,
|
package/scripts/cut.py
CHANGED
|
@@ -61,6 +61,7 @@ from typing import List, Tuple
|
|
|
61
61
|
from _common import (beat_grid, snap_points, decode_pcm_mono, rms_envelope, BEAT_MIN_CONFIDENCE)
|
|
62
62
|
from _common import require_tool
|
|
63
63
|
from _common.probe import TS_EXTS, signatures_match, stream_signature
|
|
64
|
+
from _common.decision import grid_at_or_after
|
|
64
65
|
from _common import warn, sibling_temp, source_codec_video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, time_arg, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, place_output, refuse_output_is_input, fmt_secs, measure_frame_timing, dry_run_input_pending
|
|
65
66
|
|
|
66
67
|
# outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
|
|
@@ -1013,10 +1014,9 @@ def _fraction_rate(text: str) -> float:
|
|
|
1013
1014
|
return float(num) / float(den or 1)
|
|
1014
1015
|
|
|
1015
1016
|
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
return v0 + math.ceil((t - v0) * rate - 1e-6) / rate
|
|
1017
|
+
# the first frame at or after t on the source's grid; shared with the tools that cut both streams
|
|
1018
|
+
# on one set of bounds (#409)
|
|
1019
|
+
_grid_at_or_after = grid_at_or_after
|
|
1020
1020
|
|
|
1021
1021
|
|
|
1022
1022
|
def _join_chunk(src: str, segments: List[Tuple[float, float]], dst: str, meta: dict, crf: int, preset: str,
|
package/scripts/export.py
CHANGED
|
@@ -38,9 +38,10 @@ import tempfile
|
|
|
38
38
|
from pathlib import Path
|
|
39
39
|
from typing import Dict, List
|
|
40
40
|
|
|
41
|
-
from _common import STATE, run_analysis, add_common, apply_common, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
41
|
+
from _common import STATE, run_analysis, add_common, apply_common, bt709_label_filter, bt709_matrix_args, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, require_tool, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
42
42
|
from _common.decision import hw_preset_video, restate_last_swap
|
|
43
43
|
from _common.emit import note, record_fallback, record_skip, warn
|
|
44
|
+
from _common.probe import square_pixels, square_pixels_note
|
|
44
45
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
45
46
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, TP_TOLERANCE_DB, resolve as resolve_platform
|
|
46
47
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
@@ -358,6 +359,9 @@ def main() -> int:
|
|
|
358
359
|
out_ext = Path(output).suffix.lstrip(".").lower()
|
|
359
360
|
|
|
360
361
|
vf: List[str] = []
|
|
362
|
+
# A GIF is RGB: an untagged SDR source is labelled BT.709 (tags only) for palettegen's conversion,
|
|
363
|
+
# which read "unknown" as BT.601 (C03028 came out B31F28, #410); a tagged one is read by its tag
|
|
364
|
+
gif_label = bt709_label_filter(meta) if args.preset == "gif" else None
|
|
361
365
|
if p["w"] and not args.no_scale:
|
|
362
366
|
if p["h"]:
|
|
363
367
|
if args.fit == "crop":
|
|
@@ -370,6 +374,11 @@ def main() -> int:
|
|
|
370
374
|
vf.append("setsar=1")
|
|
371
375
|
else:
|
|
372
376
|
vf.append(f"scale={p['w']}:-2")
|
|
377
|
+
# sized in square pixels: a non-square-pixel master is squared at its display aspect
|
|
378
|
+
# first, or the preset's frame would squash it (#411)
|
|
379
|
+
if square_pixels(meta["video"]):
|
|
380
|
+
vf.insert(0, square_pixels(meta["video"]))
|
|
381
|
+
note(square_pixels_note(meta["video"]))
|
|
373
382
|
if args.repair and args.preset != "copy":
|
|
374
383
|
die("--repair is a remux: use it with --preset copy (a re-encoding preset already rebuilds every frame)")
|
|
375
384
|
if args.max_mb is not None and args.preset == "gif":
|
|
@@ -387,7 +396,7 @@ def main() -> int:
|
|
|
387
396
|
cmd += ["-map", "0:V:0"] + (["-map", "0:a:0"] if has_audio else [])
|
|
388
397
|
|
|
389
398
|
if args.preset == "gif":
|
|
390
|
-
chain = ",".join([
|
|
399
|
+
chain = ",".join(([gif_label] if gif_label else []) + ["fps=12"] + vf)
|
|
391
400
|
fc = f"[0:v]{chain},split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5:diff_mode=rectangle"
|
|
392
401
|
cmd += ["-filter_complex", fc, "-loop", "0", output]
|
|
393
402
|
run(cmd)
|
package/scripts/fit.py
CHANGED
|
@@ -39,7 +39,7 @@ import sys
|
|
|
39
39
|
from fractions import Fraction
|
|
40
40
|
from typing import List
|
|
41
41
|
|
|
42
|
-
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size
|
|
42
|
+
from _common import video_args, STATE, add_common, apply_common, emit, aac_args, aspect_ratio, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg, fmt_secs, frame_size, display_size, square_pixels, square_pixels_note, note
|
|
43
43
|
BLUR_DARKEN = 0.15 # how much --fit blur dims the blurred background copy (eq brightness)
|
|
44
44
|
|
|
45
45
|
|
|
@@ -125,9 +125,8 @@ def main() -> int:
|
|
|
125
125
|
if args.audio_stream and not audio_streams:
|
|
126
126
|
die("--audio-stream needs an input with audio streams")
|
|
127
127
|
src_dur = meta["duration"] or 0.0
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
sw, sh = sh, sw
|
|
128
|
+
# the displayed frame: rotation applied and non-square pixels squared (#411)
|
|
129
|
+
sw, sh = display_size(meta["video"])
|
|
131
130
|
if args.rotate in (90, 270):
|
|
132
131
|
sw, sh = sh, sw
|
|
133
132
|
has_audio = bool(meta.get("audio"))
|
|
@@ -204,6 +203,11 @@ def main() -> int:
|
|
|
204
203
|
else:
|
|
205
204
|
vf.append(pad_filters(out_w, out_h, args.pad_fill, args.pad_color, args.pad_blur, meta=meta))
|
|
206
205
|
vf.append("setsar=1")
|
|
206
|
+
# a non-square-pixel source is made square at its display aspect before anything else
|
|
207
|
+
# (the size above is computed in that frame), so the fitted picture is not squashed
|
|
208
|
+
if square_pixels(meta["video"]):
|
|
209
|
+
vf.insert(0, square_pixels(meta["video"]))
|
|
210
|
+
note(square_pixels_note(meta["video"]))
|
|
207
211
|
|
|
208
212
|
if args.fps:
|
|
209
213
|
vf.append(f"fps={args.fps:g}")
|
package/scripts/freeze.py
CHANGED
|
@@ -19,6 +19,7 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
+
from _common.decision import av_trim, frame_durations, frame_rate, grid_at_or_after, stream_starts
|
|
22
23
|
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, MissingFpsError, time_arg, fmt_secs, run
|
|
23
24
|
|
|
24
25
|
|
|
@@ -55,14 +56,24 @@ def main() -> int:
|
|
|
55
56
|
has_audio = bool(meta.get("audio"))
|
|
56
57
|
output = args.output or default_output(args.input, "freeze")
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
# Both streams are cut at the same frame time and shifted by the same constants (#409): the
|
|
60
|
+
# head runs from the first frame, the tail from the first frame at or after --at. Rebasing each
|
|
61
|
+
# stream to its own start moved a sound that starts 0.3 s late 0.279 s early, and a picture that
|
|
62
|
+
# starts 0.1 s late 0.1 s early (147 frames for 150).
|
|
63
|
+
v0 = stream_starts(meta)[0]
|
|
64
|
+
rate = frame_rate(meta) or fps
|
|
65
|
+
cut_at = grid_at_or_after(at, v0, rate)
|
|
66
|
+
# no frame at or after --at (no --at holds at the clip's end): the insert tail would be empty
|
|
67
|
+
# and the hold lost, so hold the last frame as --mode extend does (#416 review)
|
|
68
|
+
vend = v0 + (meta["video"].get("duration") or dur - v0)
|
|
69
|
+
if args.mode == "extend" or cut_at >= vend - 0.5 / rate:
|
|
59
70
|
# tpad's stop_duration clones the last frame for the given duration; no PTS surgery
|
|
60
71
|
# needed since it only ever appends past the real end.
|
|
61
72
|
vf = f"tpad=stop_mode=clone:stop_duration={args.hold:.3f}"
|
|
62
73
|
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0"]
|
|
63
74
|
if has_audio:
|
|
64
75
|
cmd += ["-map", "0:a:0?", "-af", f"apad=pad_dur={args.hold:.3f}"]
|
|
65
|
-
elif at == 0:
|
|
76
|
+
elif at == 0 or cut_at <= v0 + 1e-6:
|
|
66
77
|
# A freeze at the very start has no preceding "head" segment to hold on to (the
|
|
67
78
|
# split/trim/concat approach below needs a non-empty head, which trim=end=0 can't give
|
|
68
79
|
# it -- ffmpeg fails filtering an empty stream). Symmetric to --mode extend at the other
|
|
@@ -73,22 +84,39 @@ def main() -> int:
|
|
|
73
84
|
cmd += ["-map", "0:a:0?", "-af", f"adelay={int(args.hold * 1000)}:all=1"]
|
|
74
85
|
else:
|
|
75
86
|
# freeze N frames at `at` by holding on that one source frame for --hold seconds, then
|
|
76
|
-
# resuming the rest of the clip: split the timeline at `at
|
|
77
|
-
#
|
|
87
|
+
# resuming the rest of the clip: split the timeline at `at` and hold the remainder's
|
|
88
|
+
# FIRST frame -- the frame shown at `at` -- for --hold seconds (a segment of its own, below).
|
|
89
|
+
# Through 3.1.3 the head's last frame was held, the frame before `at`: at a cut, the last
|
|
90
|
+
# frame of the previous shot (#411).
|
|
78
91
|
n = max(1, round(args.hold * fps))
|
|
79
92
|
# The video hold length is rounded to a whole number of frames (n / fps), but the audio
|
|
80
93
|
# side used to pad by the raw --hold value -- up to half a frame duration off from what
|
|
81
94
|
# the video actually holds for, a permanent A/V drift from this point on. Pad audio by
|
|
82
95
|
# the same, frame-rounded duration the video actually gets.
|
|
83
96
|
actual_hold = n / fps
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
97
|
+
head_v, head_a = av_trim(v0, cut_at, rate)
|
|
98
|
+
tail_v, tail_a = av_trim(cut_at, None, rate)
|
|
99
|
+
# The hold is its own segment: the frame at cut_at, cut on the input's clock and cloned to
|
|
100
|
+
# n frames there (tpad's stop clone), then shifted like the tail. From FFmpeg 7.0 setpts
|
|
101
|
+
# leaves its output's frame rate unknown and its frames' durations 0, so the earlier tpad
|
|
102
|
+
# start clone after av_trim's setpts stamped every clone 0 and the hold was dropped (a 3 s
|
|
103
|
+
# freeze came out 1.97 s on 7.1). Cloning before setpts and concatenating the hold before
|
|
104
|
+
# the tail reads the same on every version; the tail's first frame then shows n + 1 times,
|
|
105
|
+
# as tpad's start clone showed it.
|
|
106
|
+
# The held frame is the first frame at or after the cut, whatever its time: on a variable
|
|
107
|
+
# frame rate a nominal one-frame window around cut_at can hold no frame (frames at 0.033
|
|
108
|
+
# and 0.100, --at 0.06), and the hold was lost (#416 review). A second trim keeps that one
|
|
109
|
+
# frame; on a constant rate it is the frame at cut_at, as before.
|
|
110
|
+
half = 0.5 / rate
|
|
111
|
+
hold_v = (f"trim=start={cut_at - half:.6f},trim=end_frame=1,"
|
|
112
|
+
f"tpad=stop_mode=clone:stop={n - 1},setpts=PTS-STARTPTS")
|
|
113
|
+
vf = (f"[0:v]split=3[a][b][c];[a]{head_v}[head];"
|
|
114
|
+
f"[b]{hold_v}[hold];[c]{tail_v}[tail];"
|
|
115
|
+
f"[head][hold][tail]concat=n=3:v=1:a=0,{frame_durations(meta, rate)}[outv]")
|
|
88
116
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", vf, "-map", "[outv]"]
|
|
89
117
|
if has_audio:
|
|
90
|
-
af = (f"[0:a]asplit[aa][ab];[aa]
|
|
91
|
-
f"[ab]
|
|
118
|
+
af = (f"[0:a]asplit[aa][ab];[aa]{head_a}[ahead];"
|
|
119
|
+
f"[ab]{tail_a}[atail];"
|
|
92
120
|
f"[ahead]apad=pad_dur={actual_hold:.6f}[afrozen];"
|
|
93
121
|
f"[afrozen][atail]concat=n=2:v=0:a=1[outa]")
|
|
94
122
|
cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", f"{vf};{af}", "-map", "[outv]", "-map", "[outa]"]
|