ffmpeg-skill 3.8.12 → 3.8.14

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.
Files changed (44) hide show
  1. package/README.md +2 -1
  2. package/SKILL.md +1 -1
  3. package/docs/contract.md +11 -5
  4. package/mcp/server.py +0 -1
  5. package/package.json +5 -2
  6. package/references/flags.md +2 -2
  7. package/references/scripts.md +72 -27
  8. package/scripts/_common/asr.py +1 -1
  9. package/scripts/_common/color.py +7 -2
  10. package/scripts/_common/decision.py +40 -8
  11. package/scripts/_common/emit.py +38 -10
  12. package/scripts/_common/probe.py +34 -6
  13. package/scripts/_common/runner.py +76 -7
  14. package/scripts/_contract.py +24 -10
  15. package/scripts/_platforms.py +0 -5
  16. package/scripts/_smartcut.py +23 -5
  17. package/scripts/audio.py +86 -28
  18. package/scripts/background.py +1 -1
  19. package/scripts/caption.py +2 -3
  20. package/scripts/check.py +3 -3
  21. package/scripts/color.py +7 -1
  22. package/scripts/crop.py +3 -2
  23. package/scripts/cropdetect.py +1 -1
  24. package/scripts/cut.py +50 -17
  25. package/scripts/export.py +39 -6
  26. package/scripts/fit.py +4 -3
  27. package/scripts/freeze.py +1 -1
  28. package/scripts/graphics.py +2 -3
  29. package/scripts/insert.py +1 -1
  30. package/scripts/look.py +9 -4
  31. package/scripts/loop.py +3 -3
  32. package/scripts/loudness.py +20 -12
  33. package/scripts/multicam.py +56 -15
  34. package/scripts/overlay.py +1 -1
  35. package/scripts/probe.py +13 -2
  36. package/scripts/proxy.py +11 -2
  37. package/scripts/redact.py +5 -1
  38. package/scripts/scenes.py +82 -21
  39. package/scripts/silence.py +62 -16
  40. package/scripts/sphere.py +23 -3
  41. package/scripts/stabilize.py +5 -2
  42. package/scripts/sync.py +11 -6
  43. package/scripts/verify.py +82 -20
  44. package/scripts/waveform.py +12 -2
@@ -30,9 +30,9 @@ import json
30
30
  import math
31
31
  import os
32
32
  import sys
33
- from typing import Dict, List, Tuple
33
+ from typing import Dict, List, Optional, Tuple
34
34
 
35
- 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 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_PRESETS, fmt_secs, decode_pcm_mono, rms_envelope, STATE
36
36
  from _common.emit import note, record_fallback, record_skip, warn
37
37
  from _common.decision import av_trim, conform_output_t, conform_to_length, rebase_project_paths
38
38
  from _common.probe import display_size, square_pixels, square_pixels_note
@@ -60,8 +60,19 @@ def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
60
60
  die(f"camera {c} does not exist (inputs are 0..{n - 1})")
61
61
  if e <= s:
62
62
  die(f"switch entry '{raw}': end must be after start")
63
+ if round(e, 3) <= round(s, 3):
64
+ # the cut list (--edl, `cuts`) is written to the millisecond: this range was its
65
+ # "0.000-0.000", a line cut.py --segments refuses (the scenes.py --edl sibling)
66
+ die(f"switch entry '{raw}': shorter than a millisecond, the precision the cut list is written in",
67
+ kind="input")
63
68
  out.append((s, e, c))
64
69
  out.sort()
70
+ # A132: ranges on one timeline cannot overlap. The fill below kept both, so the picture of the
71
+ # overlap played twice while the sound played once, and every later cut ran behind it
72
+ for (s0, e0, _c0), (s1, e1, _c1) in zip(out, out[1:]):
73
+ if s1 < e0:
74
+ die(f"--switch ranges overlap: {fmt_secs(s0)}-{fmt_secs(e0)} and {fmt_secs(s1)}-{fmt_secs(e1)} "
75
+ "(on the reference timeline each moment has one camera)", kind="input")
65
76
  return out
66
77
 
67
78
 
@@ -158,7 +169,7 @@ def main() -> int:
158
169
  media_out = args.output or default_output(args.inputs[0], "multicam", "mp4")
159
170
  guard_sidecar(args.edl, *args.inputs, siblings=(args.write_project, media_out))
160
171
  guard_sidecar(args.write_project, *args.inputs, siblings=(media_out,))
161
- if args.edl or args.write_project: # they are written before the cut: refuse its output first
172
+ if args.edl or args.write_project: # refused up front with them, before any decode
162
173
  guard_sidecar(media_out, *args.inputs)
163
174
  if args.analyze_seconds > 900:
164
175
  die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
@@ -272,17 +283,26 @@ def main() -> int:
272
283
  cursor = max(cursor, e)
273
284
  if cursor < ref_dur:
274
285
  filled.append((cursor, ref_dur, 0))
286
+ # a gap or a clipped range under a millisecond is nothing at the precision the cut list is
287
+ # written in (--switch "0.0004-3:1" left a leading "0.000-0.000", the line cut.py --segments
288
+ # refuses, and a 0.4 ms trim in the render): its neighbour takes it, so the timeline stays whole
289
+ whole: List[Tuple[float, float, int]] = []
290
+ carry: Optional[float] = None
291
+ for s, e, c in filled:
292
+ if carry is not None:
293
+ s, carry = carry, None
294
+ if round(e, 3) <= round(s, 3):
295
+ if whole:
296
+ whole[-1] = (whole[-1][0], e, whole[-1][2])
297
+ else:
298
+ carry = s
299
+ continue
300
+ whole.append((s, e, c))
301
+ filled = whole
275
302
  for s, e, c in filled:
276
303
  if not metas[c].get("video"):
277
304
  die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
278
305
 
279
- if args.edl:
280
- if not STATE.dry_run:
281
- with open(args.edl, "w", encoding="utf-8") as fh:
282
- for s, e, _c in filled:
283
- fh.write(f"{s:.3f}-{e:.3f}\n")
284
- info(f"wrote {args.edl}")
285
-
286
306
  v0 = metas[0]["video"]
287
307
  # camera 0's displayed frame: rotation applied, non-square pixels squared (#411)
288
308
  w, h = args.width or display_size(v0)[0], args.height or display_size(v0)[1]
@@ -322,9 +342,18 @@ def main() -> int:
322
342
  c, src_s, src_e = 0, s, e
323
343
  rendered.append((s, e, c))
324
344
  sq = (square_pixels(metas[c]["video"]) + ",") if square_pixels(metas[c]["video"]) else ""
325
- parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{convert[c]}{sq}{geo}[v{i}]")
345
+ # A130: a drift-corrected camera's range is (e - s) * ratio of its own time; played at that
346
+ # rate it lasts e - s on the reference, as its sound does (asetrate) -- at its own rate the
347
+ # picture ran (ratio - 1) * (e - s) long and every later cut fell behind the sound
348
+ retime = f"(PTS-STARTPTS)/{ratios[c]:.9f}" if abs(ratios[c] - 1.0) > 1e-7 else "PTS-STARTPTS"
349
+ parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts={retime},{convert[c]}{sq}{geo}[v{i}]")
326
350
  labels.append(f"[v{i}]")
327
- project_clips.append({"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)})
351
+ clip = {"src": args.inputs[c], "in": round(src_s, 3), "out": round(src_e, 3)}
352
+ if abs(ratios[c] - 1.0) > 1e-7:
353
+ # render.py plays a clip at clips[].speed (its length / speed), as the retime above
354
+ # does: without it a drift-corrected camera's clip ran (ratio - 1) * (e - s) long
355
+ clip["speed"] = round(ratios[c], 9)
356
+ project_clips.append(clip)
328
357
  parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
329
358
  a = args.audio
330
359
  a_start = -offsets[a] if offsets[a] < 0 else 0.0
@@ -358,17 +387,29 @@ def main() -> int:
358
387
  # render.py project instead of the direct render this tool already does -- additive, not
359
388
  # a replacement: the combined output below is still written as usual.
360
389
  project = {"output": default_output(args.inputs[0], "multicam_project", "mp4"),
361
- "clips": project_clips}
390
+ "clips": project_clips,
391
+ # a switch is a cut: render.py's default is a 0.5 s crossfade at every join,
392
+ # which shortens the timeline by 0.5 s a switch and moves every later one
393
+ "transition": {"type": "none"}}
362
394
  # the inputs are named as given, relative to the working directory, and render.py reads a
363
395
  # project's relative paths against the project file's directory: written elsewhere
364
396
  # (--write-project out/cut.json), they named files that are not there
365
397
  project = rebase_project_paths(project, os.getcwd())
398
+
399
+ run(cmd)
400
+ # the EDL and the project record the cut: written once it exists, not before an encode that then
401
+ # failed (#462 A41, the silence.py --edl sibling)
402
+ if args.edl:
403
+ if not STATE.dry_run:
404
+ with open(args.edl, "w", encoding="utf-8") as fh:
405
+ for s, e, _c in filled:
406
+ fh.write(f"{s:.3f}-{e:.3f}\n")
407
+ info(f"wrote {args.edl}")
408
+ if args.write_project:
366
409
  if not STATE.dry_run:
367
410
  with open(args.write_project, "w", encoding="utf-8") as fh:
368
411
  fh.write(json.dumps(project, indent=2) + "\n")
369
412
  info(f"wrote {args.write_project}")
370
-
371
- run(cmd)
372
413
  r = probe(output, role="output")
373
414
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {len(filled)} cuts, audio from input {a})")
374
415
  extra = {"switch_mode": "energy", "min_shot": args.min_shot} if energy_mode else {}
@@ -35,7 +35,7 @@ from typing import Any, Dict, List, Optional
35
35
 
36
36
  from _platforms import PLATFORMS, PLATFORM_CHOICES, safe_margins_px, resolve as resolve_platform
37
37
  from _common import still_input_args # noqa: E402
38
- from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg, fmt_secs
38
+ from _common import STATE, script_font_for_text, drawtext_clean, drawtext_line_metrics, drawtext_text_opts, drawtext_draws_newline_glyph, is_multiline, needs_shaping, LANGUAGE_NAMES, has_emoji, detect_script, emoji_clusters, load_brand, drawn_chain, label_chain, layer_base_chain, matrix_chain, overlay_format, range_chain, rgb_chain_to_bt709, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, X264_PRESETS, time_arg, fmt_secs
39
39
  from _common.emit import note, warn
40
40
  from _common.color import is_rgb
41
41
  from _common.decision import length_output_t, window_expr, windowed_overlay_x
package/scripts/probe.py CHANGED
@@ -15,10 +15,11 @@ Examples:
15
15
  points a stream copy can start at, and where cut.py --smart's re-encode around a cut ends.
16
16
  """
17
17
  import argparse
18
+ import json
18
19
  import math
19
20
  import sys
20
21
 
21
- from _common import add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
22
+ from _common import STATE, add_common, analyze_levels, apply_common, die, keyframe_times, print_json, probe, time_arg
22
23
 
23
24
 
24
25
  def keyframe_candidates(result: dict, times: list, window: float) -> list:
@@ -87,7 +88,17 @@ def main() -> int:
87
88
  cur = r
88
89
  for key in args.field.split("."):
89
90
  cur = cur.get(key) if isinstance(cur, dict) else None
90
- print(cur if cur is not None else "")
91
+ # a string as itself; anything else as JSON (true, null, {"codec": ...}), not Python's
92
+ # repr (True, None, {'codec': ...}), which no caller can parse (through 3.8.8, A123).
93
+ # Under --json (or --json-brief, which implies it) a missing field is null and a string
94
+ # is quoted: every line is JSON. Without either a missing field is an empty line, as it
95
+ # always was, so `[ -z "$(probe.py f --field x)" ]` keeps working
96
+ if isinstance(cur, str) and not STATE.json:
97
+ print(cur)
98
+ elif cur is None and not STATE.json:
99
+ print("")
100
+ else:
101
+ print(json.dumps(cur, ensure_ascii=False))
91
102
  return 0
92
103
 
93
104
  if args.compact:
package/scripts/proxy.py CHANGED
@@ -5,7 +5,8 @@ editing-decision workflows that only need to look at (or feed a model) a
5
5
  much smaller stand-in for the original.
6
6
 
7
7
  Resizes to --width (default 640px, height follows the source aspect) or by
8
- --scale factor, re-encodes at a proxy-grade --quality (default 30 - well above any
8
+ --scale factor, never wider than the source (a proxy is not upscaled) and
9
+ never under 2px, re-encodes at a proxy-grade --quality (default 30 - well above any
9
10
  delivery preset's 18-24 in export.py, since a proxy trades visual quality for
10
11
  size and speed), and always uses the fastest x264/x265 preset. Keeps the
11
12
  source's own dynamic range (an HDR source proxies to HEVC10, same as every
@@ -65,7 +66,15 @@ def main() -> int:
65
66
  note(square_pixels_note(meta["video"]))
66
67
  has_audio = bool(meta.get("audio")) and not args.no_audio
67
68
 
68
- out_w = even(sw * args.scale) if args.scale is not None else even(args.width)
69
+ # A proxy is never wider than its source, and never under 2 px: through 3.8.8 the default
70
+ # --width 640 upscaled a smaller source, and a --scale that rounded to 0 became scale=0, which
71
+ # ffmpeg reads as "the input's width" (a full-size proxy, audit A162)
72
+ asked = even(sw * args.scale) if args.scale is not None else even(args.width)
73
+ ceiling = max(2, sw - sw % 2) if sw else asked
74
+ out_w = max(2, min(asked, ceiling))
75
+ if out_w != asked:
76
+ note(f"proxy width {out_w}px, not {asked}px: "
77
+ + (f"the source is {sw}px wide and a proxy is not upscaled" if asked > ceiling else "2px is the smallest width"))
69
78
  output = args.output or default_output(args.input, "proxy")
70
79
 
71
80
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", (square + "," if square else "") + f"scale={out_w}:-2"]
package/scripts/redact.py CHANGED
@@ -113,7 +113,11 @@ def main() -> int:
113
113
  # boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
114
114
  # so they get their own (halved) radius; a 30 px region with the default 20 used to fail
115
115
  radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
116
- chroma = max(1, min(radius // 2, min(args.width, args.height) // 4 - 1))
116
+ # at least 1 wherever the chroma plane can hold it: radius // 2 alone was 0 at
117
+ # --blur-strength 1, which left colour-only detail readable (review of #521). (FFmpeg
118
+ # 7.1 refuses a chroma radius of 1 on a 2 px chroma plane, a 4 px wide region; 6.1
119
+ # took it.) 0 is only a plane too small to blur: the luma still is
120
+ chroma = max(0, min(max(1, radius // 2), (min(args.width, args.height) // 2 - 1) // 2))
117
121
  region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
118
122
  else:
119
123
  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"
package/scripts/scenes.py CHANGED
@@ -110,6 +110,48 @@ def speech_music_ratio(samples: "List[float]", rate: int, step_s: float = SPEECH
110
110
  return [{"time": round(i * step_s, 2), "speech_music_ratio": round(z / med, 3)} for i, z in enumerate(zcrs)]
111
111
 
112
112
 
113
+ # A --sheet picks one frame a scene with select. One expression summing a term a scene was refused
114
+ # by FFmpeg 7.1 and later at 3,000 scenes ("Error while parsing expression", ENOMEM) and taken by
115
+ # 6.1. No expression here grows with the scene count: the scenes are dealt round-robin to branches
116
+ # of at most SHEET_SELECT_TERMS terms, each summed as a balanced tree (at most
117
+ # ceil(log2(SHEET_SELECT_TERMS)) = 6 nested parentheses), and the branches' frames are merged back in
118
+ # time order by interleave. Round-robin keeps the branches level, so interleave holds about one
119
+ # thumbnail a branch.
120
+ SHEET_SELECT_TERMS = 64
121
+
122
+
123
+ def _first_at(t: float) -> str:
124
+ """The select term for the first frame at or after `t`. The starts are presentation times, so
125
+ the frame is chosen by its time, not by start x fps as a frame number -- on a
126
+ variable-frame-rate source that number is another frame (#411)."""
127
+ a = f"{max(0.0, t - 0.0006):.4f}"
128
+ return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
129
+
130
+
131
+ def _balanced_sum(terms: List[str]) -> str:
132
+ if len(terms) == 1:
133
+ return terms[0]
134
+ mid = len(terms) // 2
135
+ return f"({_balanced_sum(terms[:mid])}+{_balanced_sum(terms[mid:])})"
136
+
137
+
138
+ def sheet_graph(starts: List[float], src: str, thumb: str, tile: str) -> str:
139
+ """The --sheet filter graph: exactly one frame a scene (the first at or after its start), each
140
+ made a thumbnail by `thumb`, laid out by `tile`, out as [sheet]."""
141
+ # no scene (a dry run whose input an earlier step has yet to write, a clip of under one scene):
142
+ # the sheet is its one tile, the first frame, as the 1x1 layout the caller sized for it
143
+ starts = starts or [0.0]
144
+ k = max(1, math.ceil(len(starts) / SHEET_SELECT_TERMS))
145
+ if k == 1:
146
+ return f"{src}select='{_balanced_sum([_first_at(t) for t in starts])}',{thumb},{tile}[sheet]"
147
+ parts = [f"{src}split={k}" + "".join(f"[s{j}]" for j in range(k))]
148
+ for j in range(k):
149
+ expr = _balanced_sum([_first_at(t) for t in starts[j::k]])
150
+ parts.append(f"[s{j}]select='{expr}',{thumb}[t{j}]")
151
+ parts.append("".join(f"[t{j}]" for j in range(k)) + f"interleave=nb_inputs={k},{tile}[sheet]")
152
+ return ";".join(parts)
153
+
154
+
113
155
  def main() -> int:
114
156
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
115
157
  ap.add_argument("input")
@@ -305,6 +347,7 @@ def main() -> int:
305
347
  rank_key = lambda sc: (-sc["audio_rms"], sc["start"])
306
348
  ranked = sorted(scenes, key=rank_key)[: args.highlights]
307
349
  picks: List[Tuple[float, float]] = []
350
+ empty: List[Tuple[float, float]] = []
308
351
  budget = args.target if args.target else None
309
352
  per = (budget / max(1, len(ranked))) if budget else args.max_scene
310
353
  for sc in ranked:
@@ -314,36 +357,44 @@ def main() -> int:
314
357
  if env and length < sc["duration"]:
315
358
  best, best_s = -1.0, sc["start"]
316
359
  win = max(1, int(length / step_s))
317
- lo, hi = int(sc["start"] / step_s), max(int(sc["start"] / step_s) + 1, int(sc["end"] / step_s) - win)
318
- for i in range(lo, hi + 1):
360
+ # A139: a window starts at the scene's start or on the step grid after it, and
361
+ # ends inside the scene. The grid point at or before the start (floor) was a
362
+ # candidate, so a pick opened up to one step (0.5 s) early, on the previous shot
363
+ latest = sc["end"] - length
364
+ starts = [sc["start"]] + [i * step_s for i in range(math.ceil(sc["start"] / step_s - 1e-9),
365
+ math.floor(latest / step_s + 1e-9) + 1)
366
+ if sc["start"] < i * step_s <= latest]
367
+ for t in starts:
368
+ i = int(t / step_s + 1e-9)
319
369
  val = sum(env[i:i + win])
320
370
  if val > best:
321
- best, best_s = val, i * step_s
322
- picks.append((round(best_s, 2), round(min(sc["end"], best_s + length), 2)))
371
+ best, best_s = val, t
372
+ # to the hundredth the result and the EDL are written in, inward: a start of 3.333
373
+ # written as 3.33 opened on the frame before the cut
374
+ pick_s = math.ceil(best_s * 100 - 1e-6) / 100
375
+ pick_e = math.floor(min(sc["end"], pick_s + length) * 100 + 1e-6) / 100
376
+ if pick_e <= pick_s:
377
+ # no hundredth fits inside it: written as 0.00-0.00 it was a range cut.py refuses
378
+ empty.append((sc["start"], sc["end"]))
379
+ continue
380
+ picks.append((pick_s, pick_e))
381
+ if empty and not picks:
382
+ die(f"--highlights found no range of at least 0.01 s, the precision the ranges are written in "
383
+ f"(each pick is at most {min(per, args.max_scene):g} s, or its scene is shorter)",
384
+ kind="input", hint="raise --target (or drop it), or lower --highlights")
385
+ for s, e in empty:
386
+ note(f"--highlights: the scene {s:.3f}-{e:.3f}s holds no range of at least 0.01 s and has no pick")
323
387
  picks.sort()
324
388
  result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
325
389
  result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
326
390
  result["highlights_rank_by"] = args.rank_by
327
391
  info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
328
- if args.edl:
329
- if not STATE.dry_run: # the contract says --edl is not written under --dry-run
330
- with open(args.edl, "w", encoding="utf-8") as fh:
331
- for s, e in picks:
332
- fh.write(f"{s:.2f}-{e:.2f}\n")
333
- info(f"wrote {args.edl}")
334
392
 
335
393
  if args.sheet:
336
394
  n = len(scenes)
337
395
  cols = min(4, max(1, n))
338
396
  rows = max(1, math.ceil(n / cols))
339
397
  tile_w = 1280 // cols // 2 * 2
340
- # exactly one frame per scene: the first frame at or after the scene start. The starts are
341
- # presentation times, so the frame is chosen by its time, not by start x fps as a frame
342
- # number -- on a variable-frame-rate source that number is another frame (#411)
343
- def first_at(t: float) -> str:
344
- a = f"{max(0.0, t - 0.0006):.4f}"
345
- return f"gte(t\\,{a})*(isnan(prev_t)+lt(prev_t\\,{a}))"
346
- expr = "+".join(first_at(sc["start"]) for sc in scenes)
347
398
  stamp = ""
348
399
  if not args.no_timecode and ffmpeg_filters() and "drawtext" not in ffmpeg_filters():
349
400
  # as look.py (#394): the sheet needs drawtext only for its stamps
@@ -360,13 +411,23 @@ def main() -> int:
360
411
  # a non-square-pixel source is squared at its display aspect (#411)
361
412
  lab = bt709_label_filter(meta)
362
413
  square = square_pixels(meta["video"])
363
- vf = (f"select='{expr}'," + (lab + "," if lab else "") + (square + "," if square else "")
364
- + f"scale={tile_w}:-2{stamp},"
365
- f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
366
- run(ffmpeg_base() + ["-i", args.input, "-map", f"0:{PROBED_VIDEO}", "-an", "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
414
+ thumb = (lab + "," if lab else "") + (square + "," if square else "") + f"scale={tile_w}:-2{stamp}"
415
+ graph = sheet_graph([sc["start"] for sc in scenes], f"[0:{PROBED_VIDEO}]", thumb,
416
+ f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
417
+ run(ffmpeg_base() + ["-i", args.input, "-filter_complex", graph, "-map", "[sheet]", "-an",
418
+ "-frames:v", "1", "-fps_mode", "vfr", "-update", "1", args.sheet])
367
419
  info(f"wrote {args.sheet}")
368
420
  result["sheet"] = args.sheet
369
421
 
422
+ # after the sheet, as silence.py writes its EDL after the cut (#462 A41): a --sheet refused as an
423
+ # existing file, or failed, left the EDL behind, and the corrected retry was then refused for it
424
+ if args.highlights and args.edl:
425
+ if not STATE.dry_run: # the contract says --edl is not written under --dry-run
426
+ with open(args.edl, "w", encoding="utf-8") as fh:
427
+ for s, e in picks:
428
+ fh.write(f"{s:.2f}-{e:.2f}\n")
429
+ info(f"wrote {args.edl}")
430
+
370
431
  if STATE.json: # --json-brief too: emit() is what adds the diagnostics (scenes_sheet_no_drawtext)
371
432
  emit(None, **result)
372
433
  else:
@@ -33,7 +33,7 @@ from _common.runner import parse_json, replace_checked
33
33
  from _common import guard_sidecar, refuse_existing_outputs, refuse_output_is_input, _check_output_path
34
34
  from _common.emit import refuse
35
35
  from _common import detect_silences as detect, STATE, add_common, apply_common, default_output, die, emit, info, is_audio_output, print_json, probe, X264_PRESETS, measured_level_dbfs, fmt_secs
36
- from _common.decision import frame_rate, stream_starts
36
+ from _common.decision import frame_rate, grid_at_or_after, stream_starts
37
37
  from _common.probe import PROBED_AUDIO
38
38
  from _common.emit import input_length
39
39
 
@@ -163,21 +163,47 @@ def speech_aware_silences(path: str, threshold: float, min_silence: float) -> "T
163
163
  return boundaries, breaths
164
164
 
165
165
 
166
+ # how close to the start or the end of the sound a silence's edge may sit and still be where the sound
167
+ # starts or ends: silencedetect closes a silence that runs to the end at its last decoded block (7.509
168
+ # for a sound probed as 7.500), and an AAC stream's stated start sits a priming frame off its samples
169
+ SOUND_EDGE = 0.05
170
+
171
+
172
+ def sound_span(meta: Dict[str, Any], duration: float) -> Tuple[float, float]:
173
+ """Where the sound starts and ends on the clock silencedetect reports on (the file's), for
174
+ inside_margin(): its start, and its own end (the file's length when that is not stated). The
175
+ end is stream_end()'s: a Matroska DURATION tag is already where the sound ends, and adding the
176
+ start to it put the end of a sound from 1 s to 6 s at 7 s."""
177
+ from _common.emit import file_origin, stream_end
178
+ a0 = stream_starts(meta)[1]
179
+ end = stream_end(meta, "audio")
180
+ return a0, (end - file_origin(meta)) if end is not None else duration
181
+
182
+
166
183
  def inside_margin(silences: List[Tuple[float, float]], duration: float, margin: float,
167
- lead: Optional[float] = None) -> List[Tuple[float, float]]:
184
+ lead: Optional[float] = None,
185
+ sound: Optional[Tuple[float, float]] = None) -> List[Tuple[float, float]]:
168
186
  """Each silence with a margin kept on both sides: the stretch actually removed. `margin` is
169
187
  kept after the speech that ends where the silence starts (--margin-tail), `lead` before the
170
188
  speech that starts where it ends (--margin-lead; the same as `margin` when not given). A
171
189
  silence too short to hold both removes nothing (it is dropped, never turned inside out).
172
190
 
191
+ A silence with no speech on one side keeps no margin on that side (#462 A40): one that starts
192
+ where the sound does (`sound`, from sound_span()) is removed from the file's start, and one
193
+ that runs to where the sound ends (or that silencedetect never closed, `inf`) to the file's end.
194
+ A margin there kept dead air as a piece of its own, with a jump cut after it; FFmpeg 6.1 closes
195
+ a silence at the end of the file, so the `inf` end alone never caught the last one.
196
+
173
197
  The margin is applied to the silences alone, before they are merged with filler spans (#414):
174
198
  a filler word's span is the word itself plus --filler-pad, and taking a 0.2 s margin off each
175
199
  side of it turned every word narrower than 2 x --margin inside out -- kept, and still counted
176
200
  as removed."""
177
201
  out: List[Tuple[float, float]] = []
178
202
  for s, e in silences:
179
- a = s + margin
180
- b = duration if e == float("inf") else min(duration, e - (margin if lead is None else lead))
203
+ opens = sound is not None and s <= sound[0] + SOUND_EDGE
204
+ closes = e == float("inf") or (sound is not None and e >= sound[1] - SOUND_EDGE)
205
+ a = 0.0 if opens else s + margin
206
+ b = duration if closes else min(duration, e - (margin if lead is None else lead))
181
207
  if b > a:
182
208
  out.append((a, b))
183
209
  return out
@@ -215,7 +241,10 @@ def rendered_ranges(keeps: List[Tuple[float, float]], meta: Dict[str, Any], dura
215
241
  v0 = stream_starts(meta)[0]
216
242
 
217
243
  def snap(t: float) -> float:
218
- return min(v0 + round((t - v0) * rate) / rate, duration)
244
+ # a video that starts after the sound puts frame times before 0 on its grid's extension: a
245
+ # bound there moves to the first one at or after 0, which is where the re-cut starts (#462 A43)
246
+ t = v0 + round((t - v0) * rate) / rate
247
+ return min(t if t >= 0 else grid_at_or_after(0.0, v0, rate), duration)
219
248
  return [(a, b) for a, b in ((snap(s), snap(e)) for s, e in keeps) if b > a]
220
249
 
221
250
 
@@ -307,8 +336,10 @@ def cut_levels(path: str, tm: Dict[str, Any], duration: float,
307
336
  out = []
308
337
  with tempfile.TemporaryDirectory(prefix="silence-levels-") as tmp:
309
338
  raw = os.path.join(tmp, "pcm.s16le")
339
+ # padded from the file's start, as decode_pcm_mono() pads it: the windows are read at
340
+ # t x rate, so a sound that starts late was read that far after each cut (#462 A42)
310
341
  proc = run_analysis([require_tool("ffmpeg"), "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path,
311
- "-map", f"0:{PROBED_AUDIO}", "-vn",
342
+ "-map", f"0:{PROBED_AUDIO}", "-vn", "-af", "aresample=async=1:first_pts=0",
312
343
  "-ac", str(channels), "-ar", str(CUT_LEVEL_RATE), "-f", "s16le", "-y", raw],
313
344
  check=False, text=False)
314
345
  if proc.returncode != 0 or not os.path.isfile(raw) or not os.path.getsize(raw):
@@ -624,8 +655,9 @@ def main() -> int:
624
655
  fil.add_argument("--filler-list", action="store_true",
625
656
  help="report what --filler would remove and write nothing")
626
657
  fil.add_argument("--max-cuts", type=int, default=400,
627
- help="refuse above this many removal ranges: the filter graph grows with them "
628
- "(default 400)")
658
+ help="refuse to render above this many removal ranges (default 400): that many cuts usually "
659
+ "means --threshold, --min-silence or the filler list is catching speech. --list and "
660
+ "--filler-list report them without the limit")
629
661
  ap.set_defaults(crf=18) # --quality's default (the --crf alias was removed in 2.0)
630
662
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
631
663
  add_common(ap)
@@ -686,6 +718,7 @@ def main() -> int:
686
718
  if not meta.get("audio"):
687
719
  die("input has no audio stream to analyse")
688
720
  duration = input_length(meta)
721
+ sound = sound_span(meta, duration)
689
722
  breaths: List[Tuple[float, float]] = []
690
723
  if args.speech_aware:
691
724
  silences, breaths = speech_aware_silences(args.input, args.threshold, args.min_silence)
@@ -717,10 +750,10 @@ def main() -> int:
717
750
  def margined(spans: List[Tuple[float, float]]) -> List[Tuple[float, float]]:
718
751
  """The stretch of each silence that is removed: its own tail for one after a sentence end."""
719
752
  if not by_sentence:
720
- return inside_margin(spans, duration, tail, lead)
753
+ return inside_margin(spans, duration, tail, lead, sound)
721
754
  out: List[Tuple[float, float]] = []
722
755
  for span, pos in zip(spans, positions):
723
- out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead)
756
+ out += inside_margin([span], duration, sentence_tail if pos == "sentence_end" else tail, lead, sound)
724
757
  return out
725
758
  # One sorted, merged removal list through the graph the tool already has: filler removal IS
726
759
  # time-range removal, so it reuses keep_ranges() and the same select chain.
@@ -730,7 +763,9 @@ def main() -> int:
730
763
  keeps = keep_ranges(removals, duration, 0.0, args.min_keep)
731
764
  # what the render holds (on a picture, each bound on its nearest frame): the report, the EDL
732
765
  # and the time map all describe these, so a re-cut or a sum of `keep` is the rendered edit
733
- video_out = bool(meta.get("video")) and not (media_out and is_audio_output(media_out))
766
+ # from the -o given, under --list and --filler-list too (which write no media): a list for an
767
+ # audio -o reported frame-snapped ranges that the audio run does not cut (#462 A48)
768
+ video_out = bool(meta.get("video")) and not is_audio_output(args.output or "")
734
769
  held = rendered_ranges(keeps, meta, duration, video_out)
735
770
  kept = sum(e - s for s, e in held)
736
771
  removed = max(0.0, duration - kept)
@@ -787,10 +822,6 @@ def main() -> int:
787
822
  # an English-only transcript made on an assumption (asr.assumed_english_note): the
788
823
  # bridge already logged the warning; the result document carries it at the top level
789
824
  summary["notes"] = list(args._asr_notes)
790
- if len(keeps) > args.max_cuts:
791
- die(f"{len(keeps)} keep ranges is above --max-cuts {args.max_cuts}: the filter graph grows "
792
- "with every range and a graph this size is slow and fragile. Raise --max-cuts if you "
793
- "mean it, or use --min-silence/--filler-pad to merge the short ones.", kind="input")
794
825
  filler_only = args.filler and not args.speech_aware and not by_sentence
795
826
  if filler_only:
796
827
  info(f"--filler only (no --speech-aware): skipping generic silence detection, "
@@ -812,6 +843,14 @@ def main() -> int:
812
843
  info("hint: " + summary["hint"])
813
844
 
814
845
  tm = time_map(held, duration)
846
+ cuts = len(tm["removed"])
847
+ if cuts > args.max_cuts and not (args.list or args.filler_list):
848
+ # the removal ranges, as --max-cuts says, not the kept ones; a list renders nothing and is
849
+ # how the caller sees what the limit is about (#462 A49)
850
+ die(f"{cuts} removal ranges is above --max-cuts {args.max_cuts}: that many cuts usually means "
851
+ "--threshold, --min-silence or the filler list is catching speech. Check them with --list, then "
852
+ "raise --max-cuts if you mean it, or use --min-silence/--filler-pad to merge the short ones.",
853
+ kind="input")
815
854
  points: Any = False # False: not measured (a dry run over a pending input); None: the decode failed
816
855
  if not (STATE.dry_run and not os.path.exists(args.input)):
817
856
  points = cut_levels(args.input, tm, duration, list(filler_ranges))
@@ -841,7 +880,12 @@ def main() -> int:
841
880
  info(f"wrote {args.timemap}")
842
881
  summary["timemap"] = args.timemap
843
882
 
844
- if args.edl:
883
+ def finish_edl() -> None:
884
+ """Written with the time map, once the cut exists (or under --list): an EDL left by a
885
+ refused run (an existing -o, -o the input, nothing kept) or a failed encode described
886
+ media never made, and then blocked the corrected retry as an existing file (#462 A41)."""
887
+ if not args.edl:
888
+ return
845
889
  if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
846
890
  with open(args.edl, "w", encoding="utf-8") as fh:
847
891
  on_frames = bool(video_out and frame_rate(meta))
@@ -852,6 +896,7 @@ def main() -> int:
852
896
  info(f"wrote {args.edl}")
853
897
 
854
898
  if args.list or args.filler_list:
899
+ finish_edl()
855
900
  finish_time_map()
856
901
  if args.json:
857
902
  emit(None, **summary)
@@ -883,6 +928,7 @@ def main() -> int:
883
928
  _cut.join_from_source(args.input, held, output, meta, args.crf, args.preset, tmp)
884
929
  r = probe(output, role="output")
885
930
  info(f"wrote {output} ({fmt_secs(r['duration'])}, expected ~{kept:.3f}s)")
931
+ finish_edl()
886
932
  finish_time_map()
887
933
  emit(output, **summary)
888
934
  return 0
package/scripts/sphere.py CHANGED
@@ -6,7 +6,8 @@ turning an equirectangular (or other spherical-projection) source into an
6
6
  ordinary flat video pointed at a chosen direction -- the same "look this way"
7
7
  operation a VR headset or a 360 video player's viewport does, baked into a
8
8
  real file. --yaw/--pitch/--roll aim the camera; --h-fov/--v-fov set how wide
9
- the view is.
9
+ the view is (without --v-fov, the vertical view is the one that keeps the
10
+ output's pixels square).
10
11
 
11
12
  This tool does not decide WHERE to point the camera -- there is no subject
12
13
  detection or tracking here, only the typed rotation/FOV you give it (see this
@@ -29,9 +30,11 @@ Examples:
29
30
  python3 sphere.py gopro_max.mp4 --input-projection fisheye --yaw -45 --pitch 10 -o angle.mp4
30
31
  """
31
32
  import argparse
33
+ import math
32
34
  import sys
33
35
 
34
36
  from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, fmt_secs
37
+ from _common.emit import note
35
38
 
36
39
  # v360's own AVOption names for the input projections real 360 cameras/exports actually
37
40
  # produce (ffmpeg -h filter=v360 documents 24 total; this is the subset a caller is likely
@@ -59,7 +62,9 @@ def main() -> int:
59
62
  aim.add_argument("--roll", type=float, default=0.0, help="tilt/roll rotation in degrees, -180..180 (default 0)")
60
63
  fov = ap.add_argument_group("field of view")
61
64
  fov.add_argument("--h-fov", type=float, default=90.0, help="output horizontal field of view in degrees, 1..170 (default 90)")
62
- fov.add_argument("--v-fov", type=float, default=60.0, help="output vertical field of view in degrees, 1..170 (default 60)")
65
+ fov.add_argument("--v-fov", type=float, default=None,
66
+ help="output vertical field of view in degrees, 1..170 (default: the one that keeps pixels square at "
67
+ "--h-fov and --width/--height, 58.7 at the defaults)")
63
68
  out = ap.add_argument_group("output frame")
64
69
  out.add_argument("--width", type=int, default=1920, help="output width in px, must be even (default 1920)")
65
70
  out.add_argument("--height", type=int, default=1080, help="output height in px, must be even (default 1080)")
@@ -81,12 +86,27 @@ def main() -> int:
81
86
  die(f"--roll must be -180..180, got {args.roll}")
82
87
  if not 1 <= args.h_fov <= 170:
83
88
  die(f"--h-fov must be 1..170, got {args.h_fov}")
84
- if not 1 <= args.v_fov <= 170:
89
+ if args.v_fov is not None and not 1 <= args.v_fov <= 170:
85
90
  die(f"--v-fov must be 1..170, got {args.v_fov}")
86
91
  if args.width <= 0 or args.height <= 0:
87
92
  die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
88
93
  if args.width % 2 or args.height % 2:
89
94
  die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
95
+ # A rectilinear view has square pixels when tan(v/2) / tan(h/2) = H / W. Through 3.8.8 the
96
+ # default was a fixed 60 degrees, which at 90 x 1920x1080 squashed the view 2.6 % vertically
97
+ # (audit A163); the vertical FOV now follows from the other three unless it is given.
98
+ square_v = math.degrees(2 * math.atan(math.tan(math.radians(args.h_fov) / 2) * args.height / args.width))
99
+ if args.v_fov is None:
100
+ if not 1 <= square_v <= 170:
101
+ die(f"--h-fov {args.h_fov:g} at {args.width}x{args.height} needs a vertical field of view of {square_v:.1f} "
102
+ "degrees for square pixels, outside 1..170", hint="give --v-fov, or a lower --h-fov")
103
+ args.v_fov = round(square_v, 4)
104
+ else:
105
+ stretch = math.tan(math.radians(args.v_fov) / 2) / math.tan(math.radians(square_v) / 2)
106
+ if abs(stretch - 1) > 0.005:
107
+ note(f"--h-fov {args.h_fov:g} / --v-fov {args.v_fov:g} at {args.width}x{args.height} is not square-pixel: the view is "
108
+ f"{'squashed' if stretch > 1 else 'stretched'} {abs(stretch - 1) * 100:.1f} % vertically "
109
+ f"(--v-fov {square_v:.2f} keeps it square; omit --v-fov to get it)")
90
110
 
91
111
  meta = probe(args.input)
92
112
  if not meta.get("video"):
@@ -29,7 +29,7 @@ from pathlib import Path
29
29
 
30
30
  from _common.probe import PROBED_VIDEO
31
31
  from _common.runner import ffmpeg_failure_extra, default_selection_maps, require_filter
32
- from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
32
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
33
33
 
34
34
 
35
35
  def main() -> int:
@@ -96,7 +96,10 @@ def main() -> int:
96
96
  die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
97
97
 
98
98
  crop_mode = {"keep": 0, "black": 1}[args.crop]
99
- transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
99
+ # optzoom=0: the zoom is --zoom and nothing else. vidstab's default (optzoom=1) adds its own
100
+ # "optimal" zoom on top, so --zoom 0 did not keep the original framing and --crop black
101
+ # rarely had an edge to fill (through 3.8.8, audit A156)
102
+ transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=0"
100
103
  if args.tripod:
101
104
  # Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
102
105
  # camera path makes no sense once every frame is locked to one fixed reference.