ffmpeg-skill 2.2.2 → 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.2`) | 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.2", "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"},
@@ -492,6 +492,12 @@ Per-tool keys added in 1.18.0, all additive:
492
492
  | `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
493
493
  | `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
494
494
 
495
+ Per-tool keys added after 2.2.2, all additive:
496
+
497
+ | key | tool | what it holds |
498
+ |---|---|---|
499
+ | `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
500
+
495
501
  `check.py` also gains an informational `subtitles` row on **every** platform:
496
502
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
497
503
  does not (or when there are none). Like `channels` and `chapters` it is never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.2.2",
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`.
@@ -573,7 +575,11 @@ frame, rate or xfade offsets that rest on a pending input's unmeasured placehold
573
575
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
574
576
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
575
577
  silent track generated for clips without audio), then chains `xfade` +
576
- `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
578
+ `acrossfade`. With a transition each clip has one length for picture and sound
579
+ -- the video stream's, or the audio's when it runs more than a frame longer --
580
+ and the shorter stream is padded (silence / last frame held), so every clip's
581
+ sound stays with its picture. Output length = sum of clip lengths −
582
+ transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
577
583
  longer than 2 × the transition. Use `--transition none` for a plain cut.
578
584
  A subtitle/data track in the source is not carried into the retimed/concatenated
579
585
  output; the result says so with `dropped_non_av_streams: true`.
@@ -1049,6 +1055,13 @@ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt O
1049
1055
  [--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
1050
1056
  caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
1051
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
+
1052
1065
  **Caption margins (1.17.2).** `--margin` is the **vertical** distance from the
1053
1066
  edge, in ASS units against the 288-line script grid (default 30; with
1054
1067
  `--platform` it becomes that destination's `safe.top`/`safe.bottom`, e.g. 22 %
@@ -1384,6 +1397,9 @@ threshold ducks on quieter speech, a shorter release brings the bed back faster.
1384
1397
  `--json`'s `audio` block reports the settings the run actually used.
1385
1398
  `--effects FILE` mixes a third track (sound effects, atmos) at
1386
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}]`).
1387
1403
  `--stereo-widen 0..1` widens the stereo image (`extrastereo=m=1+2*amount`) and
1388
1404
  needs a real stereo source: it scales the side signal (L−R), so a mono track
1389
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",
@@ -283,6 +283,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
283
283
  "color_range": video.get("color_range"),
284
284
  "rotation": rotation,
285
285
  "nb_frames": _to_int(video.get("nb_frames")),
286
+ # the stream's own length (a container's also counts audio priming and a longer track)
287
+ "duration": _to_float(video.get("duration")),
286
288
  "bitrate": _to_int(video.get("bit_rate")),
287
289
  }
288
290
  if audio:
@@ -292,6 +294,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
292
294
  "channel_layout": audio.get("channel_layout"),
293
295
  "sample_rate": _to_int(audio.get("sample_rate")),
294
296
  "bitrate": _to_int(audio.get("bit_rate")),
297
+ "duration": _to_float(audio.get("duration")),
295
298
  }
296
299
  # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
297
300
  out["audio_streams"] = [{
@@ -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
 
@@ -443,6 +443,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
443
443
  extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
444
444
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
445
445
  "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
446
+ elif name == "color":
447
+ extra = {"dropped_non_av_streams": {"type": "boolean"},
448
+ "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
449
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
450
+ "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
446
451
  elif name == "cut":
447
452
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
448
453
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
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/color.py CHANGED
@@ -169,12 +169,36 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
169
169
  return ",".join(chain)
170
170
 
171
171
 
172
+ def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
173
+ """Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
174
+ true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
175
+ its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
176
+ white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
177
+ as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
178
+ v = meta["video"]
179
+ trc = v.get("color_transfer") or "bt709"
180
+ space = v.get("color_space") or "bt2020nc"
181
+ return ",".join([
182
+ f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
183
+ "format=gbrpf32le",
184
+ "zscale=p=bt709",
185
+ "zscale=t=bt709:m=bt709:r=tv",
186
+ "format=yuv420p",
187
+ ])
188
+
189
+
190
+ def is_bt2020_sdr(v: dict) -> bool:
191
+ """BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
192
+ return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
193
+ and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
194
+
195
+
172
196
  def main() -> int:
173
197
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
174
198
  ap.add_argument("input")
175
199
  ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
176
200
  mode = ap.add_mutually_exclusive_group(required=True)
177
- mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
201
+ mode.add_argument("--to-sdr", action="store_true", help="convert to SDR BT.709: tone-map HDR (PQ/HLG/Dolby Vision); BT.2020 primaries on an SDR transfer get a gamut conversion only, no tone map")
178
202
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
179
203
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
180
204
  mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
@@ -183,7 +207,7 @@ def main() -> int:
183
207
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
184
208
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
185
209
  ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
186
- ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
210
+ ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
187
211
  ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
188
212
  ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
189
213
  ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
@@ -278,10 +302,21 @@ def main() -> int:
278
302
  return 0
279
303
 
280
304
  measurements = None
305
+ sdr_extra: dict = {}
281
306
  if args.to_sdr:
282
307
  if not v.get("bt2020_or_hdr") and not args.force:
283
308
  die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
284
- vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
309
+ if is_bt2020_sdr(v):
310
+ vf = bt2020_sdr_to_bt709_chain(meta)
311
+ sdr_path = "gamut"
312
+ note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
313
+ f"without a tone map (--tonemap/--peak/--desat do not apply)")
314
+ else:
315
+ vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
316
+ sdr_path = "tonemap"
317
+ note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
318
+ info(f"note: {note}")
319
+ sdr_extra = {"sdr_path": sdr_path, "notes": [note]}
285
320
  output = args.output or default_output(args.input, "sdr")
286
321
  tag = "sdr"
287
322
  elif args.correct:
@@ -323,7 +358,7 @@ def main() -> int:
323
358
  r = probe(output, role="output")
324
359
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
325
360
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
326
- extra = {"dropped_non_av_streams": dropped_streams}
361
+ extra = {"dropped_non_av_streams": dropped_streams, **sdr_extra}
327
362
  if measurements is not None:
328
363
  measurements["output"] = analyze_levels(output)
329
364
  extra["measurements"] = measurements
package/scripts/join.py CHANGED
@@ -63,6 +63,20 @@ def unpending_length(durs: List[float], d: float) -> Optional[float]:
63
63
  return round(sum(durs) - d * (len(durs) - 1), 3)
64
64
 
65
65
 
66
+ def clip_length(meta: Dict[str, Any], fps: float) -> float:
67
+ """The one length a clip holds in a crossfaded join, for its picture and its sound alike: the
68
+ video stream's, or the audio stream's when the sound runs more than a frame past the picture
69
+ (narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
70
+ a longer track) is only the fallback when a stream length is unknown (a pending input)."""
71
+ v = (meta.get("video") or {}).get("duration")
72
+ a = (meta.get("audio") or {}).get("duration")
73
+ if not v:
74
+ return meta.get("duration") or 0.0
75
+ if a and fps and a > v + 1.0 / fps:
76
+ return a
77
+ return v
78
+
79
+
66
80
  def expected_text(expected: Optional[float]) -> str:
67
81
  """unpending_length() as the stderr summary line puts it."""
68
82
  return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
@@ -293,7 +307,10 @@ def main() -> int:
293
307
  w, h = w - (w % 2), h - (h % 2)
294
308
  durs = [m.get("duration") or 0.0 for m in metas]
295
309
  d = args.duration if args.transition != "none" else 0.0
296
- for p, dur in zip(args.inputs, durs):
310
+ # a crossfaded join gives each clip one length for both streams, so the offsets that place
311
+ # every picture also place its sound; the plain cut (concat) keeps the streams paired itself
312
+ lens = [clip_length(m, fps) for m in metas] if d else durs
313
+ for p, dur in zip(args.inputs, lens):
297
314
  if d and dur <= d * 2 and not STATE.dry_run:
298
315
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
299
316
 
@@ -347,14 +364,22 @@ def main() -> int:
347
364
  chain = "".join(f"[v{i}][a{i}]" for i in range(n))
348
365
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
349
366
  else:
350
- vprev, aprev = "v0", "a0"
367
+ # pad the shorter stream to the clip's length: the audio with silence, the picture by
368
+ # holding its last frame, then cut both there -- each xfade and acrossfade then starts
369
+ # at the same clip boundary instead of the audio chain drifting by every clip's gap
370
+ for i in range(n):
371
+ vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
372
+ hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
373
+ parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
374
+ parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
375
+ vprev, aprev = "vp0", "ap0"
351
376
  offset = 0.0
352
377
  for i in range(1, n):
353
- offset += durs[i - 1] - d
378
+ offset += lens[i - 1] - d
354
379
  vout = f"vx{i}" if i < n - 1 else "vout"
355
380
  aout = f"ax{i}" if i < n - 1 else "aout"
356
- parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
357
- parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
381
+ parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
382
+ parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
358
383
  vprev, aprev = vout, aout
359
384
 
360
385
  # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
@@ -367,12 +392,26 @@ def main() -> int:
367
392
  "by its measured length")
368
393
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
369
394
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
395
+ if d:
396
+ # the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
397
+ # 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
398
+ # dissolve came out 52), which newer builds no longer do -- pass the frames through as built
399
+ cmd += ["-fps_mode", "passthrough"]
370
400
  cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
371
401
  run(cmd)
372
- expected = unpending_length(durs, d)
402
+ expected = unpending_length(lens, d)
373
403
  r = probe(output, role="output")
374
- info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
404
+ # the picture is what the offsets place: measure the video stream, not the container (whose
405
+ # length also counts the AAC tail), against the length the clips add up to
406
+ measured = (r.get("video") or {}).get("duration") or r.get("duration")
407
+ verification = []
408
+ if expected is not None and measured is not None and not STATE.dry_run:
409
+ tol = max(1.5 / fps, 0.05)
410
+ verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
411
+ "measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
412
+ info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
375
413
  emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
414
+ verification=verification,
376
415
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
377
416
  return 0
378
417
 
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())