ffmpeg-skill 1.4.8 → 1.4.10

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/README.md CHANGED
@@ -293,7 +293,7 @@ The contract is generated from the code that runs, not maintained beside it. For
293
293
  | `mutates_input` | always `false` |
294
294
  | `idempotency_hint` | `bit_exact`, `content_equivalent`, `cached` or `environment_dependent` |
295
295
 
296
- `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
296
+ `contract_version` (1.0) is separate from the skill version, so a consumer can pin the shape and read the version for provenance. The document also states the invocation mapping (structured arguments → argv), the JSON shapes for success and failure (`{"status": "failed", "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": …}}`), and that no tool runs a shell or executes anything other than the named script, `ffmpeg` and `ffprobe`. Field-by-field reference: [docs/contract.md](docs/contract.md).
297
297
 
298
298
  ### MCP
299
299
 
@@ -316,6 +316,21 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
316
316
 
317
317
  `doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). No tool here uses one yet — every tool still assumes CPU x264/x265 — so this is purely informational and never affects `ok` or any tool's `usable`. GPU-accelerated encoding stays deliberately off the roadmap until there's a real-hardware-verified design for it (build-presence alone is not proof a job will succeed) — not a promised feature, just an honest "not yet, and not without proof it actually works."
318
318
 
319
+ ## Gotchas and best practices
320
+
321
+ The short list for humans. The agent-facing version, with the reasoning, is the "Things that look right but are wrong" and "Gotchas" sections of [SKILL.md](SKILL.md).
322
+
323
+ - **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically, and `cut.py` switches to frame-accurate mode on its own because copy-cuts on VFR land on the wrong frame. Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
324
+ - **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1`, and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`.
325
+ - **HDR stays HDR.** When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with `color.py --to-sdr` before H.264 deliverables or LUT work. `export.py` platform presets are SDR and warn on HDR input.
326
+ - **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
327
+ - **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
328
+ - **Cropping 16:9 to 9:16 discards 70 % of the width.** `fit.py --fit crop` centres by default; pass `--crop-x`/`--crop-y` toward the subject, or pad with `--fit pad --pad-fill blur`. Look at the contact sheet before deciding.
329
+ - **Non-Latin captions need a font with the glyphs.** Without one you get boxes, not an error. Name it (`caption.py --font "Noto Sans CJK JP"`) or point at the file (`overlay.py --font-file /path/to/NotoSansCJK-Regular.ttc`).
330
+ - **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
331
+ - **Sync results carry a confidence.** Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge `--analyze-seconds` or find a clap. Recordings over ten minutes from separate devices need `sync.py --fix-drift`.
332
+ - **Long chains belong in a plan.** Three hand-chained re-encodes lose quality and are hard to change; `render.py` runs the whole edit from one JSON file, and `--dry-run` shows every ffmpeg command before anything is written.
333
+
319
334
  ## FFmpeg compatibility
320
335
 
321
336
  The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
9
9
 
10
10
  ## Workflow (always follow this order)
11
11
 
@@ -265,7 +265,7 @@ Notes: send a valid .cube, or say if you want the clip left as is
265
265
 
266
266
  A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
267
267
 
268
- Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
268
+ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification | interrupted, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
269
269
 
270
270
  ## Things that look right but are wrong
271
271
 
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.8`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.4.10`) | 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.8", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.4.10", "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"},
@@ -156,9 +156,10 @@ with `--dry-run` behind a fake `ffmpeg` that records any call, and asserts that
156
156
  call happened and no file appeared. Under `--dry-run` a tool prints the command lines
157
157
  it would run, reports `dry_run: true`, and never reports an output probe. The
158
158
  exceptions are stated per tool in the contract's `dry_run` field: `probe` and `check` are
159
- read-only (ffprobe still runs), `sync`, `multicam`, `scenes`, `cropdetect` and `report` still
160
- run their ffmpeg/ffprobe measurements (the analysis is the tool's job; only the artifact is
161
- skipped), and `verify` does not support dry-run (its steps run). `SKILL.md` and
159
+ read-only (ffprobe still runs); `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`,
160
+ `loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
161
+ tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
162
+ generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
162
163
  `references/scripts.md` repeat the same list; the contract is the authority.
163
164
 
164
165
  ### Repeatability
@@ -308,7 +309,7 @@ before, and, when `--json` was given, on stdout:
308
309
 
309
310
  ```json
310
311
  {"status": "failed", "exit_code": 1,
311
- "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": "...",
312
+ "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification | interrupted", "message": "...",
312
313
  "code": "INPUT_INVALID | DEPENDENCY_MISSING | FFMPEG_EXECUTION_FAILED | OUTPUT_INVALID | TIMEOUT | VERIFICATION_FAILED | INTERNAL_ERROR",
313
314
  "retryable": false},
314
315
  "commands": ["ffmpeg ..."]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.4.8",
3
+ "version": "1.4.10",
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",
@@ -596,6 +596,13 @@ loudness.py INPUT [-I -14] [--tp -1] [--lra 11] [--measure-only] [-o OUT]
596
596
  Two-pass `loudnorm`: measure, then apply with measured values (linear mode when
597
597
  the true-peak ceiling allows). Video is stream-copied; audio becomes AAC in
598
598
  video containers or the codec matching the extension (.wav → PCM, .flac, .mp3).
599
+ The written file is measured again: a lossy encoder can push peaks past the
600
+ ceiling loudnorm held (ffmpeg's AAC at 192k turned one transient from -2.4 to
601
+ +3.7 dBFS). When it does, the tool re-encodes -- first at 256k then 320k if you
602
+ did not pass `--audio-bitrate`, then with the loudnorm ceiling lowered by the
603
+ overshoot -- until the file itself meets `--tp`. `result` reports
604
+ `tp_ceiling_used`, `audio_bitrate_used`, `encodes`, and a `note` when the
605
+ integrated loudness ended more than 1 LU from the target because of it.
599
606
 
600
607
  ### export.py — delivery presets
601
608
  ```
@@ -64,6 +64,7 @@ ERROR_CODE = {
64
64
  "output": "OUTPUT_INVALID",
65
65
  "timeout": "TIMEOUT",
66
66
  "verification": "VERIFICATION_FAILED",
67
+ "interrupted": "INTERRUPTED",
67
68
  }
68
69
 
69
70
  # Wall-clock ceiling for one ffmpeg/ffprobe invocation, in seconds. A hung ffmpeg (a build
@@ -263,6 +264,66 @@ def apply_common(args: "argparse.Namespace") -> None:
263
264
  crf = getattr(args, "crf", None)
264
265
  if crf is not None and not 0 <= int(crf) <= 51:
265
266
  die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
267
+ install_signal_handlers()
268
+
269
+
270
+ # The child processes this tool is waiting on right now (an ffmpeg, or a sibling script under
271
+ # run_tool), with the command whose partial output would need removing. A signal handler
272
+ # reads it; the runners keep it current. Before 1.4.9 a SIGTERM to the tool (a cancelled MCP
273
+ # call, a supervisor's stop, a closed terminal) killed only the Python parent: ffmpeg carried on
274
+ # as an orphan, finished a file nobody verified, and the caller got no JSON at all; SIGINT was a
275
+ # KeyboardInterrupt traceback with the partial left on disk.
276
+ _CHILDREN: List[Tuple[subprocess.Popen, Sequence[str]]] = []
277
+ _SIGNALS_INSTALLED = False
278
+
279
+
280
+ def _on_signal(signum: int, frame: Any) -> None:
281
+ import signal as _signal
282
+ name = {getattr(_signal, "SIGINT", None): "SIGINT", getattr(_signal, "SIGTERM", None): "SIGTERM"}.get(signum, str(signum))
283
+ for proc, cmd in list(_CHILDREN):
284
+ try:
285
+ proc.terminate() # ffmpeg exits promptly on SIGTERM; a sibling script runs this same handler
286
+ try:
287
+ proc.wait(timeout=5)
288
+ except subprocess.TimeoutExpired:
289
+ proc.kill()
290
+ proc.wait()
291
+ except OSError:
292
+ pass
293
+ if cmd:
294
+ _cleanup_partial_output(cmd)
295
+ _CHILDREN.clear()
296
+ die(f"interrupted by {name}: the running command was stopped and its partial output removed; nothing was written",
297
+ code=128 + signum, kind="interrupted")
298
+
299
+
300
+ def install_signal_handlers() -> None:
301
+ """SIGINT/SIGTERM stop the child, remove its partial output and exit with a failure document
302
+ (kind: interrupted, exit 130/143). Main thread only; on Windows SIGTERM is never delivered,
303
+ SIGINT (Ctrl-C) is."""
304
+ global _SIGNALS_INSTALLED
305
+ if _SIGNALS_INSTALLED:
306
+ return
307
+ import signal as _signal
308
+ import threading
309
+ if threading.current_thread() is not threading.main_thread():
310
+ return
311
+ for sig in (getattr(_signal, "SIGINT", None), getattr(_signal, "SIGTERM", None)):
312
+ if sig is None:
313
+ continue
314
+ try:
315
+ _signal.signal(sig, _on_signal)
316
+ except (ValueError, OSError):
317
+ pass
318
+ _SIGNALS_INSTALLED = True
319
+
320
+
321
+ def _watch(proc: subprocess.Popen, cmd: Sequence[str]) -> None:
322
+ _CHILDREN.append((proc, cmd))
323
+
324
+
325
+ def _unwatch(proc: subprocess.Popen) -> None:
326
+ _CHILDREN[:] = [(p, c) for p, c in _CHILDREN if p is not proc]
266
327
 
267
328
 
268
329
  def emit(output: Optional[str], **extra: Any) -> None:
@@ -356,6 +417,26 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
356
417
  continue
357
418
 
358
419
 
420
+ def refuse_output_is_input(output: str, *inputs: str) -> None:
421
+ """Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
422
+ the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
423
+ a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
424
+ through _check_no_overwrite_input() and replaced the source with the join (fourth audit,
425
+ P0). Call it once the output path is known, before any part of the input is consumed."""
426
+ try:
427
+ out_real = os.path.realpath(output)
428
+ except OSError:
429
+ return
430
+ for inp in inputs:
431
+ try:
432
+ same = os.path.realpath(inp) == out_real
433
+ except OSError:
434
+ continue
435
+ if same:
436
+ die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
437
+ f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
438
+
439
+
359
440
  def _check_existing_output(cmd: Sequence[str]) -> None:
360
441
  """An output path that already exists is someone's file: a previous result, a source the
361
442
  agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
@@ -514,9 +595,16 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
514
595
  document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
515
596
  exactly as they would from the child itself."""
516
597
  limit = child_limit(per_call)
598
+ child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
599
+ _watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
517
600
  try:
518
- return subprocess.run([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
601
+ out, err = child.communicate(timeout=limit)
602
+ _unwatch(child)
603
+ return subprocess.CompletedProcess(child.args, child.returncode, out, err)
519
604
  except subprocess.TimeoutExpired as e:
605
+ child.kill()
606
+ child.communicate()
607
+ _unwatch(child)
520
608
  name = os.path.basename(str(argv[0]))
521
609
  msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
522
610
  doc = {"status": "failed", "exit_code": 124,
@@ -608,10 +696,18 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
608
696
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
609
697
  """Plain run with stdout/stderr captured."""
610
698
  limit = _limit_for(cmd)
699
+ child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
700
+ _watch(child, cmd)
611
701
  try:
612
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
702
+ out, err = child.communicate(timeout=limit)
613
703
  except subprocess.TimeoutExpired:
704
+ child.kill()
705
+ child.communicate()
706
+ _unwatch(child)
614
707
  _timed_out(cmd, limit or 0)
708
+ finally:
709
+ _unwatch(child)
710
+ proc = subprocess.CompletedProcess(list(cmd), child.returncode, out, err)
615
711
  if proc.returncode == 0 and _is_ffmpeg(cmd):
616
712
  _remember_output(cmd)
617
713
  if proc.returncode != 0:
@@ -651,6 +747,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
651
747
  t0 = time.time()
652
748
  limit = _limit_for(cmd)
653
749
  proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
750
+ _watch(proc, cmd)
654
751
  assert proc.stdout is not None and proc.stderr is not None
655
752
  lines: "queue.Queue[Optional[str]]" = queue.Queue()
656
753
  err_chunks: List[str] = []
@@ -702,6 +799,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
702
799
  proc.wait(timeout=(max(5.0, limit - (time.time() - t0)) if limit else None))
703
800
  except subprocess.TimeoutExpired:
704
801
  timed_out()
802
+ _unwatch(proc)
705
803
  err_thread.join()
706
804
  err = "".join(err_chunks)
707
805
  clear_line()
@@ -954,6 +1052,19 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
954
1052
  return total
955
1053
 
956
1054
 
1055
+ def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
1056
+ """parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
1057
+ the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
1058
+ `--json` callers get a failure document, never a traceback)."""
1059
+ try:
1060
+ return parse_time(value, fps)
1061
+ except MissingFpsError as e:
1062
+ die(f"{flag} {value!r}: {e}")
1063
+ except ValueError as e:
1064
+ die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms or, with a known fps, hh:mm:ss:ff)")
1065
+ return 0.0 # unreachable
1066
+
1067
+
957
1068
  def fmt_srt_time(seconds: float) -> str:
958
1069
  if seconds < 0:
959
1070
  seconds = 0.0
@@ -987,12 +1098,17 @@ def escape_filter_path(path: str) -> str:
987
1098
  written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
988
1099
  ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
989
1100
  Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
990
- never has to be escaped itself; `'`, `,`, `;`, `[` and `]` are graph-level characters.
1101
+ never has to be escaped itself; `,`, `;`, `[` and `]` are graph-level characters and survive with
1102
+ one backslash. `'` is special: the graph parser also treats a quote as the start of a quoted
1103
+ token, so a single `\\'` is consumed by the first pass and "Ryo's Mac/cues.srt" reaches the
1104
+ filter as "Ryos Mac/cues.srt" (Unable to open ...). Three backslashes survive both passes
1105
+ (measured on 6.1 and 7.1 with subtitles=, ass= and lut3d=file=).
991
1106
  """
992
1107
  p = str(Path(path))
993
1108
  p = p.replace("\\", "/")
994
1109
  p = p.replace(":", "\\\\:")
995
- for ch in ("'", ",", ";", "[", "]"):
1110
+ p = p.replace("'", "\\\\\\'")
1111
+ for ch in (",", ";", "[", "]"):
996
1112
  p = p.replace(ch, "\\" + ch)
997
1113
  return p
998
1114
 
@@ -1040,7 +1040,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
1040
1040
  "json_output": {
1041
1041
  "success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
1042
1042
  "failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
1043
- "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1"},
1043
+ "error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH", "timeout": "one ffmpeg/ffprobe run exceeded --timeout (default 1800 s, FFMPEG_SKILL_TIMEOUT) and was killed; partial output removed; exit 124", "verification": "the tool ran but its result failed the requested check: check.py platform rows (checks attached), render.py's check stage (output written, check attached), batch.py items (results attached), verify.py steps (files attached); exit 1", "interrupted": "the tool received SIGINT or SIGTERM: the running ffmpeg (or sibling script) was stopped and its partial output removed; exit 130 or 143"},
1044
1044
  "success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
1045
1045
  },
1046
1046
  "capabilities": caps,
@@ -14,7 +14,7 @@ import argparse
14
14
  import math
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -34,7 +34,7 @@ def main() -> int:
34
34
  args = ap.parse_args()
35
35
  apply_common(args)
36
36
 
37
- target = parse_time(args.duration)
37
+ target = time_arg(args.duration, "--duration", args.fps)
38
38
  if target <= 0:
39
39
  die("--duration must be > 0")
40
40
  if args.fps <= 0:
@@ -200,7 +200,10 @@ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
200
200
  if times:
201
201
  a, b = times.split("-->")
202
202
  text = "\n".join(block[block.index(times) + 1:]).strip()
203
- cues.append((parse_time(a), parse_time(b), text))
203
+ try:
204
+ cues.append((parse_time(a), parse_time(b), text))
205
+ except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
206
+ die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
204
207
  block = []
205
208
  if not cues:
206
209
  die(f"no cues found in {path}")
@@ -531,12 +534,16 @@ def main() -> int:
531
534
  w, h = meta["video"]["width"], meta["video"]["height"]
532
535
  if meta["video"].get("rotation") in (90, -90, 270, -270):
533
536
  w, h = h, w
534
- write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
537
+ if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
538
+ write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
535
539
  info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
536
540
  args.ass = ass_path
541
+ generated_ass = True
542
+ else:
543
+ generated_ass = False
537
544
 
538
545
  if args.ass:
539
- if not os.path.exists(args.ass):
546
+ if not generated_ass and not os.path.exists(args.ass):
540
547
  die(f"ASS file not found: {args.ass}")
541
548
  vf = f"ass={escape_filter_path(args.ass)}"
542
549
  if args.fonts_dir:
package/scripts/cut.py CHANGED
@@ -30,7 +30,7 @@ import sys
30
30
  import tempfile
31
31
  from typing import List, Tuple
32
32
 
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
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
34
34
 
35
35
  # keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
36
36
  # (reported so the caller can choose a lossless cut at one of them next time)
@@ -192,6 +192,7 @@ def main() -> int:
192
192
  segments = [(s, min(e, total) if total else e) for s, e in segments]
193
193
 
194
194
  output = args.output or default_output(args.input, "cut")
195
+ refuse_output_is_input(output, args.input)
195
196
  ext = os.path.splitext(output)[1] or ".mp4"
196
197
 
197
198
  reencoded = False
package/scripts/fit.py CHANGED
@@ -38,7 +38,7 @@ import sys
38
38
  from fractions import Fraction
39
39
  from typing import List
40
40
 
41
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -155,7 +155,7 @@ def main() -> int:
155
155
 
156
156
  # ---- duration
157
157
  if args.duration:
158
- target = parse_time(args.duration)
158
+ target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
159
159
  if target <= 0:
160
160
  die("target duration must be > 0")
161
161
  if args.method == "speed":
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -86,8 +86,9 @@ def main() -> int:
86
86
  if meta["video"].get("rotation") in (90, -90, 270, -270):
87
87
  W, H = H, W
88
88
  dur = meta.get("duration") or 0.0
89
- s = parse_time(args.start) if args.start else 0.0
90
- e = parse_time(args.end) if args.end else dur
89
+ fps = meta["video"].get("fps")
90
+ s = time_arg(args.start, "--start", fps) if args.start else 0.0
91
+ e = time_arg(args.end, "--end", fps) if args.end else dur
91
92
  if e <= s:
92
93
  die("--end must be after --start")
93
94
  en = f"enable='between(t,{s:.3f},{e:.3f})'"
package/scripts/insert.py CHANGED
@@ -27,7 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
30
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
31
31
 
32
32
 
33
33
  def even(n: float) -> int:
@@ -52,7 +52,7 @@ def main() -> int:
52
52
  args = ap.parse_args()
53
53
  apply_common(args)
54
54
 
55
- target = parse_time(args.duration)
55
+ target = time_arg(args.duration, "--duration", args.fps)
56
56
  if target <= 0:
57
57
  die("--duration must be > 0")
58
58
  if args.fps <= 0:
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
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
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -69,7 +69,7 @@ def main() -> int:
69
69
  die("--compare needs --at TIME")
70
70
  probe(args.compare)
71
71
  for t in args.at:
72
- sec = parse_time(t)
72
+ sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
73
73
  out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
74
74
  half = args.width // 2
75
75
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
@@ -82,7 +82,7 @@ def main() -> int:
82
82
  outputs.append(out)
83
83
  elif args.at:
84
84
  for t in args.at:
85
- sec = parse_time(t)
85
+ sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
86
86
  if dur and sec > dur:
87
87
  die(f"--at {t} is beyond the duration ({dur:.2f}s)")
88
88
  if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
package/scripts/loop.py CHANGED
@@ -18,7 +18,7 @@ import argparse
18
18
  import math
19
19
  import sys
20
20
 
21
- from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
21
+ from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
22
22
 
23
23
 
24
24
  def main() -> int:
@@ -49,7 +49,7 @@ def main() -> int:
49
49
  target = None
50
50
  stream_loop = args.times - 1
51
51
  else:
52
- target = parse_time(args.duration)
52
+ target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
53
53
  if target <= src_dur:
54
54
  die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
55
55
  stream_loop = math.ceil(target / src_dur) - 1
@@ -50,7 +50,7 @@ def main() -> int:
50
50
  ap.add_argument("--tp", type=float, default=-1.0, help="true peak ceiling in dBTP (default -1)")
51
51
  ap.add_argument("--lra", type=float, default=11.0, help="loudness range target in LU (default 11)")
52
52
  ap.add_argument("--measure-only", action="store_true", help="print the measured stats as JSON and exit")
53
- ap.add_argument("--audio-bitrate", default="192k", help="AAC bitrate when the container is video (default 192k)")
53
+ ap.add_argument("--audio-bitrate", default=None, help="AAC bitrate when the container is video (default 192k; raised to 256k/320k only when the encoder overshoots the true-peak ceiling and you did not pin it)")
54
54
  ap.add_argument("--sample-rate", type=int, help="output sample rate (default: 48000; loudnorm upsamples internally to 192k)")
55
55
  add_common(ap)
56
56
  args = ap.parse_args()
@@ -80,24 +80,79 @@ def main() -> int:
80
80
  )
81
81
  sr = args.sample_rate or meta["audio"].get("sample_rate") or 48000
82
82
  ext = os.path.splitext(output)[1].lower()
83
- cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
84
- if ext in AUDIO_CODECS or not meta.get("video"):
85
- cmd += ["-vn"] + audio_codec_for(output, args.audio_bitrate)
86
- else:
87
- cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", args.audio_bitrate]
88
- cmd.append(output)
89
- run(cmd)
83
+ bitrate_pinned = args.audio_bitrate is not None
84
+ bitrate = args.audio_bitrate or "192k"
90
85
 
86
+ def encode(tp: float, bitrate: str) -> None:
87
+ af = (
88
+ f"loudnorm=I={args.lufs}:TP={tp}:LRA={args.lra}"
89
+ f":measured_I={stats['input_i']}:measured_TP={stats['input_tp']}:measured_LRA={stats['input_lra']}"
90
+ f":measured_thresh={stats['input_thresh']}:offset={stats['target_offset']}:linear=true:print_format=summary"
91
+ )
92
+ cmd = ffmpeg_base() + ["-i", args.input, "-af", af, "-ar", str(sr)]
93
+ if ext in AUDIO_CODECS or not meta.get("video"):
94
+ cmd += ["-vn"] + audio_codec_for(output, bitrate)
95
+ else:
96
+ cmd += ["-map", "0:v:0", "-map", "0:a:0", "-c:v", "copy", "-c:a", "aac", "-b:a", bitrate]
97
+ cmd.append(output)
98
+ run(cmd)
99
+
100
+ encode(args.tp, bitrate)
91
101
  if STATE.dry_run:
92
102
  # pass 1 measured the input for real; there is no output to measure
93
103
  emit(output, measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
94
104
  return 0
95
105
  after = measure(output, args.lufs, args.tp, args.lra)
106
+ # loudnorm holds the ceiling on the float samples it outputs; the lossy encoder then adds
107
+ # its own overshoot. ffmpeg's native AAC at 192k turned one transient of a 12-minute film
108
+ # from -2.4 dBFS into +3.7 dBFS, so the file measured +1.2 dBTP after "--tp -1" and
109
+ # check.py's fix hint pointed straight back here. Two remedies, in this order: more bits
110
+ # (256k, then 320k: the overshoot is quantisation noise and shrinks with bitrate, and the
111
+ # loudness target is untouched) when the caller did not pin the bitrate; then a lower
112
+ # loudnorm ceiling by the measured overshoot, which in linear mode also lowers the
113
+ # integrated loudness -- reported, never hidden.
114
+ ceiling, rounds = args.tp, 0
115
+ steps = [] if bitrate_pinned or ext in AUDIO_CODECS and "aac" not in AUDIO_CODECS[ext] else [b for b in ("256k", "320k") if _kbps(b) > _kbps(bitrate)]
116
+ while not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1 and rounds < 5:
117
+ rounds += 1
118
+ overshoot = float(after["input_tp"]) - args.tp
119
+ if steps:
120
+ bitrate = steps.pop(0)
121
+ info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {args.tp:g} dBTP after encoding "
122
+ f"(codec overshoot); re-encoding at {bitrate}")
123
+ else:
124
+ ceiling -= overshoot + 0.2
125
+ info(f"true peak {float(after['input_tp']):.2f} dBTP exceeds the requested {args.tp:g} dBTP after encoding "
126
+ f"(codec overshoot); re-encoding with the loudnorm ceiling at {ceiling:.2f} dBTP")
127
+ encode(ceiling, bitrate)
128
+ after = measure(output, args.lufs, args.tp, args.lra)
96
129
  if not after.get("silent"):
97
- info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
98
- emit(output, result={k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
130
+ info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS, TP <= {args.tp:g})")
131
+ result = {k: after[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")}
132
+ result["tp_ceiling_used"] = round(ceiling, 2)
133
+ if ext not in AUDIO_CODECS or "aac" in AUDIO_CODECS[ext]:
134
+ result["audio_bitrate_used"] = bitrate
135
+ result["encodes"] = rounds + 1
136
+ if not after.get("silent") and abs(float(after["input_i"]) - args.lufs) > 1.0:
137
+ result["note"] = (f"integrated loudness is {float(after['input_i']) - args.lufs:+.1f} LU from the target because the "
138
+ f"true-peak ceiling had to absorb the encoder's overshoot; a lossless delivery (wav/flac) or a pinned "
139
+ f"higher --audio-bitrate keeps both")
140
+ if not after.get("silent") and float(after["input_tp"]) > args.tp + 0.1:
141
+ die(f"true peak is still {float(after['input_tp']):.2f} dBTP after {rounds + 1} encodes (requested <= {args.tp:g}); "
142
+ f"the encoder overshoots more than the loudnorm ceiling can absorb at this bitrate",
143
+ kind="verification", output=output, result=result,
144
+ hint="raise --audio-bitrate (e.g. 256k) or deliver a lossless format (wav/flac) and let the platform encode")
145
+ emit(output, result=result)
99
146
  return 0
100
147
 
101
148
 
149
+ def _kbps(value: str) -> int:
150
+ v = value.lower().rstrip("k")
151
+ try:
152
+ return int(float(v)) if value.lower().endswith("k") else int(float(v)) // 1000
153
+ except ValueError:
154
+ return 0
155
+
156
+
102
157
  if __name__ == "__main__":
103
158
  sys.exit(main())
@@ -48,7 +48,7 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
48
48
  parts = line.split(None, 1)
49
49
  try:
50
50
  start = parse_time(parts[0])
51
- except ValueError:
51
+ except ValueError: # MissingFpsError is a ValueError: chapter files carry no fps
52
52
  die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
53
53
  title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
54
54
  if entries and start <= entries[-1]["start"]:
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -155,8 +155,9 @@ def main() -> int:
155
155
  if args.audio_stream and not audio_streams:
156
156
  die("--audio-stream needs an input with audio streams")
157
157
  vw = meta["video"]["width"]
158
- start = parse_time(args.start) if args.start else None
159
- end = parse_time(args.end) if args.end else None
158
+ fps = meta["video"].get("fps")
159
+ start = time_arg(args.start, "--start", fps) if args.start else None
160
+ end = time_arg(args.end, "--end", fps) if args.end else None
160
161
  if start is not None and end is not None and end <= start:
161
162
  die("--end must be after --start")
162
163
  if not 0 <= args.opacity <= 1:
package/scripts/pad.py CHANGED
@@ -16,15 +16,15 @@ 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
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
20
20
 
21
21
 
22
22
  def main() -> int:
23
23
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
24
24
  ap.add_argument("input")
25
25
  ap.add_argument("-o", "--output", help="output file (default: <name>_pad.<ext>)")
26
- ap.add_argument("--start", type=float, default=0.0, help="seconds of padding to add before the clip (default 0)")
27
- ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
26
+ ap.add_argument("--start", default="0", help="padding to add before the clip: seconds or mm:ss (default 0)")
27
+ ap.add_argument("--end", default="0", help="padding to add after the clip: seconds or mm:ss (default 0)")
28
28
  ap.add_argument("--color", default="black", help="padding colour (default black)")
29
29
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
30
30
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
@@ -32,6 +32,8 @@ def main() -> int:
32
32
  args = ap.parse_args()
33
33
  apply_common(args)
34
34
 
35
+ args.start = time_arg(args.start, "--start")
36
+ args.end = time_arg(args.end, "--end")
35
37
  if args.start < 0 or args.end < 0:
36
38
  die(f"--start/--end must be >= 0, got start={args.start:g} end={args.end:g}")
37
39
  if args.start == 0 and args.end == 0:
package/scripts/scenes.py CHANGED
@@ -24,7 +24,7 @@ import re
24
24
  import sys
25
25
  from typing import Dict, List, Tuple
26
26
 
27
- from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
27
+ from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
28
28
 
29
29
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
30
30
 
@@ -168,9 +168,10 @@ def main() -> int:
168
168
  result["highlights_rank_by"] = args.rank_by
169
169
  info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
170
170
  if args.edl:
171
- with open(args.edl, "w", encoding="utf-8") as fh:
172
- for s, e in picks:
173
- fh.write(f"{s:.2f}-{e:.2f}\n")
171
+ if not STATE.dry_run: # the contract says --edl is not written under --dry-run
172
+ with open(args.edl, "w", encoding="utf-8") as fh:
173
+ for s, e in picks:
174
+ fh.write(f"{s:.2f}-{e:.2f}\n")
174
175
  info(f"wrote {args.edl}")
175
176
 
176
177
  if args.sheet:
@@ -104,9 +104,10 @@ def main() -> int:
104
104
  info("hint: " + summary["hint"])
105
105
 
106
106
  if args.edl:
107
- with open(args.edl, "w", encoding="utf-8") as fh:
108
- for s, e in keeps:
109
- fh.write(f"{s:.3f}-{e:.3f}\n")
107
+ if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
108
+ with open(args.edl, "w", encoding="utf-8") as fh:
109
+ for s, e in keeps:
110
+ fh.write(f"{s:.3f}-{e:.3f}\n")
110
111
  info(f"wrote {args.edl}")
111
112
 
112
113
  if args.list: