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.
@@ -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="-stream_loop always re-encodes both streams; the audio codec is always AAC when present. --boomerang re-encodes the video through reverse/loop filters and drops the audio"),
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 STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, draws_chroma, drawn_chain, emit, ffmpeg_base, info, time_arg, label_chain, matrix_chain, probe, run, validate_color, video_args, X264_PRESETS, fmt_secs
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
- w, h = meta_a["video"]["width"], meta_a["video"]["height"]
59
- # the cutaway is scaled onto the frame ffmpeg has already rotated, not the stored one
60
- if meta_a["video"].get("rotation") in (90, -90, 270, -270):
61
- w, h = h, w
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
- if convert[0]:
114
- parts.append(f"[0:v]{convert[0]}null[a709]")
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
- parts.append(f"[{i + 1}:a:0]atrim=start={c['from']:.3f}:duration={c['length']:.3f},asetpts=PTS-STARTPTS,"
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]")
@@ -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
- play_w, play_h = meta["video"]["width"], meta["video"]["height"]
1143
- if meta["video"].get("rotation") in (90, -90, 270, -270):
1144
- play_w, play_h = play_h, play_w
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"]["width"], meta["video"]["height"]
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=label_chain(meta, meta) if rgb_chain_to_bt709(meta) else "")
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 (its own default)
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
- f"exposure=exposure={exposure:g}",
121
- f"colortemperature=temperature={temperature:g}",
122
- 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}",
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
- vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
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},format=yuv420p"
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
- sw, sh = meta["video"]["width"], meta["video"]["height"]
55
- if meta["video"].get("rotation") in (90, -90, 270, -270):
56
- sw, sh = sh, sw
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)
@@ -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
- if meta["video"].get("rotation") in (90, -90, 270, -270):
152
- sw, sh = sh, sw
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
- def _grid_at_or_after(t: float, v0: float, rate: float) -> float:
1017
- """The time of the first frame at or after `t` on a constant-rate grid starting at `v0`."""
1018
- import math
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(["fps=12"] + vf) if vf else "fps=12"
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
- sw, sh = meta["video"]["width"], meta["video"]["height"]
129
- if meta["video"].get("rotation") in (90, -90, 270, -270):
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
- if args.mode == "extend":
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`, freeze-frame the first
77
- # part's last frame for --hold seconds via tpad, concat with the remainder.
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
- vf = (f"[0:v]split[a][b];[a]trim=end={at:.3f},setpts=PTS-STARTPTS[head];"
85
- f"[b]trim=start={at:.3f},setpts=PTS-STARTPTS[tail];"
86
- f"[head]tpad=stop_mode=clone:stop={n}[frozen];"
87
- f"[frozen][tail]concat=n=2:v=1:a=0[outv]")
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]atrim=end={at:.3f},asetpts=PTS-STARTPTS[ahead];"
91
- f"[ab]atrim=start={at:.3f},asetpts=PTS-STARTPTS[atail];"
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]"]