ffmpeg-skill 2.2.3 → 2.2.5

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
@@ -259,7 +259,7 @@ These are the rules the skill file gives the agent and the code enforces.
259
259
  |---|---|
260
260
  | `render.py` | Render a whole edit from a declarative `project.json` (clips, transitions, captions, overlays including the social sticker/hook/meme graphics, music and stem levels, loudness, export, chapter markers, check); `--init`, `--dry-run`, `--stop-after`, `--cache DIR`/`--from STAGE` (reuse the stages that did not change); the captions block takes the fit-size policy (`fit_size`, `min_size`, `fit_size_scope`) and the run reports the caption stage's counts as `caption`; `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table); `--export-timeline edit.fcpxml\|.edl\|.otio` (2.1) writes the cut as an editor timeline for Final Cut, DaVinci Resolve or Premiere instead of rendering it — clips, speed, dissolves, the music bed and chapter markers carry over, and `not_exported` names what stays behind |
261
261
  | `batch.py` | Apply a step recipe or a project to a folder with a content-hash cache; `--watch`; `--jobs N` processes several files at once under one shared `--timeout` |
262
- | `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list; `--switch energy` auto-cuts to whichever camera is loudest/talking instead of a hand-built list, with `--min-shot` (minimum shot length) and `--edl` (write the cut list) |
262
+ | `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list; `--switch energy` auto-cuts to whichever camera is loudest/talking instead of a hand-built list, with `--min-shot` (minimum shot length) and `--edl` (write a plain START-END cut list, not a CMX EDL; for Premiere/Resolve use `--write-project` then `render.py --export-timeline`) |
263
263
  | `verify.py` | Run the toolchain on real device files and report PASS / FAIL per step |
264
264
 
265
265
  Not tools, but part of the surface: `mcp/server.py` (the MCP transport) and `scripts/_contract.py` (`contract --json`, `doctor`). Per-flag reference for every tool: [references/scripts.md](references/scripts.md).
package/SKILL.md CHANGED
@@ -5,11 +5,11 @@ 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`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much to read as this file; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 42 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request, and `--help` on the script about to run is the cheapest full flag list. A reference file costs as much as this one; open one only for a question you have: `references/scripts.md` (every flag of all 42 scripts), `references/devices.md` (iPhone HDR, GoPro, DJI, screen recordings, Zoom), `references/gotchas.md` (the long form of the one-line rules at the end). MCP: `tools/list` shows only the core 12 by default; the other 42 (per this table) stay callable by name via `tools/call`, described by `contract --json`; set `FFMPEG_SKILL_MCP_FULL=1` for all 42.
9
9
 
10
10
  Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (status/output/verified plus a `summary`; prefer on writing steps); `--fast` (preview quality); `--progress`; `--timeout SECONDS` (`kind: timeout`, default 1800); `--overwrite` (step 7); `--plan FILE` (the dry run as a plan `render.py FILE` runs later; refuses if an input changed). Re-encoding tools also take `--codec h264|hevc|av1|prores` and `--quality N`: unset, SDR is x264, HDR is x265 Main10; `prores` needs `-o NAME.mov`, `h264` refuses HDR (`color.py --to-sdr` first).
11
11
 
12
- Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe — they just skip the artifact and side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per-tool: `contract --json`'s `dry_run` field.
12
+ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `check`, `sync`, `multicam`, `scenes`, `cropdetect`, `report`, `silence`, `loudness`, `stabilize`) may still run ffmpeg/ffprobe, skipping artifacts/side files (`--edl`, `--sheet`, a generated `.ass`); `verify` ignores the flag. Per tool: `contract --json` `dry_run`.
13
13
 
14
14
  ## Workflow (always follow this order)
15
15
 
@@ -96,7 +96,7 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
96
96
  | "add subtitles from this SRT", "burn in captions" | `caption.py input.mp4 --srt subs.srt` |
97
97
  | "caption it with these lines" (text with times) | `caption.py input.mp4 --text cues.txt` |
98
98
  | "keep the subtitles toggleable", "mux in an SRT" | `caption.py input.mp4 --srt subs.srt --mode mux`; repeat `--srt file:lang` for several languages, `.mkv` for more than two |
99
- | "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off` for 1.16 behaviour, `--min-size` sets the floor). Keep the template's `--max-lines` (2 on a vertical) and let the size drop; raising it to dodge a shrink stacks two words per line. A word still wider than the column at the floor is sliced at the edge (hyphen preferred), never rewritten, automatic; `broken_inside_word` in the JSON counts it |
99
+ | "the captions are tiny / three lines on a Short", "don't chop the sentence" | `caption.py` shrinks the size until the cue fits `--max-lines` before splitting it (`--fit-size off` = 1.16 behaviour, `--min-size` the floor). Keep the template's `--max-lines` (2 on a vertical) and let the size drop; raising it to dodge a shrink stacks two words per line. A word still wider than the column at the floor is sliced at the edge (hyphen preferred), never rewritten, automatic; `broken_inside_word` in the JSON counts it |
100
100
  | "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; else `--text`) |
101
101
  | "TikTok-style captions with the words popping" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
102
102
  | "our logo top-right", "a watermark" | `overlay.py input.mp4 --image logo.png --position top-right --scale 200` |
@@ -139,9 +139,9 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
139
139
  | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` (TTS parts: `--list parts.txt`, `--on-missing skip`) |
140
140
  | "several changes to one edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
141
141
  | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages reused (`--from STAGE` starts there) |
142
- | "open it in Premiere / Resolve / Final Cut" | `render.py project.json --export-timeline edit.fcpxml\|.edl\|.otio` — the cut as a timeline, nothing rendered; read `not_exported` |
142
+ | "open it in Premiere / Resolve / Final Cut" | `render.py project.json --export-timeline edit.fcpxml\|.edl\|.otio` — renders nothing; read `not_exported`; no project? `--write-project` |
143
143
  | "do this to every file in the folder", "use all the cores" | `batch.py FOLDER --recipe batch.json --jobs auto` (steps or a render project; cached) |
144
- | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` (manual) or `--switch energy` (auto-cuts to the loudest camera, `--min-shot`, `--edl`) |
144
+ | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` (manual) or `--switch energy` (`--min-shot`); `--edl` = cut list; editor: `--write-project p.json` → `--export-timeline` |
145
145
  | "what's in this file", "how long is it" | `probe.py input.mp4` |
146
146
  | "show me what it looks like", "are the captions readable" | `look.py output.mp4 --tiles 3x2`, then view the PNG |
147
147
  | "what would you run?", "don't render yet" | any script with `--dry-run` |
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 (`2.2.3`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.2.5`) | 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`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.2.3", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.2.5", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "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": "2.2.3",
3
+ "version": "2.2.5",
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",
@@ -338,8 +338,10 @@ sticker template and `--srt`/`--text` burns captions by running caption.py
338
338
  afterwards -- both as second processes, so neither of those code paths is
339
339
  re-implemented here. `--image` must be a readable local file: a URL is refused
340
340
  (`kind: input`), nothing is fetched, and the skill never invents cover art --
341
- give an image or a colour. Without any of these flags the command line is
342
- byte-identical to 1.15's. Every run's result carries an `audiogram` object (style,
341
+ give an image or a colour. The title and caption stages receive `--overwrite`, `--timeout`,
342
+ `--fast` and `--dry-run`, and a failed stage reports that tool's own error kind; a missing
343
+ `--srt`/`--text` file is refused (`kind: input`) before anything is encoded. Without any of
344
+ these flags the command line is byte-identical to 1.15's. Every run's result carries an `audiogram` object (style,
343
345
  background, image, position, vis_height, platform, captions, title, stages,
344
346
  verified). `render.py --template audiogram` is the one-call form; it is
345
347
  deliberately not part of `--template all`.
@@ -1053,6 +1055,13 @@ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt O
1053
1055
  [--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
1054
1056
  caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
1055
1057
  ```
1058
+ **Side files and `--overwrite` (2.2.4).** The files caption.py writes besides the
1059
+ video -- the `.srt` from `--text`/`--transcribe` (or `--write-srt`), the generated
1060
+ `.ass` (or `--write-ass`), `<output>_adjusted.srt`, `<output>_offset.ass` -- are
1061
+ refused like the video when they already exist, all named together, before speech
1062
+ recognition and before the first write, dry runs included; `--overwrite` replaces
1063
+ them. A hand-corrected transcript next to the output is never replaced silently.
1064
+
1056
1065
  **Caption margins (1.17.2).** `--margin` is the **vertical** distance from the
1057
1066
  edge, in ASS units against the 288-line script grid (default 30; with
1058
1067
  `--platform` it becomes that destination's `safe.top`/`safe.bottom`, e.g. 22 %
@@ -1388,6 +1397,9 @@ threshold ducks on quieter speech, a shorter release brings the bed back faster.
1388
1397
  `--json`'s `audio` block reports the settings the run actually used.
1389
1398
  `--effects FILE` mixes a third track (sound effects, atmos) at
1390
1399
  `--effects-volume` and is never ducked — effects are cut to the picture.
1400
+ Every `--music` / `--effects` / `--replace` file is checked before ffmpeg runs (under
1401
+ `--dry-run` too): missing, empty, unreadable or with no audio stream, all of them are named in
1402
+ one refusal (`kind: input`, `problems: [{flag, path, reason}]`).
1391
1403
  `--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
1392
1404
  needs a real stereo source: it scales the side signal (L−R), so a mono track
1393
1405
  duplicated to two channels has nothing to scale. A 1-channel input is refused
@@ -52,7 +52,7 @@ for _stream in (sys.stdout, sys.stderr):
52
52
  # demos/ or evals/ has ever reached for through `_common`.
53
53
 
54
54
  from _common.runner import (
55
- add_common, apply_common, _check_existing_output, _check_no_overwrite_input, _check_output_path, child_args,
55
+ add_common, apply_common, _check_existing_output, refuse_existing_outputs, _check_no_overwrite_input, _check_output_path, child_args,
56
56
  child_limit, _CHILDREN, _cleanup_partial_output, _cmdline, CODECS, Context, DEFAULT_TIMEOUT,
57
57
  _DRAWTEXT_PENDING, _DRAWTEXT_TMPDIR, _drawtext_tmpdir, dry_run_input_pending, _ENCODERS, _env_timeout,
58
58
  ERROR_CODE, ERROR_RETRYABLE, EVEN_SCALE, _execute, _fail, ffmpeg_base, ffmpeg_encoders, _FFMPEG_VERSION,
@@ -179,7 +179,7 @@ __all__ = [
179
179
  "aac_args", "add_common", "add_pad_fill_args", "ADVANCE_EM", "analyze_levels", "apply_common", "detect_scenes", "detect_silences", "SCORE_RE", "SIL_RE", "_aspect_string",
180
180
  "audio_codec_for", "AUDIO_CODECS", "BIDI_SCRIPTS", "_bit_depth", "brand_caption_style", "BRAND_DEFAULTS",
181
181
  "brand_states_font", "_brief", "_BRIEF_DROP", "_brief_summary", "bt709_tag_args", "cfr_args", "_char_em",
182
- "char_script", "_check_existing_output", "_check_no_overwrite_input", "_check_output_path", "child_args",
182
+ "char_script", "_check_existing_output", "refuse_existing_outputs", "_check_no_overwrite_input", "_check_output_path", "child_args",
183
183
  "child_limit", "_CHILDREN", "_cleanup_partial_output", "_cmdline", "CODECS", "color_hex", "_COLOR_TOKEN_RE",
184
184
  "concat_list_line", "Context", "_CURRENT_CTX", "db_to_linear", "decode_pcm_mono", "description_block", "_evidence_rank", "fmt_chapter_time", "propose_chapters",
185
185
  "filler_spans", "FILLER_WORDS", "FILLER_AMBIGUOUS", "FILLER_DISCOURSE_MARKERS",
@@ -542,21 +542,39 @@ def _check_existing_output(cmd: Sequence[str]) -> None:
542
542
  output = cmd[-1]
543
543
  if output in ("-",) or output.startswith("pipe:") or output.startswith("-"):
544
544
  return
545
- try:
546
- exists = os.path.isfile(output)
547
- real = os.path.realpath(output)
548
- except OSError:
549
- return
550
- if not exists or real in STATE.written:
551
- return
552
- try:
553
- st = os.stat(output)
554
- STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
555
- except OSError:
556
- pass
557
- if STATE.overwrite:
545
+ refuse_existing_outputs([output])
546
+
547
+
548
+ def refuse_existing_outputs(paths: Sequence[str]) -> None:
549
+ """_check_existing_output() for every file a run will write, named together, before the
550
+ first of them is written. A tool whose outputs are not all ffmpeg's last argument (caption.py's
551
+ .srt/.ass sidecars) calls this up front so a hand-edited sidecar is refused like the video
552
+ is, and a dry run predicts the same refusal."""
553
+ existing: List[str] = []
554
+ for output in paths:
555
+ if not output:
556
+ continue
557
+ try:
558
+ exists = os.path.isfile(output)
559
+ real = os.path.realpath(output)
560
+ except OSError:
561
+ continue
562
+ if not exists or real in STATE.written:
563
+ continue
564
+ try:
565
+ st = os.stat(output)
566
+ STATE.preexisting[real] = (st.st_size, st.st_mtime_ns)
567
+ except OSError:
568
+ pass
569
+ if output not in existing:
570
+ existing.append(output)
571
+ if not existing or STATE.overwrite:
558
572
  return
559
- die(f"refusing to overwrite existing output {output!r}: pass --overwrite to replace it, or choose another -o path",
573
+ if len(existing) == 1:
574
+ die(f"refusing to overwrite existing output {existing[0]!r}: pass --overwrite to replace it, or choose another -o path",
575
+ kind="input", hint="pass --overwrite, or choose another -o path")
576
+ die("refusing to overwrite existing outputs " + ", ".join(repr(p) for p in existing)
577
+ + ": pass --overwrite to replace them, or choose another -o path",
560
578
  kind="input", hint="pass --overwrite, or choose another -o path")
561
579
 
562
580
 
package/scripts/audio.py CHANGED
@@ -20,11 +20,12 @@ Examples:
20
20
  python3 audio.py talk.wav --compress --comp-threshold -20 --comp-ratio 4 --limit --limit-ceiling -1 -o talk_dyn.wav
21
21
  """
22
22
  import argparse
23
+ import os
23
24
  import sys
24
25
  from pathlib import Path
25
26
  from typing import Any, Dict, List
26
27
 
27
- from _common import STATE, add_common, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
28
+ from _common import STATE, add_common, dry_run_input_pending, require_tool, apply_common, audio_codec_for, db_to_linear, default_output, die, emit, ffmpeg_base, info, is_audio_output, probe, run, run_keeping_subtitles, fmt_secs
28
29
 
29
30
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
30
31
 
@@ -93,6 +94,41 @@ def dynamics_filter(name: str, args: argparse.Namespace) -> str:
93
94
  return name + ("=" + ":".join(opts) if opts else "")
94
95
 
95
96
 
97
+ def preflight_beds(args: argparse.Namespace) -> List[Dict[str, Any]]:
98
+ """Every problem with every --replace / --music / --effects file, found before anything runs:
99
+ missing, a directory, empty, unreadable, or readable but with no audio stream (a picture-only
100
+ clip given as a bed used to reach ffmpeg's `[1:a:0]` and fail there, as kind ffmpeg, one file
101
+ at a time). Under --dry-run a file that does not exist yet is an earlier stage's output and is
102
+ left to the plan, as join.py's preflight does; a real file is checked under --dry-run too."""
103
+ problems: List[Dict[str, Any]] = []
104
+ beds = [(f, v) for f, v in (("--replace", args.replace), ("--music", args.music),
105
+ ("--effects", args.effects)) if v]
106
+ if not beds:
107
+ return problems
108
+ ffprobe = require_tool("ffprobe")
109
+ for flag, path in beds:
110
+ if dry_run_input_pending(path):
111
+ continue
112
+ reason = None
113
+ if not os.path.exists(path):
114
+ reason = "missing"
115
+ elif os.path.isdir(path):
116
+ reason = "a directory, not a file"
117
+ elif os.path.getsize(path) == 0:
118
+ reason = "empty (0 bytes)"
119
+ else:
120
+ proc = run([ffprobe, "-v", "error", "-show_entries", "stream=codec_type", "-of", "csv=p=0", path],
121
+ quiet=True, check=False)
122
+ kinds = {line.strip() for line in (proc.stdout or "").splitlines() if line.strip()}
123
+ if proc.returncode != 0 or not kinds:
124
+ reason = "unreadable: " + ((proc.stderr or "").strip().splitlines() or ["no streams"])[-1]
125
+ elif "audio" not in kinds:
126
+ reason = "no audio stream"
127
+ if reason:
128
+ problems.append({"flag": flag, "path": path, "reason": reason})
129
+ return problems
130
+
131
+
96
132
  def main() -> int:
97
133
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
98
134
  ap.add_argument("input")
@@ -179,6 +215,13 @@ def main() -> int:
179
215
  if args.stereo_widen is not None and not (0.0 <= args.stereo_widen <= 1.0):
180
216
  die(f"--stereo-widen must be 0..1 (0 = untouched, 1 = maximum), got {args.stereo_widen:g}")
181
217
 
218
+ problems = preflight_beds(args)
219
+ if problems:
220
+ die(f"{len(problems)} of the extra audio files cannot be mixed: "
221
+ + "; ".join(f"{p['flag']} {p['path']}: {p['reason']}" for p in problems),
222
+ kind="input", problems=problems,
223
+ hint="each --music / --effects / --replace file must be a readable file with an audio stream")
224
+
182
225
  meta = probe(args.input)
183
226
  dur = meta.get("duration") or 0.0
184
227
  has_video = bool(meta.get("video"))
@@ -209,7 +252,6 @@ def main() -> int:
209
252
  main_src = f"0:a:{args.audio_stream}"
210
253
  idx = 1
211
254
  if args.replace:
212
- probe(args.replace)
213
255
  inputs += ["-i", args.replace]
214
256
  main_src = f"{idx}:a:0"
215
257
  idx += 1
@@ -251,7 +293,6 @@ def main() -> int:
251
293
  last = "main"
252
294
 
253
295
  if args.music:
254
- probe(args.music)
255
296
  if args.music_loop:
256
297
  inputs += ["-stream_loop", "-1", "-i", args.music]
257
298
  else:
@@ -276,7 +317,6 @@ def main() -> int:
276
317
  if args.effects:
277
318
  # A third bed, mixed in at its own level and deliberately never ducked: effects are cut
278
319
  # to the picture, so dipping them under speech would move them off their own frames.
279
- probe(args.effects)
280
320
  inputs += ["-i", args.effects]
281
321
  e = f"{idx}:a:0"
282
322
  idx += 1
@@ -44,7 +44,7 @@ from typing import Any, Dict, List, Optional, Tuple
44
44
  from _platforms import (PLATFORMS, PLATFORM_CHOICES, ASS_SCRIPT_HEIGHT, ass_units,
45
45
  resolve as resolve_platform)
46
46
  from _ass_overlay import EMOJI_SENTINEL, emoji_placeholder, ass_escape
47
- from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs
47
+ from _common import emoji_filter_chain, EMOJI_ASSET_HINT, emoji_asset_for, emoji_codepoint_name, emoji_support, resolve_emoji_assets, ADVANCE_EM, LATIN_EM, NO_SPACE_SCRIPTS, _char_em, char_script, text_width_em, emoji_clusters, has_emoji, detect_script, BIDI_SCRIPTS, STATE, brand_states_font, script_font_for_text, signed_time_arg, brand_caption_style, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die, fmt_secs, refuse_existing_outputs
48
48
  # The line breaker, lifted into _common/text.py in 1.16.0 so graphics.py can use the same rules.
49
49
  # The ASR bridge and the SRT reader/writer live in _common.asr since 1.17 (silence.py --filler
50
50
  # shares them). They stay caption.py's public names -- every caller and test that reached for
@@ -1155,9 +1155,38 @@ def main() -> int:
1155
1155
  die(f"{len(missing_srt)} SRT file(s) not found: " + ", ".join(m["path"] for m in missing_srt),
1156
1156
  kind="input", problems=missing_srt)
1157
1157
  srt_path = args.srt
1158
+ if args.transcribe and not args.input:
1159
+ die("--transcribe needs the input video")
1160
+ # 2.2.4: every file this run writes -- the video AND the .srt/.ass sidecars -- is checked
1161
+ # against --overwrite here, before speech recognition and before the first write, dry runs
1162
+ # included. Through 2.2.3 only ffmpeg's last argument was: a hand-corrected transcript or ASS
1163
+ # next to the output was replaced on a re-run, even one ffmpeg's check then refused.
1164
+ # Side files whose existence depends on the cues (an _adjusted.srt, an emoji-forced ASS) are
1165
+ # checked again where they are written, still before that write.
1166
+ claimed: set = set()
1167
+
1168
+ def claim(paths) -> None:
1169
+ todo = [p for p in paths if p and os.path.realpath(p) not in claimed]
1170
+ refuse_existing_outputs(todo)
1171
+ claimed.update(os.path.realpath(p) for p in todo)
1172
+
1173
+ planned_out = args.output or (default_output(args.input, "captioned") if args.input else None)
1174
+ up_front: List[str] = []
1175
+ if args.transcribe:
1176
+ up_front.append(args.write_srt or os.path.splitext(planned_out)[0] + ".srt")
1177
+ elif args.text:
1178
+ up_front.append(args.write_srt or (os.path.splitext(planned_out)[0] + ".srt" if args.input
1179
+ else os.path.splitext(args.text)[0] + ".srt"))
1180
+ if args.input:
1181
+ if (args.animate != "none" or args.karaoke) and not args.ass:
1182
+ up_front.append(args.write_ass or os.path.splitext(planned_out)[0] + ".ass")
1183
+ if args.ass and args.offset and os.path.exists(args.ass):
1184
+ up_front.append(os.path.splitext(planned_out)[0] + "_offset.ass")
1185
+ # -o naming the input itself keeps its own, more specific refusal (_check_no_overwrite_input)
1186
+ if os.path.realpath(planned_out) != os.path.realpath(args.input):
1187
+ up_front.append(planned_out)
1188
+ claim(up_front)
1158
1189
  if args.transcribe:
1159
- if not args.input:
1160
- die("--transcribe needs the input video")
1161
1190
  # the sidecar goes next to the output like the --text one, not into the source folder
1162
1191
  # where it silently replaced a hand-written <input>.srt (review 5)
1163
1192
  srt_path = args.write_srt or os.path.splitext(args.output or default_output(args.input, "captioned"))[0] + ".srt"
@@ -1165,8 +1194,6 @@ def main() -> int:
1165
1194
  cues = []
1166
1195
  info(f"[dry-run] would transcribe {args.input} and write {srt_path}")
1167
1196
  else:
1168
- if os.path.exists(srt_path) and not getattr(args, "overwrite", False):
1169
- info(f"warning: {srt_path} already exists and will be replaced by the transcript (pass --overwrite to confirm)")
1170
1197
  cues = transcribe(args.input, srt_path, args.language, args.model, args.audio_stream)
1171
1198
  args._word_timings = whisper_word_timings(srt_path)
1172
1199
  cues, changed = lay_out(cues)
@@ -1211,6 +1238,7 @@ def main() -> int:
1211
1238
  adjusted, changed = lay_out(parse_srt(srt_path))
1212
1239
  if changed:
1213
1240
  new_srt = os.path.splitext(output)[0] + "_adjusted.srt"
1241
+ claim([new_srt])
1214
1242
  if STATE.dry_run:
1215
1243
  info(f"[dry-run] would write {new_srt} ({len(adjusted)} cues, adjusted from {os.path.basename(srt_path)})")
1216
1244
  else:
@@ -1393,6 +1421,7 @@ def main() -> int:
1393
1421
  if args.karaoke and not getattr(args, "_word_timings", None):
1394
1422
  args._word_timings = whisper_word_timings(srt_path)
1395
1423
  ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
1424
+ claim([ass_path])
1396
1425
  w, h = meta["video"]["width"], meta["video"]["height"]
1397
1426
  if meta["video"].get("rotation") in (90, -90, 270, -270):
1398
1427
  w, h = h, w
package/scripts/render.py CHANGED
@@ -64,7 +64,7 @@ from typing import Any, Dict, List, Optional, Sequence
64
64
 
65
65
  from export import PRESETS, PLATFORM_OF
66
66
  from _platforms import PLATFORMS, caption_defaults, resolve as resolve_platform
67
- from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
67
+ from _common import STATE, add_common, aspect_ratio, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, require_tool, run, run_tool, place_output, refuse_output_is_input, _check_existing_output, _check_output_path, fingerprint, PLAN_VERSION, ffmpeg_version
68
68
  import subprocess
69
69
  from _contract import CONTRACT_VERSION
70
70
  from batch import file_key
@@ -1010,14 +1010,21 @@ def main() -> int:
1010
1010
  problems.append({"index": i, "path": src, "reason": "a directory, not a file"})
1011
1011
  elif os.path.getsize(src) == 0:
1012
1012
  problems.append({"index": i, "path": src, "reason": "empty (0 bytes)"})
1013
+ elif not STATE.dry_run:
1014
+ # 2.2.4: readable, too -- one ffprobe per source, nothing decoded, the same test
1015
+ # join.py's preflight makes. Before, a real run probed the sources one by one and
1016
+ # stopped at the first unreadable file; the dry run already named every one via join.
1017
+ proc = run([require_tool("ffprobe"), "-v", "error", "-show_entries", "stream=codec_type",
1018
+ "-of", "csv=p=0", src], quiet=True, check=False)
1019
+ kinds = {line.strip() for line in (proc.stdout or "").splitlines() if line.strip()}
1020
+ if proc.returncode != 0 or not kinds & {"audio", "video"}:
1021
+ first = ((proc.stderr or "").strip().splitlines() or ["no audio or video stream"])[-1]
1022
+ problems.append({"index": i, "path": src, "reason": f"unreadable: {first}"})
1013
1023
  if problems:
1014
1024
  die(f"{len(problems)} of {len(clips)} clip sources unusable: "
1015
1025
  + "; ".join(f"clip {p['index']}: {p['reason']}: {p['path']}" for p in problems),
1016
1026
  kind="input", problems=problems,
1017
1027
  hint="fix or remove these clips in the project; nothing was cut")
1018
- if not STATE.dry_run:
1019
- for c in clips:
1020
- probe(rel(c["src"])) # an unreadable source stops the run before any clip is cut
1021
1028
 
1022
1029
  parts: List[str] = []
1023
1030
  for i, c in enumerate(clips):
package/scripts/sync.py CHANGED
@@ -186,7 +186,11 @@ def measure_offset(ref_path: str, oth_path: str, start: float, seconds: float, s
186
186
  ref = envelope(ref_s, step)
187
187
  oth = envelope(oth_s, step)
188
188
  if len(ref) < 10 or len(oth) < 10:
189
- die("not enough audio to analyse")
189
+ # name the file (or both): sync.py and multicam.py measure several sources, and a bare
190
+ # "not enough audio" left the caller to guess which one was too short
191
+ short = [p for p, env in ((ref_path, ref), (oth_path, oth)) if len(env) < 10]
192
+ die(f"not enough audio to analyse in {' and '.join(short)} "
193
+ f"(needs at least {10 * step_ms / 1000:g} s of audio from {start:g} s)", kind="input")
190
194
  max_lag = int(max_offset * SR / step)
191
195
  lag, score = cross_correlate(ref, oth, max_lag)
192
196
  offset = lag * step / SR
@@ -30,14 +30,14 @@ invented: give an image or a colour.
30
30
  python3 waveform.py ep.m4a --image cover.png --srt ep.srt --title "Episode 12" -o ep.mp4
31
31
  """
32
32
  import argparse
33
+ import json
33
34
  import os
34
- import subprocess
35
35
  import sys
36
36
 
37
37
  from _platforms import PLATFORMS, PLATFORM_CHOICES, resolve as resolve_platform
38
38
  from _common import (add_common, apply_common, aac_args, default_output, die, emit, ffmpeg_base,
39
39
  info, load_brand, pad_filters, probe, run, STATE, validate_color, video_args, X264_PRESETS,
40
- fmt_secs)
40
+ fmt_secs, child_args, run_tool)
41
41
 
42
42
  WAVEFORM_MODES = ["point", "line", "p2p", "cline"]
43
43
 
@@ -114,6 +114,11 @@ def main() -> int:
114
114
  die(f"--vis-height must be between 0 and 1, got {args.vis_height:g}")
115
115
  if args.srt and args.text:
116
116
  die("--srt and --text exclude each other (both name the cues to burn)")
117
+ # caption.py would refuse a missing cue file too, but only after the visualisation had been
118
+ # encoded: check it here, before any encode, and name the flag the caller gave
119
+ for flag, path in (("--srt", args.srt), ("--text", args.text)):
120
+ if path and not os.path.isfile(path):
121
+ die(f"{flag} file not found: {path}", kind="input")
117
122
 
118
123
  if args.width <= 0 or args.height <= 0:
119
124
  die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
@@ -193,30 +198,40 @@ def main() -> int:
193
198
  if meta.get("duration"):
194
199
  cmd += ["-t", f"{float(meta['duration']):.3f}"]
195
200
  cmd += ["-shortest", render]
196
- run(cmd)
201
+ stem, ext = os.path.splitext(output)
202
+ intermediates = [p for p in (render, stem + "_titled" + ext) if p != output]
203
+ try:
204
+ run(cmd)
197
205
 
198
- stages = ["waveform"]
199
- current = render
200
- if args.title:
201
- stem, ext = os.path.splitext(output)
202
- titled = stem + "_titled" + ext if (args.srt or args.text) else output
203
- _child("graphics.py", [current, "--template", "sticker", "--text", args.title,
204
- "--position", "top-left", "-o", titled]
205
- + (["--brand", args.brand] if args.brand else []))
206
- stages.append("title")
207
- current = titled
208
- if args.srt or args.text:
209
- _child("caption.py", [current] + (["--srt", args.srt] if args.srt else ["--text", args.text])
210
- + (["--platform", args.platform] if args.platform else []) + ["-o", output])
211
- stages.append("captions")
212
- current = output
213
- if current != output and not STATE.dry_run:
214
- os.replace(current, output)
215
- current = output
216
- # the intermediates exist only to keep each tool's own code path the only one there is
217
- for temp in (render, os.path.splitext(output)[0] + "_titled" + os.path.splitext(output)[1]):
218
- if temp != output and os.path.exists(temp) and not STATE.dry_run:
219
- os.remove(temp)
206
+ stages = ["waveform"]
207
+ current = render
208
+ if args.title:
209
+ titled = stem + "_titled" + ext if (args.srt or args.text) else output
210
+ # a dry run rendered no _vis file for graphics.py to probe, so its default --end (the
211
+ # clip's end) came out 0 and the plan failed: give it the source's duration, which
212
+ # is what -t caps the render at. A real run is unchanged.
213
+ end = (["--end", f"{float(meta['duration']):.3f}"]
214
+ if STATE.dry_run and meta.get("duration") else [])
215
+ _child("graphics.py", [current, "--template", "sticker", "--text", args.title,
216
+ "--position", "top-left"] + end + ["-o", titled]
217
+ + (["--brand", args.brand] if args.brand else []))
218
+ stages.append("title")
219
+ current = titled
220
+ if args.srt or args.text:
221
+ _child("caption.py", [current] + (["--srt", args.srt] if args.srt else ["--text", args.text])
222
+ + (["--platform", args.platform] if args.platform else []) + ["-o", output])
223
+ stages.append("captions")
224
+ current = output
225
+ if current != output and not STATE.dry_run:
226
+ os.replace(current, output)
227
+ current = output
228
+ finally:
229
+ # the intermediates exist only to keep each tool's own code path the only one there is;
230
+ # a failed stage removes them too, not only a finished chain
231
+ if not STATE.dry_run:
232
+ for temp in intermediates:
233
+ if os.path.exists(temp):
234
+ os.remove(temp)
220
235
 
221
236
  result = probe(output, role="output")
222
237
  v = result["video"] or {}
@@ -268,16 +283,24 @@ def main() -> int:
268
283
 
269
284
  def _child(script_name: str, argv: "list") -> None:
270
285
  """Run one of this skill's own tools as a second process, so the code path it owns (the ASS
271
- generator, the drawtext template) stays the only one there is."""
286
+ generator, the drawtext template) stays the only one there is. The shared flags (--overwrite,
287
+ --timeout, --fast, --dry-run) reach it through child_args(), as render.py's stages get them,
288
+ and a failure is re-raised with the child's own kind, exit code and hint."""
272
289
  here = os.path.dirname(os.path.abspath(__file__))
273
- cmd = [sys.executable, os.path.join(here, script_name)] + [str(a) for a in argv]
274
- if STATE.dry_run:
275
- cmd.append("--dry-run")
276
- info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[1:]))
277
- proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
290
+ cmd = [os.path.join(here, script_name)] + [str(a) for a in argv] + child_args() + ["--json"]
291
+ info("-> " + " ".join(os.path.basename(c) if c.endswith(".py") else str(c) for c in cmd[:-1]))
292
+ proc = run_tool(cmd)
278
293
  if proc.returncode != 0:
279
- die(f"{script_name} failed:\n{(proc.stderr or proc.stdout).strip()[-800:]}", kind="ffmpeg")
280
-
294
+ try:
295
+ doc = json.loads(proc.stdout.strip() or "{}")
296
+ except ValueError:
297
+ doc = {}
298
+ if not isinstance(doc, dict):
299
+ doc = {}
300
+ err = doc.get("error") or {}
301
+ extra = {"hint": err["hint"]} if err.get("hint") else {}
302
+ die(f"{script_name} failed: {err.get('message') or (proc.stderr.strip().splitlines() or ['?'])[-1][:300]}",
303
+ code=int(doc.get("exit_code") or 1), kind=err.get("kind") or "input", stage=script_name, **extra)
281
304
 
282
305
  if __name__ == "__main__":
283
306
  sys.exit(main())