ffmpeg-skill 1.16.0 → 1.16.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 +2 -1
- package/docs/contract.md +9 -9
- package/package.json +1 -1
- package/references/scripts.md +7 -3
- package/scripts/_common/text.py +18 -2
- package/scripts/caption.py +9 -1
package/README.md
CHANGED
|
@@ -361,7 +361,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
|
|
|
361
361
|
- **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
|
|
362
362
|
- **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
|
|
363
363
|
- **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
|
-
- **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.
|
|
364
|
+
- **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
365
|
- **Audiogram (1.16).** `waveform.py --image cover.png` (or `render.py --template audiogram`) puts the waveform over a still plate for an episode that has no picture, with `--platform` for the frame, `--title` and burnt-in captions. The image is a local file you give: nothing is fetched and no cover art is ever invented.
|
|
366
366
|
- **Emoji in captions and titles (1.15).** `caption.py`/`graphics.py --emoji-assets DIR` composites a PNG per emoji (Twemoji/Noto naming, `1f389.png`) on top of the text, because drawtext cannot load a colour emoji font at all and an installed one does not prove libass will draw it in colour — `doctor --json`'s `fonts.emoji` answers that from a render probe. Without assets the run still succeeds and reports `mode: mono`. Nothing is ever downloaded.
|
|
367
367
|
- **Indic and Thai text shaped correctly in titles and lower-thirds (1.15).** `graphics.py` renders Devanagari, Bengali, Tamil, Thai and Lao through libass automatically (`text_renderer: "ass"`), because drawtext never reorders matras or re-clusters marks; Arabic and Hebrew were already correct on a fribidi build. `--text-render drawtext` with such a script is refused, never rendered wrongly.
|
|
@@ -407,6 +407,7 @@ type on every OS.
|
|
|
407
407
|
| **F1 0.97** | `scenes.py`, 53 hard cuts between single takes, precision 0.95, recall 1.00 at the default threshold |
|
|
408
408
|
| **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) |
|
|
409
409
|
| **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) |
|
|
410
|
+
| **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` |
|
|
410
411
|
| **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` |
|
|
411
412
|
| **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` |
|
|
412
413
|
| **76 / 76** | 1.13.0 run (2026-09-13, one pass per prompt, Sonnet agent, regex grader + focused Opus grader) on the set grown to 76 prompts: 18 in Thai, Hindi, Hebrew, Russian, Greek, Vietnamese, Indonesian, Turkish and Italian, and 8 delivery requests (TikTok, Reels, Shorts, LinkedIn, Douyin, podcast): 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 across seventeen languages, visual check 18/18, trigger set 38/38, Opus quality mean 4.65 over the 26 new runs. One real defect found: Hindi through `graphics.py` (drawtext) comes out wrong-shaped even though the font covers Devanagari; captions through libass are fine (queued for 1.15.0). Four delivery runs spent a second encode for loudness, which 1.14.0's templates address. Tokens per run flat at 72.3k. Details in `evals/results/iteration-14.json` |
|
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.16.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.16.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.16.
|
|
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.16.
|
|
93
|
-
| `json` and `progress` in the MCP `inputSchema` | 1.16.
|
|
94
|
-
| `hdr` meaning "BT.2020 primaries *or* a PQ/HLG transfer" in `probe` | 1.16.
|
|
95
|
-
| Overwriting an existing output with only a warning | 1.16.
|
|
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.16.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.16.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.16.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.16.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.16.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.16.
|
|
103
|
-
"skill": {"id": "ffmpeg-skill", "version": "1.16.
|
|
102
|
+
"deprecated": [{"what": "...", "since": "1.16.1", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
|
|
103
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.16.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.16.
|
|
131
|
+
| `supports_json_brief` | whether `--json-brief` exists (1.16.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 |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.16.
|
|
3
|
+
"version": "1.16.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
|
@@ -758,9 +758,13 @@ frame width) at the chosen `--size`, measured per script — CJK and Thai count
|
|
|
758
758
|
full em per character, Latin per character from a table read off DejaVu Sans (so
|
|
759
759
|
an all-caps line measures as wide as it draws), Cyrillic/Greek about 0.55,
|
|
760
760
|
Arabic/Hebrew 0.6, Devanagari 0.7, and a combining mark nothing at all —
|
|
761
|
-
breaking between characters for CJK
|
|
762
|
-
between a character and the combining marks that belong
|
|
763
|
-
|
|
761
|
+
breaking between characters for CJK (a katakana word stays whole) and at spaces
|
|
762
|
+
otherwise, but never between a character and the combining marks that belong
|
|
763
|
+
to it (Devanagari matras, Arabic and Hebrew points). Thai (1.16.1) is never
|
|
764
|
+
broken inside a run: it writes no space inside a phrase and the wrapper has no
|
|
765
|
+
dictionary, so the break goes where you put a space or a `|`, and a run with
|
|
766
|
+
none stays long on its own line — `caption.py` counts such lines as `overlong`
|
|
767
|
+
and says so. A cue that would need more than `--max-lines` (default 2) is split
|
|
764
768
|
into consecutive cues sharing its time; a cue shorter than `--min-duration`
|
|
765
769
|
(default 1.0 s) is held longer, never past the next cue's start; `--offset
|
|
766
770
|
TIME` shifts every cue (seconds, `mm:ss`, `hh:mm:ss.ms` or `hh:mm:ss:ff`, a
|
package/scripts/_common/text.py
CHANGED
|
@@ -440,7 +440,14 @@ ADVANCE_EM = {"ja": 1.0, "zh": 1.0, "ko": 1.0, "th": 1.0, "hi": 0.7, "ar": 0.6,
|
|
|
440
440
|
|
|
441
441
|
|
|
442
442
|
# Scripts written without spaces: a line breaks between any two characters.
|
|
443
|
-
|
|
443
|
+
# Scripts a line may break inside a run of, one character at a time. Thai is deliberately NOT
|
|
444
|
+
# here since 1.16.1: it writes no space inside a phrase, and without a dictionary the wrapper
|
|
445
|
+
# cannot see where one word ends -- every character-level break it took in eval 17 landed inside
|
|
446
|
+
# a word. A Thai run is therefore one atom, broken only at the spaces (or the manual `|`) the
|
|
447
|
+
# writer put there; an over-long run stays long on its own line, the rule long Latin words
|
|
448
|
+
# already follow.
|
|
449
|
+
NO_SPACE_SCRIPTS = ("ja", "zh", "ko")
|
|
450
|
+
NO_BOUNDARY_SCRIPTS = ("th",) # per-character breaking would chop words: keep the run whole
|
|
444
451
|
|
|
445
452
|
|
|
446
453
|
# Per-character Latin advances in em, read off DejaVu Sans (the default caption family, and close
|
|
@@ -1085,7 +1092,10 @@ def _atoms(line: str) -> "List[Tuple[str, bool]]":
|
|
|
1085
1092
|
if word:
|
|
1086
1093
|
out.append((word, spaced))
|
|
1087
1094
|
word = ""
|
|
1088
|
-
if out and
|
|
1095
|
+
if out and not pending and _is_katakana_run(ch) and _is_katakana_run(out[-1][0][-1]):
|
|
1096
|
+
# a katakana word (タイミング, コンピューター) is one atom: eval 17 saw タイ|ミング
|
|
1097
|
+
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
1098
|
+
elif out and (attach_next or _is_mark(ch)):
|
|
1089
1099
|
# never break between a base and the mark (or the leading vowel) that belongs to
|
|
1090
1100
|
# it: the line would start with an orphaned tone mark or vowel sign
|
|
1091
1101
|
out[-1] = (out[-1][0] + ch, out[-1][1])
|
|
@@ -1153,6 +1163,12 @@ def _is_kana(ch: str) -> bool:
|
|
|
1153
1163
|
return 0x3040 <= ord(ch) <= 0x30FF
|
|
1154
1164
|
|
|
1155
1165
|
|
|
1166
|
+
def _is_katakana_run(ch: str) -> bool:
|
|
1167
|
+
"""Katakana proper plus the prolonged-sound mark: the characters one loan word is made of."""
|
|
1168
|
+
cp = ord(ch)
|
|
1169
|
+
return (0x30A1 <= cp <= 0x30FA) or cp == 0x30FC or (0x31F0 <= cp <= 0x31FF) or (0xFF66 <= cp <= 0xFF9F)
|
|
1170
|
+
|
|
1171
|
+
|
|
1156
1172
|
def _is_hiragana(ch: str) -> bool:
|
|
1157
1173
|
return 0x3040 <= ord(ch) <= 0x309F
|
|
1158
1174
|
|
package/scripts/caption.py
CHANGED
|
@@ -461,6 +461,10 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
461
461
|
lines, greedy, measured = wrap_variants(text, max_em, mode=wrap, lang=lang)
|
|
462
462
|
if lines != [l for l in text.split("\n") if l.strip()]:
|
|
463
463
|
stats["wrapped"] += 1
|
|
464
|
+
if any(text_width_em(l) > max_em for l in lines):
|
|
465
|
+
# a run with no break point the wrapper may use (a long word, a Thai phrase
|
|
466
|
+
# without spaces) stays long rather than chopped: say so, and name the fix
|
|
467
|
+
stats["overlong"] = stats.get("overlong", 0) + 1
|
|
464
468
|
if lines != greedy:
|
|
465
469
|
stats["rebalanced"] += 1
|
|
466
470
|
if wrap != "measured" and lines != measured:
|
|
@@ -492,9 +496,13 @@ def layout_cues(cues: List[Tuple[float, float, str]], *, max_em: Optional[float]
|
|
|
492
496
|
|
|
493
497
|
def report_layout(stats: dict) -> None:
|
|
494
498
|
"""One info line, only when a cue actually changed."""
|
|
495
|
-
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped") if stats.get(k)]
|
|
499
|
+
parts = [f"{stats[k]} {k}" for k in ("shifted", "wrapped", "rebalanced", "phrase_breaks", "split", "extended", "dropped", "overlong") if stats.get(k)]
|
|
496
500
|
if parts:
|
|
497
501
|
info("cues: " + ", ".join(parts))
|
|
502
|
+
if stats.get("overlong"):
|
|
503
|
+
info(f"{stats['overlong']} cue line(s) wider than the safe width: a word or a run with no break "
|
|
504
|
+
"point (Thai writes none inside a phrase) was kept whole rather than chopped -- put a "
|
|
505
|
+
"space or `|` where the line may break, or use a smaller --size")
|
|
498
506
|
|
|
499
507
|
|
|
500
508
|
def max_line_em(args, play_w: Optional[int], play_h: Optional[int]) -> Optional[float]:
|