ffmpeg-skill 1.17.0 → 1.17.1
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 +11 -5
- package/SKILL.md +23 -18
- package/docs/contract.md +16 -9
- package/package.json +1 -1
- package/references/scripts.md +24 -0
- package/scripts/_common/asr.py +1 -1
- package/scripts/_common/probe.py +14 -0
- package/scripts/_common/runner.py +10 -6
- package/scripts/_common/text.py +7 -7
- package/scripts/_contract.py +4 -4
- package/scripts/caption.py +15 -0
- package/scripts/render.py +39 -4
- package/scripts/verify.py +1 -1
- package/scripts/waveform.py +1 -2
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.
|
|
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. **
|
|
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 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`)
|
|
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,7 @@ 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
|
+
| **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
420
|
| **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
421
|
| **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
422
|
| **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
|
|
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` (
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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`
|
|
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
|
-
-
|
|
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.17.1`) | 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.
|
|
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.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.17.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.17.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.17.
|
|
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.1 | `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.1 | `--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.1 | 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.1 | `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.1 | `--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.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.17.
|
|
102
|
+
"deprecated": [{"what": "...", "since": "1.17.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
103
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.17.1", "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.
|
|
131
|
+
| `supports_json_brief` | whether `--json-brief` exists (1.17.1): 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.1, 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.
|
|
3
|
+
"version": "1.17.1",
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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.
|
|
@@ -973,6 +988,15 @@ Results, alongside the existing caption stats:
|
|
|
973
988
|
`shrunk`, `fit_scope`, `fit_exhausted`, and `size_source` (`input` or
|
|
974
989
|
`platform-frame`).
|
|
975
990
|
|
|
991
|
+
**"caption text unchanged" (1.17.1).** When the drawn text equals the cues that
|
|
992
|
+
were handed in — nothing transcribed, no cue dropped, no cue *split* across two
|
|
993
|
+
consecutive cues and no glyph stripped by `--emoji none` — the summary says
|
|
994
|
+
`caption text unchanged: the cues were burned exactly as given (line breaks,
|
|
995
|
+
timing and type size only)` and the result carries `text_unchanged: true`. It is
|
|
996
|
+
the honest sentence for a report, made automatic: only the line breaks, the
|
|
997
|
+
timing and the type size ever move, and those do not count as a change. The key
|
|
998
|
+
is burn mode only; `--mode mux` never touches the text and omits it.
|
|
999
|
+
|
|
976
1000
|
Under `--dry-run`/`--plan` on an input that does not exist yet there is no
|
|
977
1001
|
geometry to measure. With `--platform` the destination's own frame is used —
|
|
978
1002
|
that frame *is* what the real run will have, so the planned `FontSize` is the
|
package/scripts/_common/asr.py
CHANGED
|
@@ -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")
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
-
|
|
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
|
package/scripts/_common/text.py
CHANGED
|
@@ -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:
|
package/scripts/_contract.py
CHANGED
|
@@ -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:
|
package/scripts/caption.py
CHANGED
|
@@ -1272,6 +1272,21 @@ def main() -> int:
|
|
|
1272
1272
|
result = probe(output, role="output")
|
|
1273
1273
|
info(f"wrote {output} ({fmt_secs(result.get('duration'))})")
|
|
1274
1274
|
extra = {"notes": side_notes} if side_notes else {}
|
|
1275
|
+
# 1.17.1: say so when the words on screen are the words that were handed in. This tool never
|
|
1276
|
+
# rewrites, shortens or translates a cue -- only line breaks, timing and type size move -- so
|
|
1277
|
+
# the honest sentence in a report ("the text is yours, unchanged") needs no extra judgement.
|
|
1278
|
+
# Review 17 finding 5: a cue SPLIT across two consecutive cues, a dropped cue, transcription
|
|
1279
|
+
# and `--emoji none` (which str.replace()s glyphs out of the drawn text) all change what the
|
|
1280
|
+
# viewer reads, so none of them may be reported as unchanged. Wrapping, line breaks and
|
|
1281
|
+
# timing do not count -- the words are the same.
|
|
1282
|
+
caption_stats["text_unchanged"] = bool(
|
|
1283
|
+
not args.transcribe
|
|
1284
|
+
and not caption_stats.get("dropped")
|
|
1285
|
+
and not caption_stats.get("split")
|
|
1286
|
+
and (emoji_plan or {}).get("mode") != "none")
|
|
1287
|
+
if caption_stats["text_unchanged"]:
|
|
1288
|
+
info("caption text unchanged: the cues were burned exactly as given (line breaks, timing "
|
|
1289
|
+
"and type size only)")
|
|
1275
1290
|
extra["caption"] = dict(caption_stats)
|
|
1276
1291
|
if emoji_plan:
|
|
1277
1292
|
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:
|
package/scripts/waveform.py
CHANGED
|
@@ -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
|
|