ffmpeg-skill 2.2.3 → 2.2.4

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/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.4`) | 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.4", "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.4",
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())