ffmpeg-skill 1.4.12 → 1.4.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.
package/SKILL.md CHANGED
@@ -278,6 +278,7 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
278
278
  -40 LUFS or below is room tone, wind or nothing; raising it 25 dB raises the
279
279
  noise, not the content. Leave the level, say so, and offer music or narration.
280
280
  - Captions burned before a crop/resize: text lands off-frame. Frame changes first, then text.
281
+ - Captions burned at an intermediate size and then upscaled by `export.py` come out soft (a 1280x720 source fit to 9:16 is 406x720 until export scales it to 1080x1920). Fit to the delivery size first (`fit.py --width 1080 --height 1920`), then caption, then export.
281
282
  - Anything chained by hand through three re-encodes: use `render.py` so the plan is one file and the user can change one number.
282
283
  - `--fit crop` to reach 9:16 from 16:9 throws away 70 % of the width: a wide shot loses people at the edges. Check the sheet; pad (bars), `--crop-x`/`--crop-y` toward the subject, or a reframe is often the honest answer — a silent centre crop is a guess, not a decision.
283
284
  - Conforming 60 fps to 30 halves the motion samples: fine for a talking head, visibly choppy for sports, gaming, drone pans. Keep 60 when the platform allows it.
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.4.12`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.4.14`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.4.12", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.4.14", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.12",
3
+ "version": "1.4.14",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -258,9 +258,9 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
258
258
  g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
259
259
  if "--timeout" not in ap._option_string_actions: # verify.py defines its own per-step --timeout; apply_common reads either
260
260
  g.add_argument("--timeout", type=float, default=None, metavar="SECONDS",
261
- help=f"kill any single ffmpeg run that exceeds this many seconds and report kind=timeout (default {DEFAULT_TIMEOUT:.0f}, or FFMPEG_SKILL_TIMEOUT; 0 = no limit)")
261
+ help=f"kill an ffmpeg run past this many seconds, kind=timeout (default {DEFAULT_TIMEOUT:.0f}; 0 = no limit)")
262
262
  g.add_argument("--overwrite", action="store_true",
263
- help="allow replacing an output file that already exists (without it a warning is printed today; from 2.0 an existing output is refused, and FFMPEG_SKILL_NO_OVERWRITE=1 opts into that now)")
263
+ help="allow replacing an existing output (warned today, refused from 2.0)")
264
264
 
265
265
 
266
266
  def apply_common(args: "argparse.Namespace") -> None:
@@ -461,6 +461,112 @@ def _check_output_path(cmd: Sequence[str]) -> None:
461
461
  parent = os.path.dirname(os.path.abspath(output))
462
462
  if not os.path.isdir(parent):
463
463
  die(f"output directory {parent!r} does not exist; create it first (this tool never creates directories)")
464
+ if not os.access(parent, os.W_OK):
465
+ die(f"output directory {parent!r} is not writable")
466
+
467
+
468
+ EVEN_SCALE = "scale=trunc(iw/2)*2:trunc(ih/2)*2"
469
+
470
+
471
+ def _pid_dead(pid: int) -> bool:
472
+ """True only when the process is known not to exist. POSIX: signal 0. Windows: OpenProcess
473
+ fails with ERROR_INVALID_PARAMETER (87) for a pid that is not in use; any other outcome
474
+ (a handle, or access denied) means it is live. Unknown is treated as live."""
475
+ if os.name != "nt":
476
+ try:
477
+ os.kill(pid, 0)
478
+ except ProcessLookupError:
479
+ return True
480
+ except OSError:
481
+ pass
482
+ return False
483
+ try:
484
+ import ctypes
485
+ k32 = ctypes.windll.kernel32 # type: ignore[attr-defined]
486
+ handle = k32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
487
+ if handle:
488
+ k32.CloseHandle(handle)
489
+ return False
490
+ return k32.GetLastError() == 87
491
+ except Exception:
492
+ return False
493
+
494
+
495
+ class _OutputLock:
496
+ """Two runs writing the same output at once used to both report `completed` while one of
497
+ them described the other's file (sweep F1). A lock file next to the output, created with
498
+ O_EXCL and holding the writer's pid, makes the second run refuse as `kind: input`. A lock
499
+ whose pid is dead (POSIX) or older than an hour is stale and taken over."""
500
+ def __init__(self, output: str) -> None:
501
+ self.path: Optional[str] = None
502
+ self.fd: Optional[int] = None
503
+ if output == "-" or output.startswith("pipe:") or output.startswith("-"):
504
+ return
505
+ d, base = os.path.split(os.path.abspath(output))
506
+ self.path = os.path.join(d, f".{base}.ffskill-lock")
507
+
508
+ def __enter__(self) -> "_OutputLock":
509
+ if not self.path:
510
+ return self
511
+ for attempt in (0, 1):
512
+ try:
513
+ self.fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
514
+ os.write(self.fd, str(os.getpid()).encode())
515
+ return self
516
+ except FileExistsError:
517
+ if attempt == 0 and self._stale():
518
+ try:
519
+ os.remove(self.path)
520
+ except OSError:
521
+ pass
522
+ continue
523
+ die(f"another run is writing {os.path.basename(self.path)[1:-len('.ffskill-lock')]!r} right now "
524
+ f"(lock {self.path}); wait for it or choose a different --output/-o path")
525
+ except OSError:
526
+ return self # unlockable location (read-only dir surfaces elsewhere): proceed without a lock
527
+ return self
528
+
529
+ def _stale(self) -> bool:
530
+ try:
531
+ pid = int(open(self.path).read().strip() or "0")
532
+ if pid > 0 and _pid_dead(pid):
533
+ return True
534
+ import time
535
+ return time.time() - os.path.getmtime(self.path) > 3600
536
+ except (OSError, ValueError):
537
+ return True
538
+
539
+ def __exit__(self, *exc: Any) -> None:
540
+ if self.fd is not None:
541
+ try:
542
+ os.close(self.fd)
543
+ except OSError:
544
+ pass
545
+ if self.path:
546
+ try:
547
+ os.remove(self.path)
548
+ except OSError:
549
+ pass
550
+
551
+
552
+ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
553
+ """An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
554
+ encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
555
+ with an even-dimension scale prepended to its -vf chain (or a new -vf when the command had
556
+ none); None when the failure is something else or the graph is a -filter_complex the
557
+ caller has to fix itself."""
558
+ if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
559
+ return None
560
+ if "-filter_complex" in cmd:
561
+ return None
562
+ new = list(cmd)
563
+ if "-vf" in new:
564
+ i = new.index("-vf") + 1
565
+ new[i] = EVEN_SCALE + "," + new[i]
566
+ return new
567
+ if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
568
+ return None
569
+ return new[:-1] + ["-vf", EVEN_SCALE, new[-1]]
464
570
 
465
571
 
466
572
  def _check_existing_output(cmd: Sequence[str]) -> None:
@@ -555,24 +661,41 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
555
661
  info(("[dry-run] $ " if STATE.dry_run and is_ffmpeg else "$ ") + _cmdline(cmd))
556
662
  if STATE.dry_run and is_ffmpeg:
557
663
  return subprocess.CompletedProcess(list(cmd), 0, "", "")
558
- exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
559
- if STATE.progress and is_ffmpeg and exec_cmd[-1] != "-":
560
- proc = _run_with_progress(exec_cmd, check)
561
- else:
562
- proc = _run_captured(exec_cmd, check)
563
- if final and tmp:
564
- if proc.returncode == 0:
565
- try:
566
- os.replace(tmp, final)
567
- except OSError as e:
664
+ with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
665
+ exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
666
+ proc = _execute(exec_cmd)
667
+ if proc.returncode != 0 and is_ffmpeg:
668
+ retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
669
+ if retry is not None:
670
+ info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
671
+ STATE.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
672
+ proc = _execute(retry)
673
+ elif "not divisible by 2" in (proc.stderr or ""):
674
+ die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
675
+ "cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
676
+ kind="input")
677
+ if proc.returncode != 0 and check:
678
+ _fail(exec_cmd, proc.returncode, proc.stderr or "")
679
+ if final and tmp:
680
+ if proc.returncode == 0:
681
+ try:
682
+ os.replace(tmp, final)
683
+ except OSError as e:
684
+ _cleanup_partial_output(exec_cmd)
685
+ die(f"could not replace {final} with the new output: {e}", kind="output")
686
+ _remember_output(cmd)
687
+ else:
568
688
  _cleanup_partial_output(exec_cmd)
569
- die(f"could not replace {final} with the new output: {e}", kind="output")
570
- _remember_output(cmd)
571
- else:
572
- _cleanup_partial_output(exec_cmd)
573
689
  return proc
574
690
 
575
691
 
692
+ def _execute(exec_cmd: List[str]) -> subprocess.CompletedProcess:
693
+ """One attempt, never exiting on failure (run() decides after its retries)."""
694
+ if STATE.progress and _is_ffmpeg(exec_cmd) and exec_cmd[-1] != "-":
695
+ return _run_with_progress(exec_cmd, False)
696
+ return _run_captured(exec_cmd, False)
697
+
698
+
576
699
  def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
577
700
  """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
578
701
  silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
@@ -840,7 +963,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
840
963
 
841
964
 
842
965
  def shell_quote(s: str) -> str:
843
- if not s or any(ch in s for ch in " \t\\\"';|&<>()[]{}$*?"):
966
+ if not s or any(ch in s for ch in " \t\n\r\\\"';|&<>()[]{}$*?"):
844
967
  return "'" + s.replace("'", "'\\''") + "'"
845
968
  return s
846
969
 
@@ -1466,7 +1589,7 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
1466
1589
  # signalstats reports in the source bit depth; normalise everything to an 8-bit scale
1467
1590
  scale = 1.0
1468
1591
  if ymax > 255 or yavg > 255:
1469
- scale = 1 / 4.0 if ymax <= 1023 else 1 / 16.0
1592
+ scale = 1 / 4.0 if ymax <= 1023 else (1 / 16.0 if ymax <= 4095 else 1 / 256.0) # 10 / 12 / 16-bit
1470
1593
  ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
1471
1594
  # 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
1472
1595
  lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
package/scripts/batch.py CHANGED
@@ -156,8 +156,23 @@ def main() -> int:
156
156
  if not outdir.is_absolute():
157
157
  outdir = folder / outdir
158
158
  work = Path(args.work) if args.work else outdir / ".work"
159
+ # the children refuse an output whose directory does not exist, under --dry-run too, so the
160
+ # directories are created for the plan as well -- and removed again afterwards when a dry
161
+ # run created them and left them empty (a plan leaves nothing behind, sweep F15)
162
+ created = [d for d in (outdir, work) if not d.exists()]
159
163
  outdir.mkdir(parents=True, exist_ok=True)
160
164
  work.mkdir(parents=True, exist_ok=True)
165
+ if STATE.dry_run and created:
166
+ import atexit
167
+
168
+ def _remove_empty_dirs() -> None:
169
+ for d in sorted(created, key=lambda p: len(str(p)), reverse=True):
170
+ try:
171
+ if not any(d.iterdir()):
172
+ d.rmdir()
173
+ except OSError:
174
+ pass
175
+ atexit.register(_remove_empty_dirs)
161
176
  cache_path = outdir / ".ffskill_cache.json"
162
177
  cache: Dict[str, Any] = {}
163
178
  if cache_path.exists() and not args.force:
@@ -450,7 +450,9 @@ def main() -> int:
450
450
  args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
451
451
  args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
452
452
  args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
453
- args.animate = args.animate or (bcap.get("animate", "none") if args.brand else "none")
453
+ # a brand's caption.animate is a burn-in default; over --mode mux (soft subtitles) it used
454
+ # to be applied anyway and then refused as "animation is burn only" -- ignore it there
455
+ args.animate = args.animate or (bcap.get("animate", "none") if args.brand and args.mode != "mux" else "none")
454
456
  args.highlight_color = color_hex(args.highlight_color or bc.get("primary", "FFD200"))
455
457
  if args.brand and bcap.get("bold") and not args.bold:
456
458
  args.bold = True
package/scripts/color.py CHANGED
@@ -285,6 +285,9 @@ def main() -> int:
285
285
  output = args.output or default_output(args.input, "sdr")
286
286
  tag = "sdr"
287
287
  elif args.correct:
288
+ if v.get("hdr") and not args.force:
289
+ die(f"{args.input} is HDR ({v.get('hdr_format')}); --correct works on SDR pixels and would tag PQ/HLG data as BT.709 "
290
+ f"without a tone map (sweep F10). Run --to-sdr first, or --force to grade the raw values anyway")
288
291
  vf = correction_chain(args)
289
292
  output = args.output or default_output(args.input, "correct")
290
293
  tag = "correct"
@@ -292,6 +295,9 @@ def main() -> int:
292
295
  # "looks better" judgement -- the same primitive probe.py --analyze uses for Log detection.
293
296
  measurements = {"input": analyze_levels(args.input)}
294
297
  else:
298
+ if v.get("hdr") and not args.force:
299
+ die(f"{args.input} is HDR ({v.get('hdr_format')}); a LUT made for SDR applied to PQ/HLG pixels gives a wrong picture "
300
+ f"tagged BT.709 (sweep F10). Run --to-sdr first (or chain it), or --force if the LUT expects HDR input")
295
301
  if not os.path.exists(args.lut):
296
302
  die(f"LUT not found: {args.lut}")
297
303
  if not (0.0 <= args.lut_strength <= 1.0):
package/scripts/cut.py CHANGED
@@ -32,6 +32,8 @@ from typing import List, Tuple
32
32
 
33
33
  from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line, refuse_output_is_input, fmt_secs
34
34
 
35
+ # outputs whose re-encode dropped a subtitle/data stream (reported as dropped_non_av_streams)
36
+ DROPPED_STREAMS: List[str] = []
35
37
  # keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
36
38
  # (reported so the caller can choose a lossless cut at one of them next time)
37
39
  NEAREST_KEYFRAMES: list = []
@@ -115,6 +117,12 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
115
117
  cmd = ffmpeg_base() + ["-ss", f"{start:.6f}", "-i", src, "-t", f"{dur:.6f}"]
116
118
  if is_audio_output(dst) or not meta.get("video"):
117
119
  cmd += ["-af", f"atrim=end={dur:.6f},asetpts=PTS-STARTPTS"]
120
+ # ffmpeg's default stream selection also picks one subtitle stream; a re-encode cannot
121
+ # trim it (the cues kept their timestamps and the container grew to 2 s for a 1 s cut,
122
+ # sweep F2), so the re-encode carries video/audio only and the result says so
123
+ cmd += ["-sn", "-dn"]
124
+ if meta.get("subtitle_streams") or meta.get("data_streams"):
125
+ DROPPED_STREAMS.append(dst)
118
126
  cmd += encode_args(meta, dst, crf, preset) + ["-avoid_negative_ts", "make_zero", dst]
119
127
  elif audio_only:
120
128
  # output-side seek: an input seek on a video file lands on the previous video keyframe and on
@@ -130,7 +138,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
130
138
  die(f"ffmpeg failed:\n{proc.stderr.strip()}", kind="ffmpeg")
131
139
  if not reencode and tolerance >= 0 and not STATE.dry_run:
132
140
  got = probe(dst).get("duration") or 0.0
133
- if abs(got - dur) > tolerance:
141
+ if abs(got - dur) >= tolerance: # a snap of exactly the tolerance is not "within" it (sweep F20)
134
142
  near = keyframes_near(src, start)
135
143
  alt = ""
136
144
  if near:
@@ -230,6 +238,7 @@ def main() -> int:
230
238
  info(f"wrote {output} ({fmt_secs(got)}, expected ~{expected:.3f}s, "
231
239
  + ("re-encoded" if reencoded else "lossless stream copy") + f", {precision} precision)")
232
240
  emit(output, expected_duration=round(expected, 6), duration_error_ms=error_ms, precision=precision, reencoded=reencoded,
241
+ dropped_non_av_streams=bool(DROPPED_STREAMS),
233
242
  requested_start=round(segments[0][0], 6) if len(segments) == 1 else None,
234
243
  requested_end=round(segments[0][1], 6) if len(segments) == 1 else None,
235
244
  requested_segments=[[round(s, 6), round(e, 6)] for s, e in segments] if len(segments) > 1 else None,
package/scripts/export.py CHANGED
@@ -68,8 +68,10 @@ def main() -> int:
68
68
  meta = probe(args.input)
69
69
  if not meta.get("video"):
70
70
  die("input has no video stream")
71
+ notes: List[str] = []
71
72
  if meta["video"].get("hdr") and args.preset not in ("prores", "copy"):
72
- info("warning: source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
73
+ notes.append("source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
74
+ info("warning: " + notes[-1])
73
75
  has_audio = bool(meta.get("audio"))
74
76
  output = args.output or default_output(args.input, args.preset, p["ext"])
75
77
  out_ext = Path(output).suffix.lstrip(".").lower()
@@ -92,7 +94,7 @@ def main() -> int:
92
94
  cmd += ["-filter_complex", fc, "-loop", "0", output]
93
95
  run(cmd)
94
96
  info(f"wrote {output}")
95
- emit(output)
97
+ emit(output, **({"notes": notes} if notes else {}))
96
98
  return 0
97
99
 
98
100
  if vf:
@@ -121,7 +123,7 @@ def main() -> int:
121
123
  result = probe(output, role="output")
122
124
  v = result["video"]
123
125
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
124
- emit(output)
126
+ emit(output, **({"notes": notes} if notes else {}))
125
127
  return 0
126
128
 
127
129
 
@@ -98,6 +98,8 @@ def main() -> int:
98
98
 
99
99
  extra_inputs: List[str] = []
100
100
  fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
101
+ if 0 < min(W, H) < 64: # 0x0 is a dry-run probe of an intermediate that does not exist yet
102
+ die(f"the frame is {W}x{H}; the templates are sized from it and need at least 64 px on the short side")
101
103
  if args.template == "lower-third":
102
104
  if not args.name:
103
105
  die("lower-third needs --name")
package/scripts/insert.py CHANGED
@@ -65,6 +65,9 @@ def main() -> int:
65
65
  meta = probe(args.input)
66
66
  if not meta.get("video"):
67
67
  die("input has no image/video stream")
68
+ if (meta.get("duration") or 0) > 0.5 or meta.get("audio"):
69
+ die(f"{args.input} is a video, not a still image; insert.py animates a still (Ken Burns). "
70
+ f"For a clip use broll.py (cutaway) or cut.py/join.py")
68
71
  sw, sh = meta["video"]["width"], meta["video"]["height"]
69
72
  ratio = sw / sh
70
73
 
package/scripts/look.py CHANGED
@@ -15,7 +15,7 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import List
17
17
 
18
- from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
18
+ from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -102,19 +102,28 @@ def main() -> int:
102
102
  n = cols * rows
103
103
  if not dur:
104
104
  die("cannot build a contact sheet without a known duration")
105
+ frames_avail = int((meta["video"].get("fps") or 0) * dur) or 1
106
+ if n > frames_avail:
107
+ # a 2x2 sheet from a one-frame clip: tile waits for frames that never come and writes nothing
108
+ cols, rows = min(cols, frames_avail), 1
109
+ n = cols * rows
110
+ info(f"only {frames_avail} frame(s) available; sheet reduced to {cols}x{rows}")
105
111
  step = dur / n
106
112
  tile_w = max(2, (args.width // cols) // 2 * 2)
107
113
  out = args.output or os.path.join(outdir, f"{stem}_sheet.png")
108
114
  # sample at the middle of each slice so the first/last tiles are not black lead-in/out frames
109
115
  vf = (f"select='isnan(prev_selected_t)+gte(t-prev_selected_t\\,{step * 0.98:.6f})',scale={tile_w}:-2{tc},"
110
116
  f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
111
- cmd = ffmpeg_base() + ["-ss", f"{step / 2:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
117
+ # with only a handful of frames a mid-slice seek skips past them all: start at 0 instead
118
+ seek = 0.0 if frames_avail < 4 else step / 2
119
+ cmd = ffmpeg_base() + ["-ss", f"{seek:.6f}", "-i", args.input, "-vf", vf, "-frames:v", "1", out]
112
120
  run(cmd)
113
121
  outputs.append(out)
114
122
  info(f"contact sheet: {n} frames every {step:.2f}s")
115
123
 
116
124
  for o in outputs:
117
- info(f"wrote {o}")
125
+ if STATE.dry_run or os.path.exists(o):
126
+ info(f"wrote {o}")
118
127
  emit(outputs[0] if len(outputs) == 1 else None, outputs=outputs)
119
128
  if len(outputs) > 1 and not args.json:
120
129
  for o in outputs:
@@ -64,12 +64,19 @@ def main() -> int:
64
64
  if stats.get("silent"):
65
65
  info("audio is silent (integrated loudness -inf); nothing to normalise")
66
66
  if args.measure_only:
67
- print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
67
+ if STATE.json:
68
+ emit(None, measured={"silent": True, "input_i": "-inf"})
69
+ else:
70
+ print(json.dumps({"silent": True, "input_i": "-inf"}, indent=2))
68
71
  return 0
69
72
  die("input audio is silent; loudness normalisation is meaningless (use audio.py --replace to add a track)")
70
73
  info(f"measured: {float(stats['input_i']):.1f} LUFS, TP {float(stats['input_tp']):.1f} dBTP, LRA {float(stats['input_lra']):.1f} LU")
71
74
  if args.measure_only:
72
- print(json.dumps({k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}, indent=2))
75
+ measured = {k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset")}
76
+ if STATE.json:
77
+ emit(None, measured=measured) # the contract's document shape (status, commands), not a bare dict
78
+ else:
79
+ print(json.dumps(measured, indent=2))
73
80
  return 0
74
81
 
75
82
  output = args.output or default_output(args.input, "loudnorm")
@@ -73,6 +73,8 @@ def main() -> int:
73
73
  add_common(ap)
74
74
  args = ap.parse_args()
75
75
  apply_common(args)
76
+ if args.analyze_seconds > 900:
77
+ die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
76
78
  if args.fps is not None and args.fps <= 0:
77
79
  die(f"--fps must be positive, got {args.fps:g}")
78
80
 
package/scripts/pad.py CHANGED
@@ -16,7 +16,7 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg, fmt_secs, run
20
20
 
21
21
 
22
22
  def main() -> int:
@@ -57,7 +57,13 @@ def main() -> int:
57
57
  cmd += aac_args()
58
58
  else:
59
59
  cmd += ["-an"]
60
- dropped_streams = run_keeping_subtitles(cmd, output)
60
+ if args.start > 0:
61
+ # a stream-copied subtitle track keeps its timestamps and would fire --start seconds early
62
+ # (sweep F3); drop it and say so, as freeze --mode insert and fit --method speed do
63
+ run(cmd + [output])
64
+ dropped_streams = bool(meta.get("subtitle_streams") or meta.get("data_streams"))
65
+ else:
66
+ dropped_streams = run_keeping_subtitles(cmd, output)
61
67
 
62
68
  result = probe(output, role="output")
63
69
  v = result["video"]
package/scripts/redact.py CHANGED
@@ -74,7 +74,11 @@ def main() -> int:
74
74
  output = args.output or default_output(args.input, "redact")
75
75
  crop = f"crop={args.width}:{args.height}:{args.x}:{args.y}"
76
76
  if args.mode == "blur":
77
- region = f"{crop},boxblur={args.blur_strength}:{args.blur_strength}"
77
+ # boxblur refuses a radius above half the plane: the chroma planes of 4:2:0 are half-size,
78
+ # so they get their own (halved) radius; a 30 px region with the default 20 used to fail
79
+ radius = max(1, min(args.blur_strength, min(args.width, args.height) // 2 - 1))
80
+ chroma = max(1, min(radius // 2, min(args.width, args.height) // 4 - 1))
81
+ region = f"{crop},boxblur={radius}:{radius}:{chroma}:{radius}"
78
82
  else:
79
83
  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"
80
84
  fc = f"[0:v]split=2[base][region];[region]{region}[patched];[base][patched]overlay={args.x}:{args.y}[out]"
package/scripts/render.py CHANGED
@@ -141,7 +141,16 @@ def main() -> int:
141
141
  # shared path, e.g. to inspect intermediates across runs); only the auto-derived default is
142
142
  # made unique per process, since it's the one that's also auto-deleted at the end.
143
143
  work = Path(args.work) if args.work else Path(f"{Path(output).with_suffix('')}_work_{os.getpid()}")
144
- work.mkdir(parents=True, exist_ok=True)
144
+ try:
145
+ work.mkdir(parents=True, exist_ok=True)
146
+ except OSError as e:
147
+ die(f"cannot create the work directory {work}: {e}")
148
+ if not args.keep and not args.work:
149
+ # a failed or dry run used to leave <output>_work_<pid>/ behind (sweep F15): the
150
+ # auto-named directory is ours alone, so remove it on every exit path
151
+ import atexit
152
+ import shutil
153
+ atexit.register(lambda: shutil.rmtree(work, ignore_errors=True))
145
154
  frame = proj.get("frame") or {}
146
155
  trans = proj.get("transition") or {}
147
156
  brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
@@ -151,6 +160,8 @@ def main() -> int:
151
160
  parts: List[str] = []
152
161
  for i, c in enumerate(clips):
153
162
  src = rel(c["src"])
163
+ if not os.path.exists(src):
164
+ die(f"clip {i}: source not found: {src}") # under --dry-run too: a plan for a missing file is no plan
154
165
  if not STATE.dry_run:
155
166
  probe(src)
156
167
  needs_cut = c.get("in") is not None or c.get("out") is not None
@@ -168,10 +179,11 @@ def main() -> int:
168
179
  spd = float(c["speed"])
169
180
  if not (spd > 0) or spd != spd or spd == float("inf"):
170
181
  die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
171
- dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
172
- fitted = str(work / f"clip{i:02d}_speed.mp4")
173
- sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
174
- part = fitted
182
+ if abs(spd - 1.0) > 1e-6: # speed 1.0 used to cost a full re-encode for nothing
183
+ dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
184
+ fitted = str(work / f"clip{i:02d}_speed.mp4")
185
+ sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
186
+ part = fitted
175
187
  parts.append(part)
176
188
  stages_done.append("clips")
177
189
  current = parts[0]
package/scripts/report.py CHANGED
@@ -158,7 +158,10 @@ def main() -> int:
158
158
  if STATE.dry_run:
159
159
  info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
160
160
  else:
161
- Path(output).write_text(doc, encoding="utf-8")
161
+ try:
162
+ Path(output).write_text(doc, encoding="utf-8")
163
+ except OSError as e:
164
+ die(f"cannot write {output}: {e}", kind="output")
162
165
  info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
163
166
  emit(None, report=output, check=chk)
164
167
  if not args.json:
@@ -34,6 +34,9 @@ def main() -> int:
34
34
  has_audio = bool(meta.get("audio")) and not args.no_audio
35
35
 
36
36
  output = args.output or default_output(args.input, "reverse")
37
+ if (meta.get("duration") or 0) > 60:
38
+ info(f"warning: reverse buffers every decoded frame in memory; {meta['duration']:.0f}s of "
39
+ f"{meta['video']['width']}x{meta['video']['height']} can exhaust RAM -- cut the part to reverse first (cut.py)")
37
40
  cmd = ffmpeg_base() + ["-i", args.input, "-vf", "reverse"]
38
41
  if has_audio:
39
42
  cmd += ["-af", "areverse"]
package/scripts/sync.py CHANGED
@@ -213,6 +213,8 @@ def main() -> int:
213
213
  add_common(ap)
214
214
  args = ap.parse_args()
215
215
  apply_common(args)
216
+ if args.analyze_seconds > 900:
217
+ die(f"--analyze-seconds {args.analyze_seconds:g}: the window is decoded into memory; 900 s is the ceiling")
216
218
 
217
219
  for p in (args.reference, args.second):
218
220
  if not probe(p).get("audio"):
package/scripts/verify.py CHANGED
@@ -70,12 +70,15 @@ def collect(paths: List[str]) -> List[Path]:
70
70
  def step(name: str, argv: List[str], timeout: float) -> Dict:
71
71
  t0 = time.time()
72
72
  if argv[0] == "__check_hdr__":
73
+ was_json, STATE.json = STATE.json, False # probe()'s die() would print a JSON document of its own
73
74
  try:
74
75
  v = probe(argv[1]).get("video") or {}
75
76
  ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
76
77
  err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
77
78
  except SystemExit:
78
79
  ok, err = False, "output missing"
80
+ finally:
81
+ STATE.json = was_json
79
82
  return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
80
83
  try:
81
84
  proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
@@ -113,12 +116,15 @@ def main() -> int:
113
116
  for f in files:
114
117
  info(f"=== {f}")
115
118
  entry: Dict = {"file": str(f), "steps": []}
119
+ was_json, STATE.json = STATE.json, False # same: one JSON document per run, printed at the end
116
120
  try:
117
121
  meta = probe(str(f))
118
122
  except SystemExit:
119
123
  entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
120
124
  results.append(entry)
121
125
  continue
126
+ finally:
127
+ STATE.json = was_json
122
128
  entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
123
129
  entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
124
130
  entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}