ffmpeg-skill 1.17.0 → 1.17.2

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
@@ -117,7 +117,11 @@ python3 $S/render.py talk.mp4 --template tiktok --cues cues.txt
117
117
 
118
118
  That fills the shipped `templates/tiktok.json`: 9:16 crop, captions popped word by word *above*
119
119
  TikTok's description bar and clear of its like column, −14 LUFS, the `tiktok` export preset, and
120
- a `check.py --platform tiktok` on the file it wrote. Templates ship for `tiktok`, `reels`,
120
+ a `check.py --platform tiktok` on the file it wrote. The caption size comes from the delivery
121
+ table, so since 1.17.1 the filled project also states `"fit_size": "on"`: a size nobody asked for
122
+ shrinks to fit the cue instead of splitting the sentence across two cues. A template that states
123
+ its own `fit_size`, and a `--brand` that states a caption size, still win, and a project with
124
+ `"fit_size": "off"` renders the captions 1.17.0 rendered. Templates ship for `tiktok`, `reels`,
121
125
  `shorts`, `youtube-shorts`, `youtube`, `x`, `linkedin`, `facebook` and `podcast`;
122
126
  `--template all` (or a comma-separated list) renders every destination from the same edit and
123
127
  writes a `<name>_pack.md` table of what each one produced. Files land next to the input unless
@@ -179,7 +183,8 @@ These are the rules the skill file gives the agent and the code enforces.
179
183
  6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
180
184
  7. **Unknown is not missing.** When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are `unknown`: never `missing`, never silently `available`. An installed filter is not reported absent; a failed detection is not a pass.
181
185
  8. **Verify the result.** The output is probed, and when the picture changed (captions, overlays, crops, colour, transitions) the agent runs `look.py` and inspects the PNG. The report is not finished until its `Look:` line names that image; audio-only jobs say `Look: not needed`. **"Inspects" means the calling agent's own vision, not a feature of this skill:** `look.py` only renders a PNG; nothing in this repository detects faces, products, subjects, or "the interesting part" of a frame or a scene. When a crop or reframe needs to keep a specific part of the frame (`fit.py --fit crop --crop-x/-y`, see [Tools](#tools)), it is the multimodal agent looking at that PNG and choosing the anchor — a non-visual caller (a script, a CLI user without eyes on the sheet) has to supply that decision itself, and the default is a plain centre crop. Likewise `scenes.py --highlights` ranks candidate scenes by a measured proxy (`--rank-by audio` or `--rank-by duration`), never by content; it is the agent that turns a look at the sheet into a judgement.
182
- 9. **Keep originals.** No tool overwrites its input. Outputs are new files named `<input>_<operation>.<ext>` unless told otherwise, and a test hashes every input after the run.
186
+ 9. **One label per report.** A finished job is `Done:`, a failure or a refusal is `Failed:`, and a partial result is `Done:` with the shortfall named in `Notes:` — never a third label such as `Done (partially):`.
187
+ 10. **Keep originals.** No tool overwrites its input. Outputs are new files named `<input>_<operation>.<ext>` unless told otherwise, and a test hashes every input after the run.
183
188
 
184
189
  ## Tools
185
190
 
@@ -234,7 +239,7 @@ These are the rules the skill file gives the agent and the code enforces.
234
239
 
235
240
  | Tool | What it does |
236
241
  |---|---|
237
- | `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area with a phrase-aware breaker (`--wrap phrase|measured`) and `--max-lines`/`--min-duration`/`--offset`; `--mode mux` takes a repeated `--srt file:lang` for several language-tagged, toggleable tracks in one file; picks a font by script for non-Latin text (`--lang`); animated and word-by-word karaoke timed to the speech energy or real word timings; `--fit-size` shrinks the size until a cue fits `--max-lines` instead of splitting the sentence; optional local transcription |
242
+ | `caption.py` | Burn SRT/ASS with font, size, colour, outline, position; build SRT from timed plain text; wraps to the safe area with `--platform`, the destination's own left/right safe zone, which since 1.17.2 is also what the burnt ASS states as its side margins — with a phrase-aware breaker (`--wrap phrase|measured`) and `--max-lines`/`--min-duration`/`--offset`; `--mode mux` takes a repeated `--srt file:lang` for several language-tagged, toggleable tracks in one file; picks a font by script for non-Latin text (`--lang`); animated and word-by-word karaoke timed to the speech energy or real word timings; `--fit-size` shrinks the size until a cue fits `--max-lines` instead of splitting the sentence (on by default on the delivery-template path since 1.17.1, where the size comes from the platform table); says `caption text unchanged` when it burned the cues exactly as given; optional local transcription |
238
243
  | `overlay.py` | Logos, watermarks and titles with position, time range, opacity, fades; `--platform NAME` keeps them clear of that destination's UI; `--video` for picture-in-picture, `--chromakey` for green-screen compositing |
239
244
  | `graphics.py` | Lower-thirds, title cards, chapter chips, progress bars, countdowns, corner bugs, social stickers, opening hook cards and meme captions drawn by FFmpeg from a brand kit; `--platform NAME` keeps them inside that destination's safe zone; `--text-render` routes shaping scripts through libass and `--emoji-assets` composites colour emoji |
240
245
  | `color.py` | HDR10 / HLG / Dolby Vision → SDR BT.709 tone mapping, DV layer stripping, 3D LUT (.cube), colour-tag rewriting, typed primary correction (exposure/contrast/saturation/gamma/white balance/lift-gain/levels/curves) |
@@ -252,7 +257,7 @@ These are the rules the skill file gives the agent and the code enforces.
252
257
 
253
258
  | Tool | What it does |
254
259
  |---|---|
255
- | `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); `--template NAME INPUT` renders a shipped delivery template (`--template all` writes the whole social pack plus its table) |
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) |
256
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` |
257
262
  | `multicam.py` | Align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list |
258
263
  | `verify.py` | Run the toolchain on real device files and report PASS / FAIL per step |
@@ -362,7 +367,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
362
367
  - **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
363
368
  - **Cropping 16:9 to 9:16 discards 70 % of the width.** `fit.py --fit crop` centres by default; pass `--crop-x`/`--crop-y` toward the subject, or pad with `--fit pad --pad-fill blur`. Look at the contact sheet before deciding.
364
369
  - **Phrase-aware caption breaking (1.16).** `caption.py`/`graphics.py --wrap phrase` (the default) never breaks inside a word or on the wrong side of a hyphen, never leaves a lone digit, kana or punctuation pair on a line, prefers Japanese sentence ends and particles over a mid-word break, and never ends a line on an article or preposition. All four are penalties over break positions that already fit, so no line is widened and the line count never changes; `--wrap measured` restores 1.15's width-only wrap. The text itself is never rewritten or shortened. Since 1.16.1 a Thai run and a katakana word are never broken inside (Thai writes no space inside a phrase and there is no dictionary: the break goes where you put a space or `|`), and a line wider than the safe width is reported as `overlong` with the fix named.
365
- - **Caption size fitted to the cue (1.17).** At a platform caption size a line holds about six em, so an ordinary sentence needs four lines and `--max-lines 2` used to cut it into consecutive cues — half the sentence arriving late. `caption.py --fit-size` (default `auto`) now walks the size down until every cue fits, *before* laying the cues out, with a legibility floor of 4.5 % of the frame height (`--min-size`, default 13 ASS units). `--fit-size off` is 1.16 byte for byte, an explicit `--size` or a `brand.json` size is never overridden, and the caption text is still never rewritten to make it fit.
370
+ - **Caption size fitted to the cue (1.17, reachable from the templates since 1.17.1).** At a platform caption size a line holds about six em, so an ordinary sentence needs four lines and `--max-lines 2` used to cut it into consecutive cues — half the sentence arriving late. `caption.py --fit-size` (default `auto`) walks the size down until every cue fits, *before* laying the cues out, with a legibility floor of 4.5 % of the frame height (`--min-size`, default 13 ASS units). An explicit `--size` or a `brand.json` size is a statement about the look and is never overridden — which in 1.17.0 also silenced the fitter on every `render.py --template` run, since a template fills the size from the platform table; 1.17.1 marks that size as the default it is (`"fit_size": "on"` in the filled project), so the type shrinks and no cue is split. `--fit-size off` restores that earlier behaviour byte for byte, and the caption text is still never rewritten to make it fit.
366
371
  - **Beat-synced cuts (1.17).** `scenes.py --beats` reports the measured grid — tempo, beat times, and a confidence built from how far the winning autocorrelation lag stands above the others and how many onsets land on it. `cut.py --snap beats` (and a `"snap"` block in a `render.py` project) moves in/out points to the nearest beat within `--snap-tolerance` — and only onto the grid points a measured onset actually marks, never onto the regular grid's continuation through a passage with no music in it. Below `--min-confidence` it **refuses**: a cut point may move to a measured beat and may not appear from one, so speech and ambience get an honest "no steady pulse here" instead of an invented grid.
367
372
  - **Filler words (1.17).** `silence.py --filler --words transcript.json` removes "um" and "uh" through the same cut graph the silences use. Never without measured word timings — there is no heuristic that finds an "um" without them that would not also cut real speech — and `like`, `tipo` and `cioè` are deliberately not in the built-in lists, because a discourse marker is a content word. Whisper stays optional: `--transcribe` with no engine installed refuses and names the three installs.
368
373
  - **Throughput (1.17).** `batch.py --jobs N` runs several files at once, capped at `min(N, cpu_count, 8)` and sharing one `--timeout` budget rather than one per item; the per-item table keeps its order. `render.py --cache DIR` reuses stages whose inputs and arguments did not change, so swapping an export preset re-runs export only. The cache is opt-in with no default directory, and the ffmpeg, skill and contract versions are part of every key, so a cache is never reused across them.
@@ -411,6 +416,8 @@ type on every OS.
411
416
  | **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
412
417
  | **exact to the sample** | `cut.py --accurate` on WAV, FLAC (44.1 kHz) and AAC → WAV; WAV stream copy within 2 ms; AAC output +21 ms of encoder priming, reported as `codec_frame` (0.9.1) |
413
418
  | **72 / 72** | agent runs of 24 prompts (12 English edits, 8 Japanese, 4 that must be declined), three repeats, graded by an independent model: routing, honest refusals and user's language 72/72, report format 71/72, visual check whenever the picture changed 24/24 (0.8.4) |
419
+ | **26 / 26** | 1.17.1 run (2026-09-14, targeted re-run of the 18 prompts eval 18's follow-up named, plus three repeats each of the four caption-size prompts, Sonnet agent, regex grader + a full Opus grader over all 26 runs, every PNG opened and every written output re-probed): routing 23/26, honest refusals and failures 24/26 with 0 false successes and 0 raw ffmpeg calls, report format 26/26 with the third label gone, user's language 26/26, trigger set 50/50, Opus quality mean 3.65. 1.17.1's fix holds — `--fit-size` now fires on the `render.py --template` path in 12/12 caption runs (24 → 16, `dl4` to the 13-unit floor, `split` 0, `text_unchanged` true, identical across repeats), the beat, filler and `--jobs` prompts route on the first try, and `bt2` quotes its measured 0.184 confidence instead of denying the capability exists. The honest part: the picture is unchanged. `caption.py`'s `write_ass` writes the platform's *vertical* safe margin into `MarginL`, `MarginR` and `MarginV` alike (tiktok 63 ASS units → 420 px), so at `PlayResX` 1080 the text column is 240 px and libass wraps every word — the fitter budgets `play_w × 0.9`, which is why `split: 0` is true of the ASS text and false of the frame. It is the `--animate`/`--karaoke` path only, present since 1.14, and it explains eval 17's and eval 18's "one word per line" too; 1.17.2 is the patch and the finding is written up in `evals/results/iteration-19.json` |
420
+ | **100 / 100** | 1.17.0 run (2026-09-14, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 28 runs, every written output re-probed, `check.py` re-run on every delivery output) on the set grown to 100 prompts (caption size fitting, beat-synced cuts, filler removal, batch `--jobs`, render `--cache`): routing 95% over the 64 act prompts, honest refusals and failures 22/25 with 0 false successes and 0 raw ffmpeg calls, report format 98/100 (two runs label an honest partial result with a third label), user's language 100/100 across seventeen languages, visual check 24/24, real execution 6/6 with honest failure 5/5, trigger set 50/50 including all five new 1.17 prompts, Opus quality mean 3.71. The honest part: `--fit-size` is unreachable on the template path (`render.py` forwards the platform table's caption size as an explicit `--size`, so the fitter declines to shrink a size it thinks the user chose, and the project schema rejects `fit_size` outright — only the one run that called `caption.py` by hand got 24 → 16, `split` 0), and SKILL.md names none of the 1.17 features, so beats, filler and `--cache` were each used in one run at most — 1.17.1 is the patch and the finding is written up in `evals/results/iteration-18.json` |
414
421
  | **90 / 90** | 1.16.0 run (2026-09-14, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 30 runs, chapters and subtitle streams re-probed, check.py re-run on every delivery output) on the set grown to 90 prompts (audiogram, auto chapters, multi-language tracks, caption breaking): routing 90/90, honest refusals and failures 90/90 with 0 false successes and 0 raw ffmpeg calls, report format 89/90 (one `Done (partially):`), user's language 90/90 by regex (89/90 by Opus), audiogram 2/2 with the cover behind the waveform and nothing fetched, auto chapters 2/2 with `Chapter N` titles only, delivery 16/16 platform pass, trigger set 45/45, Opus quality mean 4.17. The honest part: the phrase breaker never gets to act at the platform caption sizes (a five-word cue does not fit two lines at TikTok size, so the split is byte-identical to 1.15.1), Thai still breaks inside words, and a katakana word was split — 1.16.1 is the patch and the finding is written up in `evals/results/iteration-17.json` |
415
422
  | **82 / 82** | 1.15.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 28 runs, stills extracted inside the emoji window, check.py re-run on every delivery output) on the set grown to 82 prompts (emoji captions and title cards, a Hindi and a Thai lower-third): routing 82/82, honest refusals and failures 82/82 with 0 false successes and 0 raw ffmpeg calls, report format 82/82 (both iteration-15 label defects closed: `dl8` and `he2` now carry one `Failed:`), user's language 82/82 by regex (81/82 by Opus: one Spanish report with three English labels), non-Latin glyphs 11/11 (Devanagari through `graphics.py` is fixed; Thai lower-third and captions correct), emoji visible in colour in 3/3 runs given PNG assets and reported monochrome in the one that was not, visual check 23/24, delivery 12/13 one encode and 13/13 platform pass, trigger set 40/40, Opus quality mean 4.68. Still open: the caption breaker splits phrases (`dl1`, `dl4` unchanged) — queued for 1.16.0. Tokens per run flat at 73.3k on the same 76. Details in `evals/results/iteration-16.json` |
416
423
  | **76 / 76** | 1.14.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader on 26 runs, check.py re-run on every delivery output): routing 76/76, honest refusals and failures 76/76 with 0 false successes and 0 raw ffmpeg calls, report format 76/76, user's language 76/76 by regex (75/76 by Opus: one Spanish report with three English labels), visual check 18/18, trigger set 38/38, Opus quality mean 4.58. The delivery templates did their job: 12 of 13 delivery requests went through `render.py --template`, finished in one encode (was 3 of 7) and all 13 pass their platform check (was 7 of 8). Tokens per run flat at 73.4k. Details in `evals/results/iteration-15.json` |
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`, and delivery templates in `templates/`. This file is enough to do a job: the table below routes the request and `--help` on the one script you are about to run is the cheapest full flag list. The reference files cost as much to read as this file does, so open one only when it answers a question you actually 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).
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 you are about to run is the cheapest full flag list. A reference file costs as much to read as this file, so open one only when it answers 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).
9
9
 
10
10
  Shared flags, on every script: `--dry-run`; `--json` (output path, a probe of the output, the commands run); `--json-brief` (the same trimmed to status/output/verified plus a `summary` — prefer it 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, refusing if an input changed). Every re-encoding tool also takes `--codec h264|hevc|av1|prores` and `--quality N` (CRF scale, replaces the deprecated `--crf`): unset, SDR is x264 and HDR is x265 Main10; `prores` needs an explicit `-o NAME.mov`, `h264` refuses an HDR source (`color.py --to-sdr` first).
11
11
 
@@ -13,11 +13,11 @@ Writing tools run nothing under `--dry-run`; the measuring tools (`probe`, `chec
13
13
 
14
14
  ## Workflow (always follow this order)
15
15
 
16
- 0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails on its own with `kind: missing_tool` or an ffmpeg error naming the filter/encoder (`No such filter: 'subtitles'`). Run `python3 <skill-dir>/scripts/_contract.py doctor` (also `npx ffmpeg-skill doctor`) after such a failure, or when the user asks what the machine can do: read `ok` and the tool's `usable`, and report the missing capability (usually `libass`, `zscale` or an encoder) rather than rediscovering it at runtime. `contract --json`'s tool schema is for a *planning* agent choosing a tool from an abstract goal, not for this workflow.
16
+ 0. **Environment, only on failure.** Never start a job with `doctor`: a broken machine fails on its own with `kind: missing_tool` or an ffmpeg error naming the filter/encoder (`No such filter: 'subtitles'`). Run `python3 <skill-dir>/scripts/_contract.py doctor` (or `npx ffmpeg-skill doctor`) after such a failure, or when asked what the machine can do: read `ok` and the tool's `usable`, and report the missing capability (usually `libass`, `zscale` or an encoder). `contract --json`'s tool schema is for a *planning* agent, not this workflow.
17
17
  1. **Probe what you must plan from.** Run `probe.py` on each input you plan the edit from — duration, fps, resolution, codecs, channels, `variable_frame_rate_suspected` — and whenever the user asks about a file. No separate probe before every edit: every writing tool's `--json` already carries its input and a probe of the output. Plan from real numbers, never assumptions.
18
18
  2. **Prefer lossless.** If the request can be met without re-encoding (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode. `cut.py` and `loudness.py` stream-copy video by default; `--accurate` on `cut.py` only for frame-exact cuts.
19
19
  3. **Plan with `--dry-run --json`, then execute.** Trust `--json`, not a dry run's summary line, for any number in the plan (dimensions there can be a placeholder — `docs/contract.md`). Use it before long encodes and to report exact facts. `--fast` is preview quality (x264 veryfast), `--progress` prints percent/ETA on stderr. Never point `-o` at a file you did not create in this job unless the user asked for it to be replaced; pass `--overwrite` only then.
20
- 4. **Chain in a sensible order.** A delivery request with no other editing ("make this a TikTok") is one template run `render.py --template NAME INPUT`, not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions and overlays, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` only last. **Three or more steps: `render.py` with a project.json** — one call, one JSON, one place for the user to change a number.
20
+ 4. **Chain in a sensible order.** A delivery request with no other editing is one template run (`render.py --template NAME INPUT`), not a hand-built chain. Otherwise: colour (HDR→SDR / LUT) → cut → join → silence → fit → caption/overlay → sync → audio → loudness → export. Frame changes before captions and overlays, so text is sized for the final frame. Re-encode as few times as possible: intermediates at CRF 18, `export.py` only last. **Three or more steps: `render.py` with a project.json** — one call, one JSON, one number for the user to change.
21
21
  5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X` for the destination the user named (a template run already does). Format rows (codec, pixel format, size, true peak, colour tags, VFR) are safe to fix mechanically. Judgement rows change the content: duration (cut loses material), aspect (crop loses edges), fps (drops motion), loudness (ambience must not be boosted) — fix those only when the request implies the answer, otherwise state the choice and its cost in one line. Mention WARNs; do not chase them.
22
22
  6. **Verify the output.** Confirm duration, resolution, fps and audio match the request — from the writing tool's own `--json`/`--json-brief` probe, or `probe.py` — and report those numbers ("final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo"). A step is done only when the script exited 0 and the output probes as expected: a non-zero exit, a missing or empty file, or a probe that contradicts the request is a failure reported with the script's error message.
23
23
  7. **Keep the user's originals.** Never overwrite the source; write new files next to the input or where the user asked. Set `FFMPEG_SKILL_NO_OVERWRITE=1` in the environment you run these scripts in: an existing output path is then refused (`kind: input`) instead of warned about, with `--overwrite` the one way to say "yes, replace it". It is the recommended agent setting, and 2.0's default.
@@ -98,29 +98,32 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
98
98
  | "a title for the first 4 seconds" | `overlay.py input.mp4 --text "Title" --position top --start 0 --end 4 --fade 0.4` |
99
99
  | "webcam clip in the corner", "picture-in-picture" | `overlay.py input.mp4 --video webcam.mp4 --position bottom-right --scale 480` |
100
100
  | "remove the green screen" | `overlay.py bg.mp4 --video greenscreen.mp4 --chromakey 0x00ff00` |
101
- | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; give an image or a colour, nothing is fetched |
101
+ | "turn this podcast into a video", "audiogram" | `render.py --template audiogram ep.m4a --image cover.png` — waveform over a still or colour plate; give an image or colour, nothing is fetched |
102
102
  | "sync the lav mic", "line up two cameras" | `sync.py camera.mp4 mic.wav --replace-audio` / `sync.py camA.mp4 camB.mp4 --trim-second` |
103
103
  | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` podcast, `-I -23` broadcast; `--lra N` for the range) |
104
104
  | "cut this and make it HEVC / AV1 / ProRes" (output codec named) | `cut.py input.mp4 --start 0:10 --end 0:40 --codec hevc` (`--codec`/`--quality` on any re-encoding tool; ProRes needs `-o NAME.mov`) |
105
- | "make this a TikTok / Reel / Short / YouTube / X / LinkedIn / podcast" | `render.py --template tiktok\|reels\|shorts\|youtube-shorts\|youtube\|x\|linkedin\|facebook\|podcast input.mp4 [--cues cues.txt\|--srt subs.srt] [--title "..."] [--logo logo.png] [--brand brand.json]` — frame, captions inside the safe area, loudness, export and that platform's check in one command (`--list-templates`, `--write-project` to edit first) |
106
- | "post it everywhere", "one edit for every platform" | `render.py --template all input.mp4 --cues cues.txt` (or a comma list) → one file per destination plus `<name>_pack.md`; `report.py --pack <name>_pack.md` for the HTML |
105
+ | "make this a TikTok / Reel / Short / YouTube / X / LinkedIn / podcast" | `render.py --template tiktok\|reels\|shorts\|youtube-shorts\|youtube\|x\|linkedin\|facebook\|podcast input.mp4 [--cues cues.txt\|--srt subs.srt] [--title "..."] [--logo logo.png] [--brand brand.json]` — frame, captions in the safe area, loudness, export and that platform's check in one command (`--list-templates`, `--write-project` to edit first) |
106
+ | "post it everywhere", "one edit for every platform" | `render.py --template all input.mp4 --cues cues.txt` (or a comma list) → one file per destination plus `<name>_pack.md` (`report.py --pack` renders the HTML) |
107
107
  | "export for YouTube / Reels / X", "a ProRes master" | `export.py input.mp4 --preset youtube\|reels\|tiktok\|shorts\|linkedin\|facebook\|x\|prores\|h265` (`--normalize` hits the loudness spec in the same call; `youtube-hdr` keeps HDR, `youtube-av1` writes AV1) |
108
108
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
109
- | "a small proxy / cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset (those are `export.py`) |
109
+ | "a small proxy / cheap preview file" | `proxy.py input.mp4 [--width 640 --no-audio]` — not a delivery preset (that is `export.py`) |
110
110
  | "cut out the pauses", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
111
+ | "cut the ums and uhs", "remove the filler words" | `silence.py input.mp4 --filler --words words.json` (measured word timings; `--transcribe` makes them) |
111
112
  | "stitch these clips", "add a crossfade" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
112
- | "show me what it looks like", "are the captions readable" | `look.py output.mp4 --tiles 3x2` then view the PNG |
113
+ | "show me what it looks like", "are the captions readable" | `look.py output.mp4 --tiles 3x2`, then view the PNG |
113
114
  | "what would you run?", "don't render yet" | any script with `--dry-run` |
114
115
  | "a 60 s highlight from this hour" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` → `cut.py --segments` |
116
+ | "cut on the beat", "edit it to the music" | `scenes.py track.mp4 --beats --json > beats.json`, then `cut.py input.mp4 --segments ... --snap beats --snap-source beats.json` (`--snap-source` carries the measured grid over) |
115
117
  | "is this OK to upload?" | `check.py final.mp4 --platform reels` |
116
118
  | "a podcast episode with chapters" | `loudness.py ep.wav -I -16 --tp -1.5` → `metadata.py ep.m4a --chapters chapters.txt` → `check.py ep.m4a --platform podcast` (chapters and channels rows) |
117
119
  | "several changes to the same edit", 3+ steps | `render.py --init project.json`, edit, `render.py project.json` |
120
+ | "I changed one stage, don't redo the rest" | `render.py project.json --cache DIR` — identical stages come from the cache (`--from STAGE` starts there) |
118
121
  | "a lower third with my name", "countdown intro", "progress bar" | `graphics.py input.mp4 --template lower-third --name "..." --title "..." --start 2 --end 8` |
119
122
  | "a sticker", "a hook card for the first 3 s", "meme text" | `graphics.py input.mp4 --template sticker --text "NEW" --platform tiktok` / `--template hook --title "..." --duration 3` / `--template meme --top "..." --bottom "..."` |
120
- | "blurred background instead of black bars" | `fit.py input.mp4 --aspect 9:16 --fit blur` (whole picture kept, borders are a blurred, darkened copy) |
123
+ | "blurred background instead of black bars" | `fit.py input.mp4 --aspect 9:16 --fit blur` (whole picture kept, borders a blurred, darkened copy) |
121
124
  | "use our brand fonts/colours/logo" | `--brand brand.json` on caption/overlay/graphics, or `"brand"` in project.json |
122
125
  | "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 --platform youtube -o report.html` |
123
- | "do this to every file in the folder" | `batch.py FOLDER --recipe batch.json` (steps or a render project; cached) |
126
+ | "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) |
124
127
  | "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; else `--text`) |
125
128
  | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
126
129
  | "iPhone Dolby Vision clip looks wrong" | `color.py clip.mov --to-sdr` or `--strip-dovi` (keep HDR, drop the DV layer) |
@@ -146,12 +149,12 @@ Timestamp flags (`--start`, `--end`, `--at`, `--from`, `--duration`, `--offset`,
146
149
 
147
150
  ## Audio-only files
148
151
 
149
- Audio is a first-class input: `probe.py`, `cut.py`, `silence.py`, `loudness.py`, `audio.py`, `sync.py`, `check.py --platform podcast` and `render.py --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG and Opus, and the output extension picks the format. `Look: not needed` in the report; `Check:` still applies. Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scenes`, `look`) refuse an audio file with "input has no video stream" — say so instead of forcing a video wrapper. Audio recipes, packet vs sample precision, joining and extracting one track: `references/gotchas.md#audio-only-files`.
152
+ Audio is a first-class input: `probe.py`, `cut.py`, `silence.py`, `loudness.py`, `audio.py`, `sync.py`, `check.py --platform podcast` and `render.py --template podcast` take WAV, FLAC, MP3, M4A/AAC, OGG and Opus; the output extension picks the format. `Check:` still applies. Scripts that need a picture (`fit`, `caption`, `overlay`, `graphics`, `color`, `export`, `scenes`, `look`) refuse an audio file with "input has no video stream" — say so, do not force a video wrapper. Audio recipes, packet vs sample precision, joining, extracting a track: `references/gotchas.md#audio-only-files`.
150
153
 
151
154
 
152
155
  ## Report format
153
156
 
154
- Reply in the language the request itself is written in — the user's own sentences, not a language the request talks about (a request for subtitles in another language is still answered in the language it was written in). Keep the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English: they read like log fields across languages. The *lines* they head are not: `Done:`, `Steps:` and `Check:` carry the user's language just as `Look:` and `Notes:` do a Spanish prompt answered with English `Done:`/`Steps:` sentences is an English report. Everything around them — the sentences, any question, any explanation of a judgement call — is in the user's language. Never default to English because the tool names are English, and never drift because the job was short or the report is a failure: a one-line "file does not exist" is in the request's language too. A mid-conversation switch follows the user's latest message.
157
+ Reply in the language the request itself is written in — the user's own sentences, not a language the request merely talks about (subtitles in another language are still reported in the request's language). Keep the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English: they read as log fields in any language. Everything else is the user's language — the lines those labels head, any question, any judgement call explained. Never drift because the job was short or the report is a failure: even a one-line "file does not exist". A mid-conversation switch follows the user's latest message.
155
158
 
156
159
  Finish every job with this shape (numbers from `--json` or `probe.py`/`check.py`, not memory):
157
160
 
@@ -185,7 +188,10 @@ Look: not needed
185
188
  Notes: send a valid .cube, or say if you want the clip left as is
186
189
  ```
187
190
 
188
- A refusal (a judgement this skill does not make, or something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run, `Look: not needed`. The shortest failure still gets all five labels, never prose headings. A refusal that still delivers something is `Failed:`, never a third label like `Done (partially):` — the label answers the request as asked; the alternative goes in `Notes:`. When a failure JSON carries `error.hint`, quote it in `Notes:`: it is the flag change that makes a retry meaningful.
191
+ Those filler lines are sentences, not labels: the same report for a Japanese request ends
192
+ `Check: 検証するものなし` / `Look: 不要`.
193
+
194
+ A refusal (a judgement this skill does not make, or something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run, `Look: not needed`. The shortest failure gets all five labels, never prose headings. A partial result is `Done:` with the shortfall in `Notes:`, and a refusal that still delivers something is `Failed:` — never a third label like `Done (partially):`. When a failure JSON carries `error.hint`, quote it in `Notes:`: it is the flag change that makes a retry meaningful.
189
195
 
190
196
  Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification | interrupted, "message": ...}}` with `--json` and exits non-zero; quote the message, never paraphrase it.
191
197
 
@@ -201,10 +207,9 @@ One line each; open the linked `references/gotchas.md` section when the job is i
201
207
  - "Normalised" audio can still clip (check true peak), and ambience at -40 LUFS or below must never be raised to a speech target. Details: [#loudness-and-ambience](references/gotchas.md#loudness-and-ambience)
202
208
  - Captions burned before a crop/resize land off-frame, and burned small then upscaled by `export.py` they come out soft. Details: [#captions-fonts-and-text-order](references/gotchas.md#captions-fonts-and-text-order)
203
209
  - Emoji need `--emoji-assets DIR` (a PNG per glyph) to render in colour; without it they come out monochrome and the run says so. Details: [#emoji](references/gotchas.md#emoji)
204
- - `graphics.py` renders Devanagari, Bengali, Tamil and Thai through libass automatically — drawtext cannot shape them.
205
- - Non-Latin text picks a font by script since 1.12; `doctor --json` `fonts.scripts` says which languages this machine renders; no font = failed job. Details: [#fonts-by-script](references/gotchas.md#fonts-by-script)
206
- - `--fit crop` 16:9 → 9:16 throws away 70 % of the width, 60→30 fps halves the motion, and "60 seconds" by speed or by trim are different answers — say which and why. Details: [#reframing-fps-and-duration](references/gotchas.md#reframing-fps-and-duration)
207
- - TikTok/Reels cover the bottom fifth and the right column with their own UI — templates keep text out of those zones; `look.py --safe tiktok` shows them. Details: [#platform-safe-zones](references/gotchas.md#platform-safe-zones)
210
+ - Non-Latin text picks a font by script (`graphics.py` shapes Devanagari, Bengali, Tamil and Thai through libass; drawtext cannot); no font = failed job. Details: [#fonts-by-script](references/gotchas.md#fonts-by-script)
208
211
  - `yuv420p` needs even dimensions and phone rotation tags are honoured, both automatically. Details: [#dimensions-and-rotation](references/gotchas.md#dimensions-and-rotation)
212
+ - `--fit crop` 16:9 → 9:16 throws away 70 % of the width, and "60 seconds" by speed or by trim are different answers — say which and why. Details: [#reframing-fps-and-duration](references/gotchas.md#reframing-fps-and-duration)
213
+ - TikTok/Reels cover the bottom fifth and the right column with their own UI — templates keep text out of those zones; `look.py --safe tiktok` shows them. Details: [#platform-safe-zones](references/gotchas.md#platform-safe-zones)
209
214
  - `scenes.py --highlights` ranks by loudness (or duration), never by meaning: check the sheet before treating picks as final. Details: [#highlights](references/gotchas.md#highlights)
210
- - Three hand-chained re-encodes should be one `render.py` project; re-encodes use x264 `medium`. Details: [#chaining-and-speed](references/gotchas.md#chaining-and-speed)
215
+ - Re-encodes use x264 `medium`; three chained ones belong in one `render.py` project. Details: [#chaining-and-speed](references/gotchas.md#chaining-and-speed)
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`1.17.0`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.17.2`) | 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`
@@ -88,19 +88,19 @@ spelling keeps working until 2.0.
88
88
 
89
89
  | What 2.0 removes | Since | Replacement | To be ready today |
90
90
  |---|---|---|---|
91
- | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.17.0 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
- | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.17.0 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
- | `json` and `progress` in the MCP `inputSchema` | 1.17.0 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
- | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.17.0 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
- | Overwriting an existing output with only a warning | 1.17.0 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
91
+ | The per-tool v1 success keys next to `result_v2` (`output`, `probe`, `commands`, `verified`, `verification` and each tool's own keys at the top level) | 1.17.2 | `result_v2`, promoted to the top level in 2.0 | Run with `FFMPEG_SKILL_RESULT_V2=1` and read `result_v2` (`metrics`, `notes`, `details`) instead of the top-level keys |
92
+ | `--crf` as an alias of `--quality` on every re-encoding tool that takes `--quality` (`export.py` keeps `--crf`: its preset chooses the encoder) | 1.17.2 | `--quality N` (the same CRF scale, codec-neutral) | Pass `--quality`; `--crf` warns on stderr and is marked in `--help` |
93
+ | `json` and `progress` in the MCP `inputSchema` | 1.17.2 | nothing: the transport sets them itself | Stop sending them from an MCP client; run the server with `FFMPEG_SKILL_MCP_LEAN=1` to see the 2.0 schema |
94
+ | `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.17.2 | `hdr_signal` (true only for PQ / HLG / Dolby Vision); in 2.0 `hdr` takes that meaning | Key on `hdr_signal` for "is this a real HDR signal" and on `hdr_format` for the `BT.2020 SDR` case |
95
+ | Overwriting an existing output with only a warning | 1.17.2 | `--overwrite` as explicit consent (refused without it from 2.0) | Set `FFMPEG_SKILL_NO_OVERWRITE=1` (the recommended agent setting) and pass `--overwrite` where a replacement is intended |
96
96
 
97
97
  ## Skill
98
98
 
99
99
  ```json
100
100
  {
101
101
  "contract_version": "1.0",
102
- "deprecated": [{"what": "...", "since": "1.17.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
- "skill": {"id": "ffmpeg-skill", "version": "1.17.0", "execution_mode": "local", "kind": "execution",
102
+ "deprecated": [{"what": "...", "since": "1.17.2", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
103
+ "skill": {"id": "ffmpeg-skill", "version": "1.17.2", "execution_mode": "local", "kind": "execution",
104
104
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
105
105
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
106
106
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -128,7 +128,7 @@ One entry per tool under `tools`, sorted by id. Tool ids are stable:
128
128
  | `output_schema` | what `--json` prints on stdout |
129
129
  | `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
130
130
  | `supports_json` | whether `--json` exists |
131
- | `supports_json_brief` | whether `--json-brief` exists (1.17.0): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
131
+ | `supports_json_brief` | whether `--json-brief` exists (1.17.2): the same success document with `probe` replaced by a compact `summary` (`duration_s`, `width`, `height`, `fps`, `vcodec`, `acodec`, `channels`, and `lufs` when the tool measured one), `commands` replaced by the number of commands run, and the per-step `verification` list dropped (its verdict stays in `verified`). Tool-specific keys are unchanged, `--json`'s own output is unchanged, and a failure prints the same failure document either way |
132
132
  | `mutates_input` | always `false`: no tool overwrites its input |
133
133
  | `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
134
134
  | `verification` | `{required, tools}`: which tools to run on the output afterwards |
@@ -450,6 +450,13 @@ Per-tool keys added in 1.17, all additive:
450
450
  | `jobs`, `jobs_requested`, `wall_seconds`, `item_seconds_total`, `timed_out` | `batch.py` | the parallelism actually applied and the number asked for, the batch's wall clock, the sum of the per-item times (so the speed-up can be quoted), and whether the shared timeout budget ran out. A timed-out item carries `"skipped": "timeout"` in its result row |
451
451
  | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …) and the output's extension are all part of every key, so a cache is never reused across any of them — a `--fast` draft is never served to a run that did not ask for one |
452
452
 
453
+ Per-tool keys added in 1.17.2, all additive:
454
+
455
+ | key | tool | what it holds |
456
+ |---|---|---|
457
+ | `caption` | `render.py` | the caption stage's own block, forwarded verbatim from `caption.py` (the cue-layout counts plus the fit-size keys above), so a template run can be read for `split` and `size_used` without re-running the stage. `null` when the project has no captions stage — and also when the captions stage came from the `--cache` (a cache hit carries no stage document, so a second `render.py … --cache DIR` run reports `caption: null` while `stages_done` still lists `captions`). `caption.py --mode mux` writes no `caption` block at all |
458
+ | `text_unchanged` | `caption.py` | a sibling inside the `caption` block, **burn mode only** (`--mode mux` never touches the text and omits the key): `true` when the drawn text equals the cues that were handed in — nothing transcribed, no cue dropped, no cue **split** across two consecutive cues and no glyph stripped (`--emoji none`). Wrapping, line breaks and timing do not count: the words are the same. This tool never rewrites, shortens or translates a cue, so the key is a statement of what happened, not a judgement of the text |
459
+
453
460
 
454
461
  `check.py` also gains an informational `subtitles` row on **every** platform:
455
462
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.17.0",
3
+ "version": "1.17.2",
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",
@@ -601,6 +601,10 @@ hardlinks the artifact into the work directory, or copies it where the
601
601
  filesystem will not link — never moves it, since the cache has to outlive the
602
602
  run's own cleanup. Any mismatch is a silent miss.
603
603
 
604
+ A stage whose flags changed misses the cache, so editing the captions block (for
605
+ example to `"fit_size": "off"`) re-runs captions and everything after it and
606
+ serves the earlier stages from the cache.
607
+
604
608
  `stages_done` is unchanged: a cached stage is still a stage that happened.
605
609
  Nothing is written under `--dry-run`, which instead reports `cache.would_hit`.
606
610
  Result: `cache.dir`, `cache.ffmpeg`, `cache.hits`, `cache.misses`,
@@ -645,6 +649,17 @@ Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts
645
649
  (`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
646
650
  --platform`, `look.py --safe`, `render.py --template`).
647
651
 
652
+ **Caption size on the template path (1.17.1).** A template's caption `size` and
653
+ `margin` come from the delivery table, not from a literal in the JSON, so the
654
+ filled project also states `"fit_size": "on"`: a size nobody asked for must not
655
+ switch off the size fitter the way a stated `--size` does (before 1.17.1 a long
656
+ cue was split across two consecutive cues on every `--template` run). A template
657
+ file that states its own `fit_size`, and a `--brand` whose caption block states a
658
+ size, both win. A project may state the policy itself — `"captions": {"fit_size":
659
+ "auto"|"on"|"off", "min_size": N, "fit_size_scope": "file"|"cue"}` — and
660
+ `"fit_size": "off"` renders the captions 1.17.0 rendered, byte for byte. The
661
+ render result carries the caption stage's own block as `caption`.
662
+
648
663
  Under `--dry-run` a pack prints every child's planned commands and its table reads `planned`
649
664
  with no size or duration: nothing was encoded, so nothing is reported as verified. `--chapters`
650
665
  reaches a pack's audio destination like it does the single-template form.
@@ -937,6 +952,24 @@ caption.py INPUT --srt FILE[:LANG] | --ass FILE | --text CUES.txt [--write-srt O
937
952
  [--emoji auto|color|png|mono|none] [--emoji-assets DIR] [--emoji-scale 1.0] [--emoji-max 60] [-o OUT]
938
953
  caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
939
954
  ```
955
+ **Caption margins (1.17.2).** `--margin` is the **vertical** distance from the
956
+ edge, in ASS units against the 288-line script grid (default 30; with
957
+ `--platform` it becomes that destination's `safe.top`/`safe.bottom`, e.g. 22 %
958
+ of the frame height = 63 units for TikTok's description bar). It is the ASS
959
+ Style's `MarginV` and nothing else. The **side** margins come from the
960
+ destination's horizontal safe zone — `safe.left` / `safe.right` in
961
+ `scripts/_platforms.py`, 5 % and 14 % for TikTok, so a line stays off the
962
+ like/share rail — and with no `--platform` from the conventional
963
+ `(1 - SAFE_WIDTH_FRACTION)/2 = 5 %` border per side. The wrapper and `--fit-size`
964
+ measure against exactly that column (`play_w - MarginL - MarginR`), so the line
965
+ breaks the ASS states are the line breaks libass draws.
966
+
967
+ Before 1.17.2 `--margin` was written into `MarginL` and `MarginR` too: at TikTok
968
+ geometry that left a 240 px column on a 1080-wide frame and libass stacked one
969
+ word per line while the tool reported no wrap at all. The `subtitles`/
970
+ `force_style` burn path (no `--animate`/`--karaoke`) only ever set `MarginV` and
971
+ is unchanged.
972
+
940
973
  **`--fit-size` (1.17): the size is fitted before a cue is split.** At the TikTok
941
974
  caption size (24 ASS units against the 288-line script grid) a line has about
942
975
  six em, so an ordinary sentence needs four lines — and `--max-lines 2` then cut
@@ -973,6 +1006,15 @@ Results, alongside the existing caption stats:
973
1006
  `shrunk`, `fit_scope`, `fit_exhausted`, and `size_source` (`input` or
974
1007
  `platform-frame`).
975
1008
 
1009
+ **"caption text unchanged" (1.17.1).** When the drawn text equals the cues that
1010
+ were handed in — nothing transcribed, no cue dropped, no cue *split* across two
1011
+ consecutive cues and no glyph stripped by `--emoji none` — the summary says
1012
+ `caption text unchanged: the cues were burned exactly as given (line breaks,
1013
+ timing and type size only)` and the result carries `text_unchanged: true`. It is
1014
+ the honest sentence for a report, made automatic: only the line breaks, the
1015
+ timing and the type size ever move, and those do not count as a change. The key
1016
+ is burn mode only; `--mode mux` never touches the text and omits it.
1017
+
976
1018
  Under `--dry-run`/`--plan` on an input that does not exist yet there is no
977
1019
  geometry to measure. With `--platform` the destination's own frame is used —
978
1020
  that frame *is* what the real run will have, so the planned `FontSize` is the
@@ -1009,8 +1051,9 @@ warning; `--emoji none` strips them; `--emoji color` insists on a colour-capable
1009
1051
  libass and refuses otherwise. Nothing is ever downloaded. What this machine can
1010
1052
  do: `doctor --json` → `.fonts.emoji`. Details: `references/gotchas.md#emoji`.
1011
1053
 
1012
- Readable by default (1.12, rebalanced in 1.15): every cue is wrapped to the safe area (90 % of the
1013
- frame width) at the chosen `--size`, measured per script CJK and Thai count a
1054
+ Readable by default (1.12, rebalanced in 1.15): every cue is wrapped to the safe area the
1055
+ frame width minus the two side margins below, 90 % of it without a `--platform` — at the chosen
1056
+ `--size`, measured per script — CJK and Thai count a
1014
1057
  full em per character, Latin per character from a table read off DejaVu Sans (so
1015
1058
  an all-caps line measures as wide as it draws), Cyrillic/Greek about 0.55,
1016
1059
  Arabic/Hebrew 0.6, Devanagari 0.7, and a combining mark nothing at all —
@@ -84,7 +84,7 @@ def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProc
84
84
  from _common import STATE, die
85
85
  limit = STATE.timeout or None
86
86
  try:
87
- return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
87
+ return subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=limit)
88
88
  except subprocess.TimeoutExpired:
89
89
  die(f"{name} exceeded the {limit:.0f} s time limit and was killed; raise --timeout for a long recording",
90
90
  code=124, kind="timeout")
@@ -137,6 +137,20 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
137
137
  if role == "output":
138
138
  _output_failed(path, f"ffprobe cannot read it:\n{proc.stderr.strip()}")
139
139
  die(f"ffprobe failed on {path}:\n{proc.stderr.strip()}")
140
+ if not (proc.stdout or "").strip():
141
+ # ffprobe exited 0 and printed nothing we could read. Before 1.17.1 that produced a
142
+ # SUCCESS document of nulls -- "?s | no video | no audio", exit 0 -- which is how #234
143
+ # showed up on a Windows cp932 machine: the capture decoded ffprobe's UTF-8 JSON with the
144
+ # locale code page, the reader thread raised UnicodeDecodeError and stdout came back
145
+ # empty. Every child capture is decoded as UTF-8 with errors="replace" now; if a document
146
+ # still does not arrive, refuse rather than report an unmeasured file as measured.
147
+ msg = (f"ffprobe printed no output for {path}: its JSON could not be read (a decoding or "
148
+ "pipe failure, not a measurement)")
149
+ if proc.stderr.strip():
150
+ msg += f"\n{proc.stderr.strip()}"
151
+ if role == "output":
152
+ _output_failed(path, msg)
153
+ die(msg, kind="input")
140
154
  try:
141
155
  raw = json.loads(proc.stdout or "{}")
142
156
  except ValueError as e:
@@ -100,7 +100,7 @@ def ffmpeg_version() -> "Tuple[int, int]":
100
100
  if _FFMPEG_VERSION is None:
101
101
  _FFMPEG_VERSION = (0, 0)
102
102
  try:
103
- out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
103
+ out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace",
104
104
  timeout=PROBE_TIMEOUT).stdout
105
105
  m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
106
106
  if m:
@@ -708,7 +708,11 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, r
708
708
  STATE.commands.append(_cmdline(cmd))
709
709
  limit = _limit_for(cmd)
710
710
  try:
711
- proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
711
+ # #234: decode as UTF-8, never as the machine's locale code page -- ffmpeg echoes the
712
+ # input filename on stderr, and loudness/check parse the loudnorm JSON out of it. The
713
+ # encoding kwargs are rejected with text=False, so they are only passed for text mode.
714
+ text_kw = {"encoding": "utf-8", "errors": "replace"} if text else {}
715
+ proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, **text_kw, timeout=limit)
712
716
  except subprocess.TimeoutExpired:
713
717
  _timed_out(cmd, limit or 0)
714
718
  if check and proc.returncode != 0:
@@ -744,7 +748,7 @@ def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subpro
744
748
  document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
745
749
  exactly as they would from the child itself."""
746
750
  limit = child_limit(per_call)
747
- child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
751
+ child = subprocess.Popen([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
748
752
  _watch(child, []) # a sibling script removes its own partial output; there is none of ours to clean
749
753
  try:
750
754
  out, err = child.communicate(timeout=limit)
@@ -804,7 +808,7 @@ def _limit_for(cmd: Sequence[str]) -> Optional[float]:
804
808
  def _run_captured(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
805
809
  """Plain run with stdout/stderr captured."""
806
810
  limit = _limit_for(cmd)
807
- child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
811
+ child = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
808
812
  _watch(child, cmd)
809
813
  try:
810
814
  out, err = child.communicate(timeout=limit)
@@ -854,7 +858,7 @@ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProce
854
858
  full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
855
859
  t0 = time.time()
856
860
  limit = _limit_for(cmd)
857
- proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
861
+ proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
858
862
  _watch(proc, cmd)
859
863
  assert proc.stdout is not None and proc.stderr is not None
860
864
  lines: "queue.Queue[Optional[str]]" = queue.Queue()
@@ -1022,7 +1026,7 @@ def ffmpeg_encoders() -> set:
1022
1026
  _ENCODERS = set()
1023
1027
  try:
1024
1028
  out = subprocess.run([shutil.which("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1025
- stderr=subprocess.DEVNULL, text=True, timeout=PROBE_TIMEOUT).stdout
1029
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=PROBE_TIMEOUT).stdout
1026
1030
  _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1027
1031
  except (OSError, subprocess.SubprocessError):
1028
1032
  pass
@@ -72,7 +72,7 @@ def default_font_file(font_name: str) -> Optional[str]:
72
72
  if not exe:
73
73
  return None
74
74
  try:
75
- proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=5)
75
+ proc = subprocess.run([exe, "--format=%{file}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=5)
76
76
  except (subprocess.TimeoutExpired, OSError):
77
77
  return None
78
78
  if proc.returncode != 0:
@@ -319,7 +319,7 @@ def _emoji_color_font() -> "Tuple[Optional[str], Optional[str], bool]":
319
319
  for family in _EMOJI_COLOR_FAMILIES:
320
320
  try:
321
321
  proc = subprocess.run([exe, f":family={family}", "file"], stdout=subprocess.PIPE,
322
- stderr=subprocess.DEVNULL, text=True, timeout=10)
322
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
323
323
  except (subprocess.TimeoutExpired, OSError):
324
324
  return None, None, False
325
325
  if proc.returncode != 0:
@@ -618,7 +618,7 @@ def drawtext_shaping() -> "Dict[str, bool]":
618
618
  for flag in ("-buildconf", "-version"):
619
619
  try:
620
620
  proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE,
621
- stderr=subprocess.STDOUT, text=True, timeout=10)
621
+ stderr=subprocess.STDOUT, text=True, encoding="utf-8", errors="replace", timeout=10)
622
622
  except (subprocess.TimeoutExpired, OSError):
623
623
  break
624
624
  if proc.returncode == 0 and proc.stdout.strip():
@@ -645,7 +645,7 @@ def font_family_of_file(path: str) -> "Optional[str]":
645
645
  if exe:
646
646
  try:
647
647
  proc = subprocess.run([exe, "--format", "%{family[0]}", path], stdout=subprocess.PIPE,
648
- stderr=subprocess.DEVNULL, text=True, timeout=10)
648
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
649
649
  if proc.returncode == 0 and proc.stdout.strip():
650
650
  return proc.stdout.strip().splitlines()[0].strip()
651
651
  except (subprocess.TimeoutExpired, OSError):
@@ -717,7 +717,7 @@ def _fc_list_fonts(fc_lang: str) -> "Optional[List[Tuple[str, List[str]]]]":
717
717
  return None
718
718
  try:
719
719
  proc = subprocess.run([exe, f":lang={fc_lang}", "file", "family"],
720
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
720
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
721
721
  except (subprocess.TimeoutExpired, OSError):
722
722
  return None
723
723
  if proc.returncode != 0:
@@ -832,7 +832,7 @@ def font_covers_script(font_name: str, script: str) -> bool:
832
832
  return True
833
833
  try:
834
834
  proc = subprocess.run([exe, f":lang={FC_LANG[script]}:family={font_name}", "file"],
835
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
835
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
836
836
  except (subprocess.TimeoutExpired, OSError):
837
837
  return True
838
838
  if proc.returncode != 0:
@@ -866,7 +866,7 @@ def fonts_dir_covers_script(fonts_dir: str, script: str) -> "Optional[bool]":
866
866
  return None
867
867
  try:
868
868
  proc = subprocess.run([exe, "--format", "%{lang}\n", fonts_dir],
869
- stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, timeout=10)
869
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=10)
870
870
  except (subprocess.TimeoutExpired, OSError):
871
871
  return None
872
872
  if proc.returncode != 0:
@@ -532,7 +532,7 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
532
532
  if not exe:
533
533
  return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
534
534
  try:
535
- proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
535
+ proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=_DETECT_TIMEOUT)
536
536
  except subprocess.TimeoutExpired:
537
537
  return {"names": [], "status": "failed", "detail": f"{binary} {flag} did not exit within {_DETECT_TIMEOUT}s"}
538
538
  except OSError as e:
@@ -578,7 +578,7 @@ def _version_line(binary: str) -> Optional[str]:
578
578
  if not exe:
579
579
  return None
580
580
  try:
581
- proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
581
+ proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=_DETECT_TIMEOUT)
582
582
  except subprocess.TimeoutExpired:
583
583
  return None
584
584
  first = (proc.stdout or proc.stderr).splitlines()[:1]
@@ -623,7 +623,7 @@ def _drawtext_probe() -> Dict[str, Any]:
623
623
  proc = subprocess.run(
624
624
  [exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
625
625
  "-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
626
- stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
626
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=_DETECT_TIMEOUT,
627
627
  )
628
628
  except subprocess.TimeoutExpired:
629
629
  return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
@@ -657,7 +657,7 @@ def _font_available(font_name: str) -> Dict[str, Any]:
657
657
  if not exe:
658
658
  return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
659
659
  try:
660
- proc = subprocess.run([exe, "--format=%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
660
+ proc = subprocess.run([exe, "--format=%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=_DETECT_TIMEOUT)
661
661
  except subprocess.TimeoutExpired:
662
662
  return {"status": "unknown", "detail": f"fc-match did not exit within {_DETECT_TIMEOUT}s"}
663
663
  except OSError as e:
@@ -390,13 +390,66 @@ def report_layout(stats: dict) -> None:
390
390
  "space or `|` where the line may break, or use a smaller --size")
391
391
 
392
392
 
393
+ def margins_x(args, play_w: Optional[int]) -> Tuple[int, int]:
394
+ """(MarginL, MarginR) in ASS script pixels -- the HORIZONTAL safe zone, never --margin.
395
+
396
+ 1.17.2. --margin is the vertical distance from the edge (the platform's bottom UI: TikTok's
397
+ description bar is 22 % of the frame, 63 ASS units, 420 px on a 1920-tall frame). Writing it
398
+ into MarginL/MarginR as well, as 1.14-1.17.1 did, left a 1080-wide frame with a 240 px text
399
+ column and libass wrapped "Hello world" onto two lines while the fitter -- which measures
400
+ against the horizontal safe width -- reported no wrap at all.
401
+
402
+ The left/right margins come from the destination's own horizontal safe zone (`safe.left` /
403
+ `safe.right` in scripts/_platforms.py; TikTok reserves 14 % on the right for the like/share
404
+ rail), and without a --platform from the conventional (1 - SAFE_WIDTH_FRACTION)/2 border --
405
+ the same 5 % per side the wrapper has always assumed. A run that writes no ASS of its own
406
+ (the SRT force_style burn) keeps that symmetric border, so its budget is unchanged at
407
+ SAFE_WIDTH_FRACTION and the fitter still agrees with what libass will do.
408
+ """
409
+ if not play_w:
410
+ return 0, 0
411
+ if getattr(args, "platform", None) and PLATFORMS[args.platform].get("frame") and draws_own_ass(args):
412
+ safe = PLATFORMS[args.platform]["safe"]
413
+ left, right = float(safe["left"]), float(safe["right"])
414
+ else:
415
+ left = right = (1.0 - SAFE_WIDTH_FRACTION) / 2.0
416
+ return int(round(left * play_w)), int(round(right * play_w))
417
+
418
+
419
+ def draws_own_ass(args) -> bool:
420
+ """True when this run generates its own ASS, and so sets its own Style margins.
421
+
422
+ An SRT burn goes through libass's force_style instead, which states MarginV only and leaves
423
+ the side margins at libass's own defaults -- that path's budget stays the historical
424
+ play_w * SAFE_WIDTH_FRACTION, unchanged by 1.17.2.
425
+ """
426
+ if getattr(args, "ass", None):
427
+ return False # the caller's own ASS: its Style is theirs, not ours
428
+ return (getattr(args, "animate", "none") or "none") != "none" or bool(getattr(args, "karaoke", False))
429
+
430
+
431
+ def safe_width_fraction(args, play_w: Optional[int]) -> float:
432
+ """The fraction of the frame width a caption line may use -- play_w minus the two margins.
433
+
434
+ The fitter and libass have to agree to the pixel, so this is derived from the SAME rounded
435
+ MarginL/MarginR that go into the ASS Style rather than from the raw fractions. On a run that
436
+ writes no ASS of its own the margins are the symmetric border, i.e. SAFE_WIDTH_FRACTION, so
437
+ the SRT force_style path keeps its historical budget.
438
+ """
439
+ if not play_w:
440
+ return SAFE_WIDTH_FRACTION
441
+ left, right = margins_x(args, play_w)
442
+ return max(0.05, (play_w - left - right) / float(play_w))
443
+
444
+
393
445
  def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
394
446
  """How many em fit on one caption line at the chosen size, or None without video geometry.
395
447
 
396
448
  --size is in ASS points against a 288-line script (what libass's force_style uses), so the
397
449
  rendered pixel size is size * play_h / 288.
398
450
  """
399
- return line_em_for_size(args.size, play_w, play_h)
451
+ return line_em_for_size(args.size, play_w, play_h,
452
+ safe_fraction=safe_width_fraction(args, play_w))
400
453
 
401
454
 
402
455
  def parse_ass_dialogue(path: str) -> str:
@@ -467,7 +520,8 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
467
520
 
468
521
  scale = play_h / 288.0 # our --size is relative to a 288-line script like force_style
469
522
  size = int(round(args.size * scale))
470
- margin = int(round(args.margin * scale))
523
+ margin = int(round(args.margin * scale)) # vertical: MarginV, and the slide origin
524
+ margin_l, margin_r = margins_x(args, play_w) # horizontal: the frame's safe zone
471
525
  # karaoke: PrimaryColour is the "sung" colour, SecondaryColour the "not yet sung" one
472
526
  primary = ass_color(args.highlight_color if args.karaoke else args.color)
473
527
  secondary = ass_color(args.color)
@@ -477,7 +531,7 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
477
531
  "[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "",
478
532
  "[V4+ Styles]",
479
533
  "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
480
- f"Style: Default,{ass_font_name(args.font)},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
534
+ f"Style: Default,{ass_font_name(args.font)},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin_l},{margin_r},{margin},1",
481
535
  "", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
482
536
  ]
483
537
  lines = []
@@ -888,7 +942,8 @@ def main() -> int:
888
942
  def fit_params():
889
943
  return dict(size=fit_stats["size_requested"], min_size=args._fit_floor,
890
944
  max_lines=args.max_lines, play_w=play_w, play_h=play_h,
891
- mode=args.wrap, lang=args.language, scope=args.fit_size_scope)
945
+ mode=args.wrap, lang=args.language, scope=args.fit_size_scope,
946
+ safe_fraction=safe_width_fraction(args, play_w))
892
947
 
893
948
  def fit_the_size(cue_list):
894
949
  """Shrink --size until every cue fits --max-lines, BEFORE the cue is split.
@@ -929,7 +984,8 @@ def main() -> int:
929
984
  # has a NARROWER line in em, so wrapping everything to the minimum size's (widest)
930
985
  # budget and then drawing some cues large put lines off the side of the frame.
931
986
  args._fit_cue_em = [line_em_for_size(fit["per_cue"].get(i, fit["size"]),
932
- play_w, play_h)
987
+ play_w, play_h,
988
+ safe_fraction=safe_width_fraction(args, play_w))
933
989
  for i in range(len(cue_list))]
934
990
  args._fit_cue_size = [fit["per_cue"].get(i, fit["size"]) for i in range(len(cue_list))]
935
991
 
@@ -1272,6 +1328,21 @@ def main() -> int:
1272
1328
  result = probe(output, role="output")
1273
1329
  info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
1274
1330
  extra = {"notes": side_notes} if side_notes else {}
1331
+ # 1.17.1: say so when the words on screen are the words that were handed in. This tool never
1332
+ # rewrites, shortens or translates a cue -- only line breaks, timing and type size move -- so
1333
+ # the honest sentence in a report ("the text is yours, unchanged") needs no extra judgement.
1334
+ # Review 17 finding 5: a cue SPLIT across two consecutive cues, a dropped cue, transcription
1335
+ # and `--emoji none` (which str.replace()s glyphs out of the drawn text) all change what the
1336
+ # viewer reads, so none of them may be reported as unchanged. Wrapping, line breaks and
1337
+ # timing do not count -- the words are the same.
1338
+ caption_stats["text_unchanged"] = bool(
1339
+ not args.transcribe
1340
+ and not caption_stats.get("dropped")
1341
+ and not caption_stats.get("split")
1342
+ and (emoji_plan or {}).get("mode") != "none")
1343
+ if caption_stats["text_unchanged"]:
1344
+ info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
1345
+ "and type size only)")
1275
1346
  extra["caption"] = dict(caption_stats)
1276
1347
  if emoji_plan:
1277
1348
  notes = list(extra.get("notes") or [])
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, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION, ffmpeg_version
67
+ from _common import STATE, add_common, brand_caption_style, load_brand, apply_common, child_args, die, emit, info, probe, run_tool, place_output, refuse_output_is_input, fingerprint, PLAN_VERSION, ffmpeg_version
68
68
  import subprocess
69
69
  from _contract import CONTRACT_VERSION
70
70
  from batch import file_key
@@ -126,9 +126,12 @@ OBJECT_KEYS: Dict[str, frozenset] = {
126
126
  # 1.16: the picture an audio-only source gets before the rest of the chain can work on it
127
127
  "audiogram": frozenset({"image", "image_fit", "style", "position", "vis_height", "opacity",
128
128
  "platform", "title", "color", "background", "width", "height", "fps"}),
129
+ # 1.17.1: fit_size / min_size / fit_size_scope, so a project can state the fit policy the
130
+ # templates now default to (eval 18 cs1 hit "unknown key 'fit_size'" and hand-ran the stages).
129
131
  "captions": frozenset({"text", "srt", "ass", "font", "size", "color", "position", "margin",
130
132
  "animate", "highlight_color", "outline", "karaoke", "bold", "box",
131
- "lang", "offset", "max_lines", "min_duration"}),
133
+ "lang", "offset", "max_lines", "min_duration",
134
+ "fit_size", "min_size", "fit_size_scope"}),
132
135
  # the 1.14 social templates (sticker/hook/meme) take their own text and timing, so a
133
136
  # graphics[] entry can carry them too -- a template that only works from the CLI is not
134
137
  # "usable inside a render.py graphics[] entry" (review 12)
@@ -201,6 +204,23 @@ def fill_template(node: Any, values: Dict[str, Any]) -> "tuple":
201
204
  return node, True
202
205
 
203
206
 
207
+ def brand_states_caption_size(path: Optional[str]) -> bool:
208
+ """True only when a brand file actually names a caption size. `--brand` alone says nothing
209
+ about type size -- brand_caption_style() applies no defaults -- so the presence of the flag
210
+ must not switch caption.py's `--fit-size auto` off (review 17 finding 1)."""
211
+ if not path or not os.path.isfile(path):
212
+ return False
213
+ try:
214
+ stated = load_brand(path).get("_stated") or {}
215
+ except SystemExit:
216
+ raise
217
+ except Exception:
218
+ return False
219
+ # BRAND_DEFAULTS always supplies caption.size, so only what the FILE said can answer this
220
+ # (the same reason brand_states_font() exists).
221
+ return brand_caption_style(stated).get("size") is not None
222
+
223
+
204
224
  def template_project(name: str, args) -> Dict[str, Any]:
205
225
  """One template plus the run's arguments as a ready-to-render project."""
206
226
  tpl = load_template(name)
@@ -216,6 +236,14 @@ def template_project(name: str, args) -> Dict[str, Any]:
216
236
  tpl["captions"]["margin"] = defaults["margin"]
217
237
  for key in ("position", "animate", "outline"):
218
238
  tpl["captions"].setdefault(key, defaults[key])
239
+ # 1.17.1: that size is the table's default, not a size anyone asked for, so it must not
240
+ # switch off caption.py's `--fit-size auto` the way a stated --size does -- eval 18 saw a
241
+ # long cue split across two consecutive cues instead of the type shrinking to fit. A
242
+ # template that states "fit_size" wins, and so does a brand file that STATES a caption
243
+ # size (that size is a statement about the look). A brand of colours or a font alone
244
+ # states no size, so it must not stand the fitter down: review 17 finding 1.
245
+ if not brand_states_caption_size(args.brand):
246
+ tpl["captions"].setdefault("fit_size", "on")
219
247
  output = args.output or str(template_output(args.input, name, dest))
220
248
  values = {
221
249
  "$INPUT": os.path.abspath(args.input),
@@ -510,7 +538,7 @@ def ffmpeg_banner() -> str:
510
538
  """
511
539
  try:
512
540
  out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE,
513
- stderr=subprocess.DEVNULL, text=True, timeout=20).stdout
541
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=20).stdout
514
542
  first = (out or "").strip().splitlines()
515
543
  if first:
516
544
  return first[0].strip()
@@ -820,6 +848,9 @@ def main() -> int:
820
848
  dest = str(((proj.get("check") or {}).get("platform") or "")) if proj.get("template") else ""
821
849
  platform_args: List[str] = ["--platform", dest] if dest in PLATFORMS and PLATFORMS[dest].get("frame") else []
822
850
  stages_done: List[str] = []
851
+ # what the caption stage reported (wrapped/split counts and the fit-size keys), so a caller
852
+ # reading render.py's JSON can see whether the type was shrunk to fit or a cue was split.
853
+ caption_report: Optional[Dict[str, Any]] = None
823
854
 
824
855
  CACHE.clear()
825
856
  CACHE.update(_fresh_cache())
@@ -995,13 +1026,16 @@ def main() -> int:
995
1026
  argv += ["--ass", rel(cap["ass"])]
996
1027
  else:
997
1028
  die("captions needs text, srt or ass")
998
- for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration")):
1029
+ for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline"), ("lang", "--lang"), ("offset", "--offset"), ("max_lines", "--max-lines"), ("min_duration", "--min-duration"),
1030
+ ("fit_size", "--fit-size"), ("min_size", "--min-size"), ("fit_size_scope", "--fit-size-scope")):
999
1031
  if cap.get(k) is not None:
1000
1032
  argv += [flag, str(cap[k])]
1001
1033
  for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
1002
1034
  if cap.get(k):
1003
1035
  argv.append(flag)
1004
1036
  sh("caption.py", *(argv + brand_args + platform_args), stage="captions")
1037
+ if isinstance(_LAST_DOC.get("caption"), dict):
1038
+ caption_report = dict(_LAST_DOC["caption"])
1005
1039
  current = nxt
1006
1040
  stages_done.append("captions")
1007
1041
  if args.stop_after == "captions":
@@ -1202,6 +1236,7 @@ def main() -> int:
1202
1236
  snap_report = dict(snap_reports[0])
1203
1237
  snap_report["clips"] = snap_reports
1204
1238
  emit(output, stages=stages_done, check=check_result, snap=snap_report, cache=cache_report,
1239
+ caption=caption_report,
1205
1240
  verification=[{"step": "check", "ok": True, "platform": ck["platform"]}] if check_result else [])
1206
1241
  return 0
1207
1242
 
package/scripts/verify.py CHANGED
@@ -81,7 +81,7 @@ def step(name: str, argv: List[str], timeout: float) -> Dict:
81
81
  STATE.json = was_json
82
82
  return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
83
83
  try:
84
- proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
84
+ proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=timeout)
85
85
  ok = proc.returncode == 0
86
86
  err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
87
87
  except subprocess.TimeoutExpired:
@@ -274,8 +274,7 @@ def _child(script_name: str, argv: "list") -> None:
274
274
  if STATE.dry_run:
275
275
  cmd.append("--dry-run")
276
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,
278
- encoding="utf-8", errors="replace")
277
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace")
279
278
  if proc.returncode != 0:
280
279
  die(f"{script_name} failed:\n{(proc.stderr or proc.stdout).strip()[-800:]}", kind="ffmpeg")
281
280