ffmpeg-skill 3.1.4 → 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.
@@ -30,8 +30,12 @@ import sys
30
30
  from typing import List, Optional
31
31
 
32
32
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
33
- 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
34
- from _common.emit import warn
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, 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
35
39
  from _common.fonts import font_choice, record_font_choice
36
40
  from _common import drawtext_fontfile # noqa: E402
37
41
 
@@ -73,6 +77,48 @@ def position_exprs(pos: str, margin: int, text_mode: bool, margins: Optional[dic
73
77
  return x, y
74
78
 
75
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
+
76
122
  def enable_expr(start: Optional[float], end: Optional[float]) -> str:
77
123
  if start is None and end is None:
78
124
  return ""
@@ -150,7 +196,8 @@ def per_line_drawtext(lines: List[str], x: str, y: str, font_opt: str, args: arg
150
196
  _PLACE_TIME = r"[^@\-]+(?:@[0-9.]+)?"
151
197
 
152
198
 
153
- 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]:
154
201
  """--place SRC_START-SRC_END@AT -> [(src_start, src_end, at)], refusing empty, negative or
155
202
  overlapping placements (two pieces on screen at once would stack in an order nobody asked for).
156
203
  SRC_START/SRC_END are positions in the layer, so an hh:mm:ss:ff timecode there counts the
@@ -160,9 +207,9 @@ def parse_placements(specs: List[str], main_fps: Optional[float], layer_fps: Opt
160
207
  m = re.fullmatch(rf"\s*({_PLACE_TIME})-({_PLACE_TIME})@({_PLACE_TIME})\s*", spec)
161
208
  if not m:
162
209
  die(f"--place {spec!r}: use SRC_START-SRC_END@AT, e.g. 0-2.5@12 (times are not negative)")
163
- a = time_arg(m.group(1), "--place", layer_fps)
164
- b = time_arg(m.group(2), "--place", layer_fps)
165
- 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)
166
213
  if b <= a:
167
214
  die(f"--place {spec!r}: the layer range ends before it starts")
168
215
  out.append((a, b, at))
@@ -303,12 +350,14 @@ def main() -> int:
303
350
  die(f"--audio-stream {args.audio_stream}: input has {len(audio_streams)} audio stream(s), 0..{len(audio_streams) - 1}")
304
351
  if args.audio_stream and not audio_streams:
305
352
  die("--audio-stream needs an input with audio streams")
306
- vw = meta["video"]["width"] or 0
307
- vh = meta["video"].get("height") or 0
308
353
  # ffmpeg applies display rotation before the filter graph, so the frame this overlay is
309
354
  # composited on is the displayed one. A 320x180 phone file tagged 90 degrees is 180 wide here.
310
- if meta["video"].get("rotation") in (90, -90, 270, -270):
311
- 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"]))
312
361
  # --platform: the edges this destination's own UI covers, in pixels of this frame. An
313
362
  # explicit --margin (or a brand safe_margin, applied above) is the more specific statement
314
363
  # and wins; without either, the historical 24 px default is unchanged.
@@ -372,11 +421,11 @@ def main() -> int:
372
421
  if enable:
373
422
  ov += f":enable='{enable}'"
374
423
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
375
- cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
424
+ cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", *still_input_args(args.image), "-i", args.image]
376
425
  # the RGBA still is converted to the BT.709 the output is tagged with (overlay's own
377
426
  # RGB-to-YUV step used BT.601), and an untagged main is labelled BT.709 (tags only) so
378
427
  # 7.1+'s negotiation does not convert one picture to the other's "unknown" matrix
379
- base = label_chain(meta, meta) if rgb_chain_to_bt709(meta) else ""
428
+ base = layer_base_chain(meta)
380
429
  fc = (f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS{rgb_chain_to_bt709(meta)}[ov];"
381
430
  + (f"[0:v]{base}null[base];[base]" if base else "[0:v]") + f"[ov]{ov}[out]")
382
431
  cmd += ["-filter_complex", fc, "-map", "[out]", "-map", f"0:a:{args.audio_stream}?"]
@@ -397,7 +446,7 @@ def main() -> int:
397
446
  if not pip_meta.get("video"):
398
447
  die(f"--video {args.video} has no video stream")
399
448
  layer_fps = pip_meta["video"].get("fps")
400
- 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 []
401
450
  # bounds from the video streams (a longer audio track would let a range run past the last
402
451
  # picture); the container duration only when a stream has none
403
452
  layer_dur = pip_meta["video"].get("duration") or pip_meta.get("duration")
@@ -414,7 +463,7 @@ def main() -> int:
414
463
  die(f"--place {a:g}-{b:g}@{at:g}: the layer is only {layer_dur:.3f} s long")
415
464
  if main_dur and at >= main_dur:
416
465
  die(f"--place {a:g}-{b:g}@{at:g}: starts after the main video ends ({main_dur:.3f} s)")
417
- chain = []
466
+ chain = [square_pixels(pip_meta["video"])] if square_pixels(pip_meta["video"]) else []
418
467
  if args.scale_percent:
419
468
  chain.append(f"scale={int(vw * args.scale_percent / 100)}:-2")
420
469
  elif args.scale:
@@ -435,6 +484,8 @@ def main() -> int:
435
484
  base, pip = matrix_chain(meta, meta), matrix_chain(pip_meta, meta)
436
485
  if base or pip:
437
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)
438
489
  if placements:
439
490
  # each placed piece: its range of the layer, re-timed to start at AT; eof_action=pass shows
440
491
  # the main picture untouched once a piece ends (the default would freeze its last frame)
@@ -463,6 +514,7 @@ def main() -> int:
463
514
  else:
464
515
  cmd += ["-shortest"]
465
516
  else:
517
+ args.text = _wrap_to_frame(args, vw, safe_margins, vh)
466
518
  x, y = position_exprs(args.position, args.margin, text_mode=True, margins=safe_margins)
467
519
  # 1.15: the text goes in a FILE with expansion off, so `'` and `%` survive verbatim
468
520
  # (they used to be dropped by escape_drawtext) and no character can reach the graph parser.
@@ -482,10 +534,13 @@ def main() -> int:
482
534
  drawing = "drawtext=" + ":".join(opts)
483
535
  # a font/border/box colour with chroma lands in the BT.709 the output is tagged with
484
536
  colours = [args.font_color] + ([args.border_color] if args.border else []) + ([args.box_color] if args.box else [])
485
- 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"]
486
538
  if meta.get("audio"):
487
539
  cmd += ["-map", f"0:a:{args.audio_stream}"]
488
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]")
489
544
  cmd += video_args(meta, args.crf, args.preset, convert_matrix=not args.video) + cfr_args(meta)
490
545
  cmd += aac_args() if meta.get("audio") else ["-an"]
491
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}?"]
package/scripts/render.py CHANGED
@@ -342,6 +342,11 @@ def render_pack(names: List[str], args) -> int:
342
342
  and platform check), so the pack reports per-platform results rather than one verdict."""
343
343
  stem = Path(args.input).with_suffix("").name
344
344
  outdir = Path(args.output).parent if args.output else Path(args.input).resolve().parent
345
+ # the table is a file this run writes itself (#408): an existing one is refused without
346
+ # --overwrite, before the first destination renders
347
+ if outdir.is_dir(): # a missing folder is the destinations' own refusal (and a dry run's plan)
348
+ side = [v for k, v in vars(args).items() if k != "output" and isinstance(v, str) and os.path.isfile(v)]
349
+ guard_sidecar(str(outdir / f"{stem}_pack.md"), args.input, *side)
345
350
  dests = {name: str((load_template(name).get("check") or {}).get("platform") or name) for name in names}
346
351
  rows: List[Dict[str, Any]] = []
347
352
  outputs: List[str] = []
@@ -399,6 +404,9 @@ def render_pack(names: List[str], args) -> int:
399
404
  lines += ["", f"{len(rows)} destinations from one edit ({os.path.basename(args.input)}).",
400
405
  "Rendered by ffmpeg-skill; `report.py --pack` turns this table into an HTML report."]
401
406
  if not STATE.dry_run:
407
+ if not outdir.is_dir():
408
+ die(f"pack: the output folder {outdir} does not exist, so the table was not written", kind="input",
409
+ pack=rows, outputs=outputs)
402
410
  Path(pack).write_text("\n".join(lines) + "\n", encoding="utf-8")
403
411
  info(("[dry-run] would write " if STATE.dry_run else "wrote ") + pack)
404
412
  if failed:
@@ -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", 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)
@@ -3,7 +3,8 @@
3
3
 
4
4
  Detects quiet stretches with ffmpeg's silencedetect, keeps a margin on each
5
5
  side so words are not clipped, drops gaps shorter than --min-silence, and
6
- writes a frame-accurate re-encode in one pass (select, and ametadata for the sound).
6
+ writes a frame-accurate re-encode: the kept ranges are re-cut from the source the way
7
+ cut.py --segments --accurate cuts them (both streams on one set of frame-grid bounds).
7
8
 
8
9
  Examples:
9
10
  python3 silence.py talk.mp4 # -35 dB, gaps >= 0.6 s, 0.2 s margin
@@ -13,6 +14,7 @@ Examples:
13
14
  """
14
15
  import argparse
15
16
  import json
17
+ import math
16
18
  import os
17
19
  import sys
18
20
  from typing import Any, Dict, List, Optional, Tuple
@@ -25,7 +27,8 @@ from _common import asr as _asr
25
27
  from _common.emit import warn
26
28
  from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
27
29
  from _common.emit import refuse
28
- from _common import detect_silences as detect, STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, run, X264_PRESETS, measured_level_dbfs, fmt_secs
30
+ from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
31
+ from _common.decision import frame_rate, stream_starts
29
32
 
30
33
 
31
34
 
@@ -97,16 +100,38 @@ def keep_ranges(silences: List[Tuple[float, float]], duration: float, margin: fl
97
100
  return keeps
98
101
 
99
102
 
103
+ def rendered_ranges(keeps: List[Tuple[float, float]], meta: Dict[str, Any], duration: float,
104
+ video_out: bool) -> List[Tuple[float, float]]:
105
+ """The source ranges the render holds, and is given: with a picture, each bound moves to the
106
+ nearest frame time, for the sound too, so cut.py's re-cut (which cuts at the first frame at or
107
+ after a bound) cuts exactly there. A measured bound sits a few microseconds off a frame
108
+ (silencedetect's 4.00002), which "at or after" would have turned into a whole frame dropped.
109
+ Sound alone is cut where asked. --timemap is built from these, so its times are the output's."""
110
+ rate = frame_rate(meta) if video_out else 0.0
111
+ if not rate:
112
+ return list(keeps)
113
+ v0 = stream_starts(meta)[0]
114
+
115
+ def snap(t: float) -> float:
116
+ return min(v0 + round((t - v0) * rate) / rate, duration)
117
+ return [(a, b) for a, b in ((snap(s), snap(e)) for s, e in keeps) if b > a]
118
+
119
+
120
+ def _edl_time(t: float, on_frames: bool) -> str:
121
+ """An EDL bound: a frame time truncated to the millisecond (below 1000 fps the frame at or
122
+ after it is still that frame), any other time rounded as before."""
123
+ return f"{math.floor(t * 1000 + 1e-6) / 1000:.3f}" if on_frames else f"{t:.3f}"
124
+
125
+
100
126
  def time_map(keeps: List[Tuple[float, float]], duration: float) -> Dict[str, Any]:
101
127
  """Each kept range with its time in the source and in the output, and each removed range with
102
- the output time it was cut at: re-timing subtitles, or finding a removed sentence again."""
128
+ the output time it was cut at: re-timing subtitles, or finding a removed sentence again.
129
+ `keeps` are the ranges the render holds (rendered_ranges): the output advances by each one's
130
+ exact length, rounded only for display."""
103
131
  kept: List[Dict[str, float]] = []
104
132
  removed: List[Dict[str, float]] = []
105
133
  out, prev = 0.0, 0.0
106
134
  for s, e in keeps:
107
- # the render cuts on bounds rounded to the millisecond (between(t,s,e) below): advance by
108
- # that rounded length, or every off-grid range adds its own error to every later time
109
- s, e = round(s, 3), round(e, 3)
110
135
  if s - prev > 0.0005:
111
136
  removed.append({"source_start": round(prev, 3), "source_end": round(s, 3), "output_time": round(out, 3)})
112
137
  kept.append({"source_start": round(s, 3), "source_end": round(e, 3),
@@ -502,7 +527,11 @@ def main() -> int:
502
527
  # time-range removal, so it reuses keep_ranges() and the same select chain.
503
528
  removals = merge_spans(list(silences) + list(filler_ranges))
504
529
  keeps = keep_ranges(removals, duration, args.margin, args.min_keep)
505
- kept = sum(e - s for s, e in keeps)
530
+ # what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
531
+ # and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
532
+ video_out = bool(meta.get("video")) and not (media_out and is_audio_output(media_out))
533
+ held = rendered_ranges(keeps, meta, duration, video_out)
534
+ kept = sum(e - s for s, e in held)
506
535
  removed = max(0.0, duration - kept)
507
536
  # `removed_seconds` keeps the meaning it has had since this tool existed: the seconds of
508
537
  # SILENCE this run removes. It must not quietly start counting filler time as well, because a
@@ -512,11 +541,11 @@ def main() -> int:
512
541
  silence_only = removed
513
542
  if args.filler:
514
543
  silence_keeps = keep_ranges(merge_spans(list(silences)), duration, args.margin, args.min_keep)
515
- silence_only = max(0.0, duration - sum(e - s for s, e in silence_keeps))
544
+ silence_only = max(0.0, duration - sum(e - s for s, e in rendered_ranges(silence_keeps, meta, duration, video_out)))
516
545
  kinds = silence_kinds(args.input, silences, duration, args.threshold)
517
546
  summary = {
518
547
  "silences": [[round(s, 3), None if e == float("inf") else round(e, 3)] for s, e in silences],
519
- "keep": [[round(s, 3), round(e, 3)] for s, e in keeps],
548
+ "keep": [[round(s, 3), round(e, 3)] for s, e in held],
520
549
  "input_duration": round(duration, 3),
521
550
  "kept_duration": round(kept, 3),
522
551
  "removed_seconds": round(silence_only, 3),
@@ -572,7 +601,7 @@ def main() -> int:
572
601
  summary["hint"] = f"no passage sits below {args.threshold:g} dBFS for {args.min_silence:g}s; try a higher --threshold (e.g. -25) or a shorter --min-silence"
573
602
  info("hint: " + summary["hint"])
574
603
 
575
- tm = time_map(keeps, duration)
604
+ tm = time_map(held, duration)
576
605
  points: Any = False # False: not measured (a dry run over a pending input); None: the decode failed
577
606
  if not (STATE.dry_run and not os.path.exists(args.input)):
578
607
  points = cut_levels(args.input, tm, duration, list(filler_ranges))
@@ -604,8 +633,11 @@ def main() -> int:
604
633
  if args.edl:
605
634
  if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
606
635
  with open(args.edl, "w", encoding="utf-8") as fh:
607
- for s, e in keeps:
608
- fh.write(f"{s:.3f}-{e:.3f}\n")
636
+ on_frames = bool(video_out and frame_rate(meta))
637
+ for s, e in held:
638
+ # a frame-time bound is written down to the millisecond, not rounded up past
639
+ # its frame: cut.py --segments cuts at the first frame at or after it (#416 review)
640
+ fh.write(f"{_edl_time(s, on_frames)}-{_edl_time(e, on_frames)}\n")
609
641
  info(f"wrote {args.edl}")
610
642
 
611
643
  if args.list or args.filler_list:
@@ -615,29 +647,28 @@ def main() -> int:
615
647
  else:
616
648
  print_json(summary)
617
649
  return 0
618
- if not keeps:
650
+ if not keeps or not held:
619
651
  die("nothing would be kept; raise --threshold (e.g. -45) or check the audio")
620
652
  if not silences or removed < 0.05:
621
653
  info("no removable silence found; output would equal the input")
622
654
 
623
655
  output = args.output or default_output(args.input, "tight")
624
- expr = "+".join(f"between(t,{s:.3f},{e:.3f})" for s, e in keeps)
625
- vf = f"select='{expr}',setpts=N/FRAME_RATE/TB"
626
- # Not `aselect='{expr}'`: FFmpeg 8.0 and 8.0.1 (Ubuntu 26.04's apt build) ship an aselect that
627
- # passes every frame through (its activate callback was lost when the filter was converted;
628
- # fixed in 8.0.2/8.1), which cut the picture and left the sound whole. Tagging the frames the
629
- # expression keeps (timeline `enable`, the same per-frame t) and selecting the tagged ones keeps
630
- # exactly the frames aselect keeps, on every release, in a command no longer than before.
631
- af = (f"ametadata=mode=add:key=ffskill_keep:value=1:enable='{expr}',"
632
- "ametadata=mode=select:key=ffskill_keep,asetpts=N/SR/TB")
633
- cmd = ffmpeg_base() + ["-i", args.input]
634
- audio_only = is_audio_output(output) or not meta.get("video")
635
- if audio_only:
636
- cmd += ["-vn"]
637
- else:
638
- cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
639
- cmd += ["-af", af] + audio_codec_for(output) + [output]
640
- run(cmd)
656
+ # the join of more than 32 ranges concats a list of chunks, so its last command never names the
657
+ # source and run()'s own check cannot see -o replace it (#416 review)
658
+ refuse_output_is_input(output, args.input)
659
+ # The kept ranges are re-cut from the source as cut.py --segments --accurate cuts them (#409):
660
+ # each range is its own seeked input, both streams cut on the same frame-grid bounds and shifted
661
+ # by one constant, the sound padded to the range's origin, at most 32 ranges per ffmpeg call.
662
+ # The one-pass select/ametadata graph this replaced rounded every range twice, the picture to
663
+ # whole frames and the sound to whole AAC frames, and the errors added up (150 ms over 21 ranges
664
+ # on a 61 s source); its asetpts=N/SR also moved a sound that starts late by its whole offset.
665
+ # No declick fade: the joins sit in the --margin the tool keeps, as they always have.
666
+ # imported here, not at the top: the contract and MCP load every tool's parser on its own
667
+ import cut as _cut
668
+ _cut.DECLICK_S = 0.0
669
+ import tempfile
670
+ with tempfile.TemporaryDirectory(prefix="ffskill_silence_") as tmp:
671
+ _cut.join_from_source(args.input, held, output, meta, args.crf, args.preset, tmp)
641
672
  r = probe(output, role="output")
642
673
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
643
674
  finish_time_map()
@@ -19,6 +19,7 @@ import argparse
19
19
  import sys
20
20
  from typing import List, Tuple
21
21
 
22
+ from _common.decision import av_trim, frame_rate, grid_at_or_after, stream_starts
22
23
  from _common import add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, fmt_secs, time_arg
23
24
 
24
25
  MAX_SPEED = 20.0
@@ -89,15 +90,26 @@ def main() -> int:
89
90
  # the last factor. The end bound is omitted only when the stated end is the file's end
90
91
  # (within 50 ms): a trim a few milliseconds short of the last packet drops that packet.
91
92
  last_runs_to_eof = abs(segments[-1][1] - dur) <= 0.05
93
+ # Every boundary is a frame time and both streams of a segment are cut on it and shifted by the
94
+ # same constant before the speed change (#409), as cut.py's re-cut does. Rebasing each stream
95
+ # of each segment to its own first sample moved a sound that starts 0.3 s late 0.3 s early, and
96
+ # the factor scaled that (+570 ms at 0.5x). The first segment starts at the first frame.
97
+ v0 = stream_starts(meta)[0]
98
+ rate = frame_rate(meta)
99
+ bounds = [v0] + [grid_at_or_after(end, v0, rate) for _, end, _ in segments[:-1]]
92
100
  for i, (start, end, factor) in enumerate(segments):
93
101
  vlabel, alabel = f"v{i}", f"a{i}"
94
- end_expr = f"{end:.3f}" if not (i == len(segments) - 1 and last_runs_to_eof) else None
95
- trim = f"trim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
96
- vparts.append(f"[0:v]{trim},setpts=(PTS-STARTPTS)/{factor:.6f}[{vlabel}]")
102
+ to_eof = i == len(segments) - 1 and last_runs_to_eof
103
+ seg_end = None if to_eof else (bounds[i + 1] if i + 1 < len(bounds) else grid_at_or_after(end, v0, rate))
104
+ if seg_end is not None and seg_end <= bounds[i] + 1e-6:
105
+ # both ends on the same frame: the trims would be empty and the factor reach no frame
106
+ die(f"--segment {start:g}-{end:g}:{factor:g} holds no frame: both ends fall on the frame at "
107
+ f"{bounds[i]:.3f}s; widen it or merge it into a neighbour")
108
+ vtrim, atrim = av_trim(bounds[i], seg_end, rate)
109
+ vparts.append(f"[0:v]{vtrim},setpts=PTS/{factor:.6f}[{vlabel}]")
97
110
  labels.append(f"[{vlabel}]")
98
111
  if has_audio:
99
- atrim = f"atrim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
100
- aparts.append(f"[0:a]{atrim},asetpts=PTS-STARTPTS,{atempo_chain(factor)}[{alabel}]")
112
+ aparts.append(f"[0:a]{atrim},{atempo_chain(factor)}[{alabel}]")
101
113
 
102
114
  if has_audio:
103
115
  concat_inputs = "".join(f"[v{i}][a{i}]" for i in range(len(segments)))
@@ -23,6 +23,8 @@ import math
23
23
  import sys
24
24
 
25
25
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, drawn_chain, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, fmt_secs
26
+ from _common.emit import note
27
+ from _common.probe import display_size, square_pixels, square_pixels_note
26
28
 
27
29
 
28
30
  def main() -> int:
@@ -68,15 +70,25 @@ def main() -> int:
68
70
  rotate = drawn_chain(f"rotate={radians:.8f}:fillcolor={args.fill_color}:{geometry}", meta, [args.fill_color],
69
71
  f"rotate={radians:.8f}:fillcolor=black:{geometry}")
70
72
  else:
71
- # Pre-scale the frame up by a safe, conservative factor (|cos|+|sin|, the exact growth
72
- # factor for a square, over-generous for a rectangle) so the rotated content fully
73
- # covers the original W:H window with no black corner, then rotate in place (canvas
74
- # stays at the scaled size) and crop back down to the original W:H, centred.
75
- s = abs(math.cos(radians)) + abs(math.sin(radians))
73
+ # Pre-scale the frame up so the rotated content fully covers the original W:H window with
74
+ # no black corner, then rotate in place (canvas stays at the scaled size) and crop back
75
+ # down to the original W:H, centred. The window turned back by the angle must fit inside
76
+ # the scaled frame: W*cos + H*sin <= s*W and W*sin + H*cos <= s*H, so
77
+ # s = |cos| + max(W/H, H/W) * |sin| for the displayed (squared, upright) frame. Through
78
+ # 3.1.5 it was |cos| + |sin|, a square's factor, which left two corners black on 16:9
79
+ # (#416 review).
80
+ dw, dh = display_size(meta["video"])
81
+ aspect = max(dw / dh, dh / dw) if dw and dh else 1.0
82
+ s = abs(math.cos(radians)) + aspect * abs(math.sin(radians))
76
83
  # crop dimensions must round down to even (4:2:0 chroma); trunc(.../2)*2 floors to the
77
84
  # nearest even value instead of leaving an odd width/height that the encoder would refuse.
78
85
  rotate = f"scale=iw*{s:.8f}:ih*{s:.8f},rotate={radians:.8f},crop=trunc(iw/{s:.8f}/2)*2:trunc(ih/{s:.8f}/2)*2"
79
86
 
87
+ # a rotation of non-square pixels is a shear on screen: the picture is squared at its
88
+ # display aspect first (#411)
89
+ if square_pixels(meta["video"]):
90
+ rotate = square_pixels(meta["video"]) + "," + rotate
91
+ note(square_pixels_note(meta["video"]))
80
92
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", rotate, "-map", "0:v:0"]
81
93
  if has_audio:
82
94
  cmd += ["-map", f"0:a:{args.audio_stream}?"]