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/scripts/loop.py CHANGED
@@ -21,11 +21,15 @@ Examples:
21
21
  python3 loop.py wave.mp4 --boomerang --times 4
22
22
  """
23
23
  import argparse
24
+ import json
24
25
  import math
26
+ import os
25
27
  import sys
28
+ import tempfile
26
29
 
27
- from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg, fmt_secs
30
+ from _common import concat_list_line, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, refuse_output_is_input, require_tool, run, video_args, X264_PRESETS, time_arg, fmt_secs
28
31
  from _common.emit import warn
32
+ from _common.decision import av_trim
29
33
 
30
34
 
31
35
  # The loop filter's size is capped at 32767 frames, so one boomerang cycle (2N-2 frames) must fit.
@@ -95,6 +99,21 @@ def _nominal_rate(v) -> str:
95
99
  return f"{counted:g}" if 0 < counted <= MAX_PLAUSIBLE_FPS else "30"
96
100
 
97
101
 
102
+ def concat_first_frame(listfile: str) -> float:
103
+ """Where the first frame sits on the clock ffmpeg reads a concat list on: the list's first video
104
+ start minus its start_time. The concat demuxer counts an MP4's hidden AAC priming packet into its
105
+ start (-0.021 s) where the MP4 demuxer does not, so ffmpeg moves both streams 21 ms later, and a
106
+ frame-rate time base then rounded the picture's move to 33 ms (12 ms off). 0.0 when unmeasured."""
107
+ proc = run([require_tool("ffprobe"), "-v", "error", "-f", "concat", "-safe", "0", "-i", listfile,
108
+ "-select_streams", "v:0", "-show_entries", "stream=start_time:format=start_time", "-of", "json"],
109
+ quiet=True, check=False)
110
+ try:
111
+ doc = json.loads(proc.stdout or "")
112
+ return float(doc["streams"][0]["start_time"]) - float(doc["format"]["start_time"])
113
+ except (ValueError, KeyError, IndexError, TypeError):
114
+ return 0.0
115
+
116
+
98
117
  def main() -> int:
99
118
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
100
119
  ap.add_argument("input")
@@ -119,6 +138,8 @@ def main() -> int:
119
138
  has_audio = bool(meta.get("audio")) and not args.boomerang
120
139
  notes = []
121
140
  output = args.output or default_output(args.input, "loop")
141
+ # the concat list is the only -i, so run() never sees the source next to -o (#416 review)
142
+ refuse_output_is_input(output, args.input)
122
143
 
123
144
  if args.times is not None:
124
145
  if args.times < (1 if args.boomerang else 2):
@@ -137,10 +158,24 @@ def main() -> int:
137
158
  if meta.get("audio"):
138
159
  notes.append("audio dropped: a boomerang plays half its sound backwards -- add a bed with audio.py")
139
160
  else:
140
- # -stream_loop repeats the whole input read (video and audio together) at the demuxer level
141
- # -- exact and lossless-in-intent for a re-encode target, unlike a filter-graph loop that
142
- # would need separate video/audio filters kept in lockstep by hand.
143
- cmd = ffmpeg_base() + ["-stream_loop", str(stream_loop), "-i", args.input]
161
+ # The concat demuxer reads the clip once per repeat and starts each repeat at the previous
162
+ # one's start plus the file's duration (#409): both streams keep their offset from the
163
+ # file's start in every repeat. -stream_loop took the repeat's length from its streams'
164
+ # spans without the last video frame, so a sound that starts 0.3 s late shortened every
165
+ # repeat by a frame and slid against the picture (346 frames for 360, 68 ms off).
166
+ # Both streams start at the first frame, shifted by one constant (av_trim), and the gap a
167
+ # late sound leaves at each repeat's start is filled with silence, at any length.
168
+ workdir = tempfile.TemporaryDirectory(prefix="ffskill_loop_")
169
+ listfile = os.path.join(workdir.name, "loop.txt")
170
+ with open(listfile, "w", encoding="utf-8") as fh:
171
+ fh.write((concat_list_line(os.path.abspath(args.input)) + "\n") * (stream_loop + 1))
172
+ vtrim, atrim = av_trim(concat_first_frame(listfile), None, fill_gaps=True)
173
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile]
174
+ if has_audio:
175
+ cmd += ["-filter_complex", f"[0:v:0]{vtrim}[v];[0:a:0]{atrim}[a]",
176
+ "-map", "[v]", "-map", "[a]"]
177
+ else:
178
+ cmd += ["-map", "0:v:0", "-vf", vtrim]
144
179
  if target is not None:
145
180
  cmd += ["-t", f"{target:.3f}"]
146
181
  cmd += video_args(meta, args.crf, args.preset)
@@ -150,7 +185,11 @@ def main() -> int:
150
185
  else:
151
186
  cmd += ["-an"]
152
187
  cmd.append(output)
153
- run(cmd)
188
+ try:
189
+ run(cmd)
190
+ finally:
191
+ if not args.boomerang:
192
+ workdir.cleanup()
154
193
 
155
194
  result = probe(output, role="output")
156
195
  v = result["video"]
@@ -31,8 +31,10 @@ import math
31
31
  import sys
32
32
  from typing import Dict, List, Tuple
33
33
 
34
- from _common import guard_sidecar, label_chain, matrix_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
- from _common.emit import record_fallback, warn
34
+ from _common import guard_sidecar, label_chain, matrix_chain, range_chain, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, time_arg, probe, run, x264_args, X264_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
35
+ from _common.emit import note, record_fallback, warn
36
+ from _common.decision import av_trim
37
+ from _common.probe import display_size, square_pixels, square_pixels_note
36
38
  from sync import measure_offset
37
39
 
38
40
  ENERGY_WINDOW_S = 0.25 # --switch energy: loudness measured over this window on the reference timeline
@@ -263,14 +265,16 @@ def main() -> int:
263
265
  info(f"wrote {args.edl}")
264
266
 
265
267
  v0 = metas[0]["video"]
266
- w, h = args.width or v0["width"], args.height or v0["height"]
267
- if v0.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
268
- w, h = h, w
268
+ # camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
269
+ w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
269
270
  fps = args.fps or v0.get("fps") or 30.0
270
271
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
271
272
  pixfmt = "yuv420p10le" if v0.get("bt2020_or_hdr") else "yuv420p"
272
273
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
273
274
 
275
+ for p, m in zip(args.inputs, metas):
276
+ if square_pixels(m.get("video")):
277
+ note(square_pixels_note(m["video"], p))
274
278
  cmd = ffmpeg_base()
275
279
  for p in args.inputs:
276
280
  cmd += ["-i", p]
@@ -283,6 +287,8 @@ def main() -> int:
283
287
  convert = [matrix_chain(m, metas[0]) for m in metas]
284
288
  if any(convert):
285
289
  convert = [c or label_chain(m, metas[0]) for c, m in zip(convert, metas)]
290
+ # concat mixes samples: a full-range camera beside a limited one is taken to limited range (#410)
291
+ convert = [c + range_chain(m, metas[0]) for c, m in zip(convert, metas)]
286
292
  # what is actually rendered: a range whose camera has not started yet plays camera 0, and the
287
293
  # result says so (`cuts` names the camera on screen, diagnostics.fallbacks the one asked for)
288
294
  rendered: List[Tuple[float, float, int]] = []
@@ -296,7 +302,8 @@ def main() -> int:
296
302
  requested_camera=c, camera=0, starts_at=round(offsets[c], 3))
297
303
  c, src_s, src_e = 0, s, e
298
304
  rendered.append((s, e, c))
299
- parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{geo}[v{i}]")
305
+ sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
306
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{sq}{geo}[v{i}]")
300
307
  labels.append(f"[v{i}]")
301
308
  project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
302
309
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
@@ -306,7 +313,9 @@ def main() -> int:
306
313
  # applied before asetrate/aresample rescale that axis -- otherwise the trim lands at the wrong
307
314
  # point once the stream's timebase has already been stretched/compressed by the drift ratio
308
315
  # (mirrors sync.py, which seeks with -ss, an input-level operation, before its drift_af filters).
309
- afx = [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS"]
316
+ # shifted by a_start, not to its own first sample: a sound that starts after its file's start
317
+ # keeps that offset (padded with silence), as the offset was measured on the file's clock (#409)
318
+ afx = [av_trim(a_start, None)[1]]
310
319
  if abs(ratios[a] - 1.0) > 1e-7:
311
320
  sr = metas[a]["audio"].get("sample_rate") or 48000
312
321
  afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
@@ -31,8 +31,11 @@ from typing import List, Optional
31
31
 
32
32
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
33
33
  from _common import still_input_args # noqa: E402
34
- 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, matrix_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
35
- from _common.emit import warn
34
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
35
+ from _common.emit import note, warn
36
+ from _common.probe import display_size, square_pixels, square_pixels_note
37
+ from _common.wrap import wrap_text
38
+ from _common import text_width_em
36
39
  from _common.fonts import font_choice, record_font_choice
37
40
  from _common import drawtext_fontfile # noqa: E402
38
41
 
@@ -74,6 +77,48 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
74
77
  return x, y
75
78
 
76
79
 
80
+ def _wrap_to_frame(args, frame_w: int, margins: Optional[dict], frame_h: int = 0) -> str:
81
+ """--text broken into lines that fit between the frame's side margins at --font-size, by the
82
+ same phrase rules graphics.py and caption.py wrap with; text that fits comes back unchanged.
83
+ drawtext never wraps, so through 3.1.3 a long line ran off the frame with no word (#411). The
84
+ width is the averaged advance table's estimate, so a wrapped run says so in its notes."""
85
+ text = args.text or ""
86
+ if not (frame_w and text and args.font_size > 0):
87
+ return text
88
+ m = {edge: int((margins or {}).get(edge, args.margin)) for edge in ("left", "right")}
89
+ left, right = m["left"], m["right"]
90
+ if args.position not in POS:
91
+ try:
92
+ xv = int(args.position.split(",")[0])
93
+ left, right = (xv, right) if xv >= 0 else (0, -xv)
94
+ except ValueError:
95
+ pass
96
+ inset = 2 * args.border + (24 if args.box else 0) # the border on both sides, boxborderw=12
97
+ room = frame_w - left - right - inset
98
+ if room <= args.font_size:
99
+ # a position this close to the edge leaves no room to wrap in: say the text runs off
100
+ # rather than draw it past the edge without a word (#416 review)
101
+ if max(text_width_em(ln) for ln in text.split("\n")) * args.font_size > max(room, 0):
102
+ note(f"--text does not fit: --position {args.position} leaves {max(room, 0)} px to the frame's edge "
103
+ f"at --font-size {args.font_size}, so the text runs past it")
104
+ return text
105
+ lines: List[str] = []
106
+ for para in text.split("\n"):
107
+ lines.extend(wrap_text(para, room / float(args.font_size), lang=getattr(args, "lang", None),
108
+ slice_overlong=True) if para.strip() else [para])
109
+ wrapped = "\n".join(lines)
110
+ if wrapped != text:
111
+ note(f"--text is wider than the frame at --font-size {args.font_size} "
112
+ f"({frame_w} px, {left}/{right} px margins): wrapped to {len(lines)} lines")
113
+ # the wrap only measures width: a block taller than the frame loses the lines past its
114
+ # edge (at a bottom position, the first ones), so that is said too (#416 review)
115
+ tall = args.font_size + (len(lines) - 1) * int(args.font_size * 1.2)
116
+ if frame_h and tall > frame_h:
117
+ note(f"the {len(lines)} wrapped lines are about {tall} px tall, taller than the {frame_h} px frame: "
118
+ f"the lines past its edge are not drawn -- lower --font-size or shorten --text")
119
+ return wrapped
120
+
121
+
77
122
  def enable_expr(start: Optional[float], end: Optional[float]) -> str:
78
123
  if start is None and end is None:
79
124
  return ""
@@ -151,7 +196,8 @@ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: arg
151
196
  _PLACE_TIME = r"[^@\-]+(?:@[0-9.]+)?"
152
197
 
153
198
 
154
- def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float]) -> List[tuple]:
199
+ def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Optional[float],
200
+ layer_meta: Optional[dict] = None, main_meta: Optional[dict] = None) -> List[tuple]:
155
201
  """--place SRC_START-SRC_END@AT -> [(src_start, src_end, at)], refusing empty, negative or
156
202
  overlapping placements (two pieces on screen at once would stack in an order nobody asked for).
157
203
  SRC_START/SRC_END are positions in the layer, so an hh:mm:ss:ff timecode there counts the
@@ -161,9 +207,9 @@ def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Opt
161
207
  m = re.fullmatch(rf"\s*({_PLACE_TIME})-({_PLACE_TIME})@({_PLACE_TIME})\s*", spec)
162
208
  if not m:
163
209
  die(f"--place {spec!r}: use SRC_START-SRC_END@AT, e.g. 0-2.5@12 (times are not negative)")
164
- a = time_arg(m.group(1), "--place", layer_fps)
165
- b = time_arg(m.group(2), "--place", layer_fps)
166
- at = time_arg(m.group(3), "--place", main_fps)
210
+ a = time_arg(m.group(1), "--place", layer_fps, source=layer_meta)
211
+ b = time_arg(m.group(2), "--place", layer_fps, source=layer_meta)
212
+ at = time_arg(m.group(3), "--place", main_fps, source=main_meta)
167
213
  if b <= a:
168
214
  die(f"--place {spec!r}: the layer range ends before it starts")
169
215
  out.append((a, b, at))
@@ -304,12 +350,14 @@ def main() -> int:
304
350
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
305
351
  if args.audio_stream and not audio_streams:
306
352
  die("--audio-stream needs an input with audio streams")
307
- vw = meta["video"]["width"] or 0
308
- vh = meta["video"].get("height") or 0
309
353
  # ffmpeg applies display rotation before the filter graph, so the frame this overlay is
310
354
  # composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
311
- if meta["video"].get("rotation") in (90, -90, 270, -270):
312
- vw, vh = vh, vw
355
+ # A non-square-pixel source is made square at its display aspect first (#411), so a logo or
356
+ # a line of text is not drawn stretched by the player's SAR.
357
+ vw, vh = display_size(meta["video"])
358
+ square = square_pixels(meta["video"])
359
+ if square:
360
+ note(square_pixels_note(meta["video"]))
313
361
  # --platform: the edges this destination's own UI covers, in pixels of this frame. An
314
362
  # explicit --margin (or a brand safe_margin, applied above) is the more specific statement
315
363
  # and wins; without either, the historical 24 px default is unchanged.
@@ -377,7 +425,7 @@ def main() -> int:
377
425
  # the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
378
426
  # RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
379
427
  # 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
380
- base = label_chain(meta, meta) if rgb_chain_to_bt709(meta) else ""
428
+ base = layer_base_chain(meta)
381
429
  fc = (f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS{rgb_chain_to_bt709(meta)}[ov];"
382
430
  + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
383
431
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
@@ -398,7 +446,7 @@ def main() -> int:
398
446
  if not pip_meta.get("video"):
399
447
  die(f"--video {args.video} has no video stream")
400
448
  layer_fps = pip_meta["video"].get("fps")
401
- placements = parse_placements(args.place, fps, layer_fps) if args.place else []
449
+ placements = parse_placements(args.place, fps, layer_fps, pip_meta, meta) if args.place else []
402
450
  # bounds from the video streams (a longer audio track would let a range run past the last
403
451
  # picture); the container duration only when a stream has none
404
452
  layer_dur = pip_meta["video"].get("duration") or pip_meta.get("duration")
@@ -415,7 +463,7 @@ def main() -> int:
415
463
  die(f"--place {a:g}-{b:g}@{at:g}: the layer is only {layer_dur:.3f} s long")
416
464
  if main_dur and at >= main_dur:
417
465
  die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
418
- chain = []
466
+ chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
419
467
  if args.scale_percent:
420
468
  chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
421
469
  elif args.scale:
@@ -436,6 +484,8 @@ def main() -> int:
436
484
  base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
437
485
  if base or pip:
438
486
  base, pip = base or label_chain(meta, meta), pip or label_chain(pip_meta, meta)
487
+ # overlay mixes samples: a full-range picture is taken to limited range (#410)
488
+ base, pip = base + range_chain(meta, meta), pip + range_chain(pip_meta, meta)
439
489
  if placements:
440
490
  # each placed piece: its range of the layer, re-timed to start at AT; eof_action=pass shows
441
491
  # the main picture untouched once a piece ends (the default would freeze its last frame)
@@ -464,6 +514,7 @@ def main() -> int:
464
514
  else:
465
515
  cmd += ["-shortest"]
466
516
  else:
517
+ args.text = _wrap_to_frame(args, vw, safe_margins, vh)
467
518
  x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
468
519
  # 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
469
520
  # (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
@@ -483,10 +534,13 @@ def main() -> int:
483
534
  drawing = "drawtext=" + ":".join(opts)
484
535
  # a font/border/box colour with chroma lands in the BT.709 the output is tagged with
485
536
  colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
486
- cmd += ["-vf", drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
537
+ cmd += ["-vf", (square + "," if square else "") + drawn_chain(drawing, meta, colours), "-map", "0:v:0"]
487
538
  if meta.get("audio"):
488
539
  cmd += ["-map", f"0:a:{args.audio_stream}"]
489
540
 
541
+ if square and "-filter_complex" in cmd:
542
+ i = cmd.index("-filter_complex") + 1
543
+ cmd[i] = f"[0:v]{square}[sq];" + cmd[i].replace("[0:v]", "[sq]")
490
544
  cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
491
545
  cmd += aac_args() if meta.get("audio") else ["-an"]
492
546
  dropped_streams = run_keeping_subtitles(cmd, output)
package/scripts/proxy.py CHANGED
@@ -26,6 +26,8 @@ import argparse
26
26
  import sys
27
27
 
28
28
  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.emit import note
30
+ from _common.probe import display_size, square_pixels, square_pixels_note
29
31
 
30
32
 
31
33
  def even(n: float) -> int:
@@ -56,15 +58,17 @@ def main() -> int:
56
58
  meta = probe(args.input)
57
59
  if not meta.get("video"):
58
60
  die("input has no video stream")
59
- sw, sh = meta["video"]["width"], meta["video"]["height"]
60
- if meta["video"].get("rotation") in (90, -90, 270, -270):
61
- sw = sh
61
+ # the displayed width: rotation applied, non-square pixels squared first (#411)
62
+ sw = display_size(meta["video"])[0]
63
+ square = square_pixels(meta["video"])
64
+ if square:
65
+ note(square_pixels_note(meta["video"]))
62
66
  has_audio = bool(meta.get("audio")) and not args.no_audio
63
67
 
64
68
  out_w = even(sw * args.scale) if args.scale is not None else even(args.width)
65
69
  output = args.output or default_output(args.input, "proxy")
66
70
 
67
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", f"scale={out_w}:-2"]
71
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
68
72
  cmd += video_args(meta, args.crf, "veryfast")
69
73
  cmd += cfr_args(meta, args.fps)
70
74
  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
@@ -28,6 +28,8 @@ import argparse
28
28
  import sys
29
29
 
30
30
  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
31
+ from _common.emit import note
32
+ from _common.probe import display_size, square_pixels, square_pixels_note
31
33
 
32
34
 
33
35
  def main() -> int:
@@ -67,9 +69,12 @@ def main() -> int:
67
69
  meta = probe(args.input)
68
70
  if not meta.get("video"):
69
71
  die("input has no video stream")
70
- sw, sh = meta["video"]["width"], meta["video"]["height"]
71
- if meta["video"].get("rotation") in (90, -90, 270, -270):
72
- sw, sh = sh, sw
72
+ # the rectangle is in the displayed frame: rotation applied, and a non-square-pixel source
73
+ # squared at its display aspect first (#411), the frame look.py and cropdetect.py show
74
+ sw, sh = display_size(meta["video"])
75
+ square = square_pixels(meta["video"])
76
+ if square:
77
+ note(square_pixels_note(meta["video"]))
73
78
  if args.x + args.width > sw or args.y + args.height > sh:
74
79
  die(f"redaction rectangle ({args.x},{args.y},{args.width}x{args.height}) exceeds the source frame ({sw}x{sh})")
75
80
  if args.mode == "delogo":
@@ -93,7 +98,7 @@ def main() -> int:
93
98
  output = args.output or default_output(args.input, "redact")
94
99
  if args.mode == "delogo":
95
100
  cmd = ffmpeg_base() + ["-i", args.input, "-vf",
96
- f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
101
+ (square + "," if square else "") + f"delogo=x={args.x}:y={args.y}:w={args.width}:h={args.height}",
97
102
  "-map", "0:v:0"]
98
103
  else:
99
104
  crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
@@ -105,7 +110,7 @@ def main() -> int:
105
110
  region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
106
111
  else:
107
112
  region = f"{crop},scale={max(1, args.width // args.block_size)}:{max(1, args.height // args.block_size)}:flags=neighbor,scale={args.width}:{args.height}:flags=neighbor"
108
- fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
113
+ fc = f"[0:v]{square + ',' if square else ''}split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
109
114
  cmd = ffmpeg_base() + ["-i", args.input, "-filter_complex", fc, "-map", "[out]"]
110
115
  if has_audio:
111
116
  cmd += ["-map", f"0:a:{args.audio_stream}?"]
@@ -12,10 +12,12 @@ Examples:
12
12
  python3 reverse.py input.mp4 --no-audio -o backwards.mp4
13
13
  """
14
14
  import argparse
15
+ import math
15
16
  import sys
16
17
 
17
18
  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
18
19
  from _common.emit import warn
20
+ from _common.decision import av_clip_length, av_trim, frame_durations, frame_rate, stream_starts
19
21
 
20
22
 
21
23
  def main() -> int:
@@ -39,9 +41,32 @@ def main() -> int:
39
41
  warn(f"reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
40
42
  f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)",
41
43
  "memory_heavy", duration=meta["duration"])
42
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
44
+ cmd = ffmpeg_base() + ["-i", args.input]
43
45
  if has_audio:
44
- cmd += ["-af", "areverse"]
46
+ # Both streams are reversed about one end (#409); each reversed about its own end put a sound
47
+ # 0.6 s longer than its picture 0.396 s off. As in a join, both start at the first frame (the
48
+ # sound shifted by the same constant, padded when it starts late) and end together, on the
49
+ # first frame time at or after the clip's one length (av_clip_length: the picture's, or the
50
+ # sound's when it runs more than a frame past it). The last frame is held and the sound
51
+ # padded with silence to that end; the end is a frame time because a copied cut's stated
52
+ # video length can run past its last frame (7.517 s for frames ending at 7.5). One graph for
53
+ # both streams, as pad.py's padding needs. areverse keeps its input's timestamps in order,
54
+ # so the reversed sound is retimed by its samples. The hold (tpad) runs before av_trim's
55
+ # setpts: from FFmpeg 7.0 setpts leaves its output's frame rate unknown and its frames'
56
+ # durations 0, so a tpad after it clones nothing (the picture stayed 0.6 s short on 7.1);
57
+ # the closing fps gives the frames their durations back (frame_durations).
58
+ rate = frame_rate(meta) or meta["video"].get("fps") or 30.0
59
+ length = av_clip_length(meta, rate)
60
+ vlen = meta["video"].get("duration") or length
61
+ end = math.ceil(length * rate - 1e-6) / rate
62
+ vtrim, atrim = av_trim(stream_starts(meta)[0], None)
63
+ cmd += ["-filter_complex",
64
+ f"[0:v:0]tpad=stop_mode=clone:stop_duration={max(0.0, end - vlen) + 2 / rate:.6f},{vtrim},"
65
+ f"trim=end={end - 0.5 / rate:.6f},reverse,{frame_durations(meta, rate)}[v];"
66
+ f"[0:a:0]{atrim},apad=whole_dur={end:.6f},atrim=end={end:.6f},areverse,asetpts=N/SR/TB[a]",
67
+ "-map", "[v]", "-map", "[a]"]
68
+ else:
69
+ cmd += ["-vf", "reverse"]
45
70
  cmd += video_args(meta, args.crf, args.preset)
46
71
  cmd += cfr_args(meta)
47
72
  if has_audio:
package/scripts/scenes.py CHANGED
@@ -29,6 +29,8 @@ from _common import (guard_sidecar, detect_scenes, STATE, add_common, apply_comm
29
29
  from _common import drawtext_fontfile # noqa: E402
30
30
  from _common import ffmpeg_filters, filter_missing_hint # noqa: E402
31
31
  from _common.emit import note, record_fallback # noqa: E402
32
+ from _common.color import bt709_label_filter # noqa: E402
33
+ from _common.probe import square_pixels # noqa: E402
32
34
 
33
35
  SHOT_FPS = 4.0 # frames/second sampled for --shots' flow estimate
34
36
  SHOT_W, SHOT_H = 48, 27 # decode size for --shots (16:9-ish; enough blocks, still a few KB/shot)
@@ -285,9 +287,13 @@ def main() -> int:
285
287
  cols = min(4, max(1, n))
286
288
  rows = max(1, math.ceil(n / cols))
287
289
  tile_w = 1280 // cols // 2 * 2
288
- # exactly one frame per scene: the frame index at the scene start
289
- fps = meta["video"].get("fps") or 30.0
290
- expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
290
+ # exactly one frame per scene: the first frame at or after the scene start. The starts are
291
+ # presentation times, so the frame is chosen by its time, not by start x fps as a frame
292
+ # number -- on a variable-frame-rate source that number is another frame (#411)
293
+ def first_at(t: float) -> str:
294
+ a = f"{max(0.0, t - 0.0006):.4f}"
295
+ return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
296
+ expr = "+".join(first_at(sc["start"]) for sc in scenes)
291
297
  stamp = ""
292
298
  if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
293
299
  # as look.py (#394): the sheet needs drawtext only for its stamps
@@ -300,9 +306,14 @@ def main() -> int:
300
306
  default_font = default_font_file("DejaVu Sans")
301
307
  font_prefix = f"{drawtext_fontfile(default_font)}:" if default_font else ""
302
308
  stamp = f",drawtext=text='%{{pts\\:hms}}':{font_prefix}fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6"
303
- vf = (f"select='{expr}',scale={tile_w}:-2{stamp},"
309
+ # an untagged source is labelled BT.709 (tags only), or the sheet's RGB read it as BT.601 (#410);
310
+ # a non-square-pixel source is squared at its display aspect (#411)
311
+ lab = bt709_label_filter(meta)
312
+ square = square_pixels(meta["video"])
313
+ vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
314
+ + f"scale={tile_w}:-2{stamp},"
304
315
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
305
- run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
316
+ run(ffmpeg_base() + ["-i", args.input, "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
306
317
  info(f"wrote {args.sheet}")
307
318
  result["sheet"] = args.sheet
308
319
 
@@ -21,7 +21,8 @@ import tempfile
21
21
  from pathlib import Path
22
22
 
23
23
  from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line, fmt_secs
24
- from _common.emit import warn
24
+ from _common.emit import note, warn
25
+ from _common.probe import display_size, square_pixels_note
25
26
 
26
27
 
27
28
  def even(n: float) -> int:
@@ -105,7 +106,10 @@ def main() -> int:
105
106
  frame_meta = probe(str(frames[0]))
106
107
  if not frame_meta.get("video"):
107
108
  die(f"{frames[0]} is not a readable image")
108
- sw, sh = frame_meta["video"]["width"], frame_meta["video"]["height"]
109
+ # the displayed frame: an EXIF rotation applied, non-square pixels squared (#411)
110
+ sw, sh = display_size(frame_meta["video"])
111
+ if square_pixels_note(frame_meta["video"], "the first frame"):
112
+ note(square_pixels_note(frame_meta["video"], "the first frame"))
109
113
 
110
114
  if args.width and args.height:
111
115
  out_w, out_h = even(args.width), even(args.height)