ffmpeg-skill 3.8.7 → 3.8.9

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
@@ -400,7 +400,7 @@ The short list for humans. The agent-facing version, with the reasoning, is the
400
400
  - **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. Built-in lists since 2.19: English, Japanese, Korean, Chinese, Spanish, German, French, Portuguese, Italian, Dutch, Polish, Russian and Turkish; any other language takes `--filler-words FILE`. Whisper stays optional: `--transcribe` with no engine installed refuses and names the five installs.
401
401
  - **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.
402
402
  - **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.
403
- - **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.
403
+ - **Emoji in captions and titles (1.15).** `caption.py`/`graphics.py --emoji-assets DIR` composites a PNG per emoji (Twemoji naming, `1f389.png`, or Noto's `emoji_u1f389.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 result's `emoji.mode` says which of three things happened: `color` when this build's libass draws the colour glyph (also for `--emoji mono` on such a build, with a warning), `mono` when it draws a monochrome one, and `none` when nothing here draws the glyph, in which case the emoji are left out of the text with an `emoji_not_drawn` warning (and a `graphics.py` title that is nothing but emoji is refused). Nothing is ever downloaded.
404
404
  - **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.
405
405
  - **Non-Latin text picks a font by script (1.12).** Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari, Thai, Cyrillic and Greek cues, titles and overlays resolve a font file that covers them automatically, and a machine with no such font fails the job (`kind: input`) instead of rendering boxes. `doctor --json`'s `fonts.scripts` says which languages this machine can render; `--lang ja|ko` disambiguates Han-only text, and `--lang zh-TW` (or `zh-HK`, `zh-Hant`) picks a Traditional Chinese face (2.19); an explicit `--font`/`--font-file` is always kept.
406
406
  - **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
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 (`2.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`3.8.7`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.9`) | 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`
@@ -149,7 +149,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
149
149
  "deprecated": [],
150
150
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"},
151
151
  {"what": "running on FFmpeg 5.x (the requirements.ffmpeg floor)", "since": "2.14.0", "replacement": "FFmpeg 6.1 or later; ...", "removed_in": "3.0.0", "where": "behaviour"}],
152
- "skill": {"id": "ffmpeg-skill", "version": "3.8.7", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.9", "execution_mode": "local", "kind": "execution",
153
153
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
154
154
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
155
155
  "requirements": {"python": ">=3.10 (standard library only)", "ffmpeg": ">=6.1", "ffprobe": ">=6.1"},
@@ -726,7 +726,8 @@ Per-tool keys added in 1.17, all additive:
726
726
  | `filler`, `removed_seconds_total` | `silence.py --filler` | `{lang, source, engine, words, removed, removed_count, removed_seconds, removed_words, word_timings, list, warnings}`. The existing `removed_seconds` is unchanged in name and meaning — the seconds of *silence* removed, which is what it has always held — and `removed_seconds_total` is the additive sibling covering silence plus filler |
727
727
  | `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 |
728
728
  | `timeline` | `render.py --export-timeline FILE` | `{format, rate, duration, frames, clips, transition, transition_frames, music, markers, notes, not_exported}`: the written timeline's own numbers (`rate` is exact, `"30000/1001"` for 29.97) and what the project asked for that an editor timeline cannot carry. Nothing is encoded (`commands: []`); `verified` means the file was written and reads back as its format (`verification` step `parse`) -- not that an editor opened it |
729
- | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`. The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) 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 |
729
+ | `templates` | `render.py --list-templates --json` | one row per shipped template: `{template, destination, frame: {width, height} or null for an audio destination, fit, max_duration, lufs, safe: {top, bottom, left, right}}` (unreleased: before, the table was printed under `--json` too). `render.py --init FILE --json` prints the plain success document with `output` the file it wrote |
730
+ | `cache` | `render.py --cache` | `{dir, ffmpeg, hits, misses, miss_reasons, saved_seconds, entries}`, plus `would_hit` under `--dry-run`. `miss_reasons` (unreleased, additive): `[{stage, reason, ...}]`, `reason` one of `not_cached`, `sidecar_missing`, `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`, `destination_not_owned` (a file already at the stage's path in `--work` is not one this render wrote). The ffmpeg build banner, the skill version, the contract version, the forwarded flags (`--fast`, `--codec`, …), the GPU setting (`FFMPEG_SKILL_HW` and whether `--hw`/`--no-hw` made it explicit) 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 |
730
731
  | `intermediate_preset` | `render.py` | The x264/x265 preset the stages before an encoding export ran at: `--intermediate-preset`, default `superfast` since 2.16 (`veryfast` in 2.15). `null` when no stage received it: no encoding export was reached, `--fast`, `--stop-after` before the export, a `copy` export, or nothing but the export encoded. Present on every render result, early stops included |
731
732
 
732
733
  Per-tool keys added in 1.17.1, all additive:
@@ -877,7 +878,17 @@ An ffmpeg run that fails keeps `kind: ffmpeg` and exit code 1, and since 3.1.0 c
877
878
  (`_common.runner.FFMPEG_FAILURE_CLASSES`): `disk_full` ("No space left on device"),
878
879
  `encoder_missing` and `filter_missing` (`details.name`; the build lacks it, `doctor` says how to
879
880
  get one that has it), `input_incomplete` ("moov atom not found": a cut-off recording or an
880
- unfinished copy, which `--repair` cannot rebuild) and `input_unreadable`. A tool that copies the
881
+ unfinished copy, which `--repair` cannot rebuild) and `input_unreadable`. After 3.8.4 an ffmpeg
882
+ command a tool runs through `run()` that names an encoder (`-c:v`/`-c:a`/`-c:s` and their
883
+ spellings, after the last input, for a stream kind the output keeps: not one `-an`/`-vn`/`-sn`
884
+ drops or no input file carries) or a filter (`-vf`, `-af`, `-filter:v`, `-filter:a`,
885
+ `-filter_complex`, `-lavfi`) the build's own list lacks is refused with that same `kind`, `reason`
886
+ and `details.name` before ffmpeg runs, under `--dry-run` too
887
+ (`_common.runner._check_build_components()`). Not checked: a measurement pass run through
888
+ `run_analysis()` (`loudness.measure()` and its loudnorm filter, for one: a pass that reports its
889
+ own failure carries ffmpeg's `reason` after the run, `ffmpeg_failure_extra()`), a VideoToolbox encoder,
890
+ a `-filter_complex_script` or lavfi input, an explicit `-map` selection, and any command when the
891
+ list could not be read (that refuses nothing). A tool that copies the
881
892
  source's subtitle and data streams retried the whole encode without them after any failure; it
882
893
  now retries only when the source has such a stream and the failure is none of these.
883
894
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.7",
3
+ "version": "3.8.9",
4
4
  "mcpName": "io.github.kajisho5/ffmpeg-skill",
5
5
  "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.",
6
6
  "keywords": [
@@ -271,9 +271,11 @@ directory of PNGs — and on most builds only the second one exists.
271
271
  to 48 pixels: invalid library handle`, and at the font's own strike
272
272
  `Monocromatic (1bpp) fonts are not supported.`) and no file is written. That
273
273
  is why the colour route is a PNG overlay, never a drawtext font.
274
- - **PNGs**: `--emoji-assets DIR`, a directory of files named by code point in
275
- the Twemoji/Noto convention — lowercase hex joined by `-`: `1f389.png`,
276
- `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png`. Twemoji's `assets/72x72`
274
+ - **PNGs**: `--emoji-assets DIR`, a directory of files named by code point:
275
+ Twemoji's lowercase hex joined by `-` (`1f389.png`, `1f1ef-1f1f5.png`,
276
+ `1f469-200d-1f4bb.png`), or Noto Emoji's `emoji_u` names, each code point at
277
+ least four hex digits, joined by `_` (`emoji_u1f389.png`,
278
+ `emoji_u0031_20e3.png`). Twemoji's `assets/72x72`
277
279
  (CC-BY 4.0) and Noto Emoji's `png/128` (OFL/Apache-2.0) are the two people
278
280
  already have. **The skill never downloads anything**: a missing directory is
279
281
  `kind: input`, never a silent fetch. The same directory can come from
@@ -353,7 +353,9 @@ the stored size, because the filter measures the picture after ffmpeg applies
353
353
  the tag. Distinct from
354
354
  `fit.py --fit crop`, which crops to a target aspect ratio it computes
355
355
  itself with no black-bar measurement involved. A rectangle that matches the
356
- full source frame means no bars were found. Does not decide whether
356
+ full source frame means no bars were found: a picture that reaches every edge
357
+ is reported as the frame itself, and `--round` (default 16) rounds a detected
358
+ crop only (1080 is not a multiple of 16). Does not decide whether
357
359
  removing detected bars is wanted -- genuine letterboxed content (a
358
360
  scope-ratio film in a 16:9 frame) "detects" the same way as accidental
359
361
  bars; look at the frame before cropping it away. `windows` / `windows_failed`
@@ -653,7 +655,8 @@ metadata.py INPUT [--chapters chapters.txt | --clear-chapters | --auto-chapters]
653
655
  [--title T] [--artist A] [--album A] [--comment C] [--date D] [--genre G] [-o OUT]
654
656
  ```
655
657
  `chapters.txt` holds one chapter per line, `TIME TITLE` (cut.py's time syntax:
656
- seconds, mm:ss, hh:mm:ss.ms); each chapter ends where the next starts and the
658
+ seconds, mm:ss, hh:mm:ss.ms; a `-`, dash, `|` or `:` between the two, as in
659
+ `0:00 - Intro`, is not part of the title); each chapter ends where the next starts and the
657
660
  last runs to the end of the file. Starts must ascend and lie inside the file.
658
661
  Every stream is `-c copy` (bit for bit; `probe` reports the result under
659
662
  `chapters` and `tags`), so this is instant and lossless. Chapter markers need a
@@ -766,7 +769,7 @@ leaves none. A dry run writes nothing.
766
769
  **`--filler` (1.17)** removes filler words as well, through the same
767
770
  `keep_ranges()` graph with more, shorter ranges. It is **never**
768
771
  applied without measured word timings: pass `--words` (a whisper JSON carrying
769
- per-word `start`/`end`) or `--transcribe` (the same local engine `caption.py`
772
+ per-word `start`/`end`, or whisper.cpp's `--output-json-full` document) or `--transcribe` (the same local engine `caption.py`
770
773
  uses, never required). There is no heuristic fallback — finding an "um" by
771
774
  looking for short quiet blips would cut real speech, so the tool refuses
772
775
  instead. `--filler-list` reports what would go and writes nothing.
@@ -871,7 +874,9 @@ packet count must equal the clips' total and its video length their summed lengt
871
874
  `copy`. `--copy` does not take `--width`, `--height`, `--fps`, `--sample-rate`, `--channels` or a transition, and an
872
875
  audio-only join (which resamples every clip to one format) is refused with it.
873
876
  `--list FILE` (2.2) reads the clips from a file, one per line in order, paths relative to the
874
- file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines accepted), for the
877
+ file (blank lines and `#` comments ignored; ffmpeg's `file 'x.wav'` lines and the `ffconcat version` header accepted,
878
+ any other concat directive -- `inpoint`, `outpoint`, `duration`, the argument-free `stream` -- refused, since join.py
879
+ applies none, unless the line names a file that is there), for the
875
880
  segments a TTS or render step wrote. Every input -- listed or given as arguments -- is checked
876
881
  before anything runs: missing, empty (0 bytes) or unreadable as media. By default all of them
877
882
  are named in one refusal (`kind: input`, `problems: [{index, path, reason}]`, nothing encoded);
@@ -977,8 +982,9 @@ rows, youtube's spec assumed, as `check.py --content` without `--platform`); und
977
982
 
978
983
  `"export": {"preset": "reels", "normalize": true}` forwards `export.py --normalize`
979
984
  so the rendered file meets the platform's loudness without a separate pass. Since
980
- 1.9.0 it is on by default when the preset is a platform (`youtube|youtube4k|reels|x`)
981
- and the project has no `loudness` stage; `"normalize": false` opts out.
985
+ 1.9.0 it is on by default when the preset is a platform's (`youtube`, `youtube4k`,
986
+ `youtube-hdr`, `youtube-av1`, `reels`, `tiktok`, `shorts`, `x`, `linkedin`, `facebook`:
987
+ `export.PLATFORM_OF`) and the project has no `loudness` stage; `"normalize": false` opts out.
982
988
 
983
989
  `"audio": {"stems": {"dialogue": -2, "music": -18, "effects": -24}}` names one
984
990
  level per element of the mix: `dialogue` is the main track's gain, `music` the
@@ -1015,7 +1021,29 @@ relative to the project file; `--export-timeline` lists the layer under
1015
1021
  before `check`, so the markers are in the file that ships (streams copied). The
1016
1022
  entries and the file path are validated before the first stage runs, and the
1017
1023
  stage plans its `metadata.py` command under `--dry-run`/`--plan` like every
1018
- other stage, so the plan lists `chapters` and the run does the same work.
1024
+ other stage, so the plan lists `chapters` and the run does the same work. An
1025
+ inline title is one line: a line feed, carriage return or NUL in it is refused
1026
+ (`kind: input`), since the chapters file holds one `TIME TITLE` line per chapter
1027
+ and FFmpeg ends a title at NUL. Under `--dry-run` the list is written to a
1028
+ temporary file, not into the work directory.
1029
+
1030
+ **`--stop-after export`** stops once the delivery is written: the chapters and
1031
+ check stages do not run (and their sections are not read). **Without an export
1032
+ stage** the delivery is the last stage's file copied as it is (`.mp4`, or the
1033
+ audio container of an audio-only output), so an output named for another
1034
+ container (`.webm`, `.mkv`) is refused, `kind: input`, before the first stage
1035
+ when the copy is certainly an intermediate and at the copy otherwise (a single
1036
+ uncut clip that no stage touches is its source, copied as it is); `.mov`,
1037
+ `.m4v`, `.m4a` and `.3gp` are the MP4 family and are accepted.
1038
+
1039
+ **The work directory.** Without `--work`, each run makes a new
1040
+ `<output>_work_<pid>_<random>/` beside the output and removes it at exit (kept
1041
+ with `--keep`, or by a `--stop-after` preview whose output is in it). `--work
1042
+ DIR` is used as given and never emptied; a stage file an earlier render wrote
1043
+ there, still unchanged (size, mtime, inode: `DIR/.ffskill-render-stages.json`
1044
+ records them), is replaced without `--overwrite`, while any other existing file
1045
+ is refused as before. A `--dry-run` writes nothing there and removes a `--work`
1046
+ directory it had to create.
1019
1047
 
1020
1048
  **`--cache DIR` (1.17)** reuses the artifact of a stage that already ran with
1021
1049
  exactly the same arguments and the same inputs. Opt-in only: **there is no
@@ -1023,7 +1051,8 @@ default cache directory** — one appearing on someone's disk unasked would
1023
1051
  contradict this tool's "a plan leaves nothing behind" posture. The key is a
1024
1052
  sha1 over the stage name, the tool, its arguments (every existing path replaced
1025
1053
  by its content hash), the input hashes, the **forwarded** flags (`--fast`,
1026
- `--overwrite`, `--timeout`, `--codec`), the output's extension, and **the ffmpeg
1054
+ `--overwrite`, `--timeout`; render.py has no `--codec`), the GPU setting (`--hw`/`--no-hw`,
1055
+ `FFMPEG_SKILL_HW`), the output's extension, and **the ffmpeg
1027
1056
  build banner, the skill version and the contract version**. `--fast` matters as
1028
1057
  much as any of them: it rewrites every child's preset to `veryfast`, so without
1029
1058
  it in the key a `--cache --fast` draft would be served back to a later run that
@@ -1037,8 +1066,10 @@ hardlinks the artifact into the work directory, or copies it where the
1037
1066
  filesystem will not link — never moves it, since the cache has to outlive the
1038
1067
  run's own cleanup. A miss is work to do, not an error, but never a silent one:
1039
1068
  `cache.miss_reasons` says why each stage missed (`not_cached`, `sidecar_missing`,
1040
- `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`), and a
1041
- damaged entry (the last two of those that name a file this tool wrote) is also a
1069
+ `artifact_missing`, `sidecar_corrupt`, `artifact_truncated`, `no_output_path`, and
1070
+ `destination_not_owned` when the file already at a stage's path in `--work` is not one this
1071
+ render wrote: the stage runs and refuses it by its own overwrite rule), and a
1072
+ damaged entry (`sidecar_corrupt`, `artifact_truncated`) is also a
1042
1073
  `cache_entry_damaged` warning and a line in `DIR/.ffskill-audit.jsonl`. A sidecar keeps
1043
1074
  the stage's `verified`, `verification`, `notes` and `diagnostics`, and a hit replays
1044
1075
  them, so a cached stage counts toward the render's `verified` as a live one does. A
@@ -1046,7 +1077,14 @@ store that fails is a `cache_store_failed` warning (the run is unaffected).
1046
1077
 
1047
1078
  A stage whose flags changed misses the cache, so editing the captions block (for
1048
1079
  example to `"fit_size": "off"`) re-runs captions and everything after it and
1049
- serves the earlier stages from the cache.
1080
+ serves the earlier stages from the cache. A file is keyed by its name, size,
1081
+ modification time (in nanoseconds; in whole seconds up to 3.8.4) and
1082
+ first and last MiB, the batch cache's key (`batch.file_key()`); a file whose
1083
+ mtime was set back to the old value keeps its key. The chapters file an inline
1084
+ `chapters` list is written to is new every run, so it is keyed by its content
1085
+ instead, and the chapters stage hits when the delivery under it is the cached
1086
+ one (an export served from the cache; a delivery copied without an export stage
1087
+ is a new file, and misses).
1050
1088
 
1051
1089
  `stages_done` is unchanged: a cached stage is still a stage that happened.
1052
1090
  Nothing is written under `--dry-run`, which instead reports `cache.would_hit`.
@@ -1086,7 +1124,10 @@ no overlay stage at all, not an overlay of nothing. The filled project then rend
1086
1124
  normal stages, so `--dry-run --json`, `--stop-after` and the work directory behave as always. An
1087
1125
  unknown name is refused (`kind: input`) with the list. Output defaults to
1088
1126
  `<input>_<template>.mp4` **next to the input** (`.m4a` for an audio-only destination such as
1089
- `podcast`) — the same rule for one template and for a pack, whose `-o` names the directory.
1127
+ `podcast`) — the same rule for one template and for a pack. A pack writes each destination as
1128
+ `<input>_<template>.<ext>` and the table as `<input>_pack.md` in the **directory of** `-o`:
1129
+ `-o packs/talk.mp4` writes into `packs/`, and `-o packs` into the folder that holds `packs`
1130
+ (the file name in `-o` is not used).
1090
1131
  Alias spellings are accepted everywhere one name is: `youtube-shorts`/`yt-shorts` = `shorts`,
1091
1132
  `yt` = `youtube`, `instagram`/`ig` = `reels`, `twitter` = `x`, `fb` = `facebook`
1092
1133
  (`check.py --platform`, `export.py --preset`, `caption.py`/`graphics.py`/`overlay.py
@@ -1137,7 +1178,8 @@ whose destinations did not all pass exits non-zero with the per-destination rows
1137
1178
 
1138
1179
  `audiogram` (1.16) is the one-call form of the podcast-to-video job: an `audiogram` stage runs
1139
1180
  **first** (it makes the picture the rest of the chain works on, through `waveform.py --image`),
1140
- then captions, loudness, export and check. It needs `--image` (a local file) or a `background`
1181
+ then captions, a `--title` lower third (graphics, as the youtube template draws it), loudness,
1182
+ export and check, with `--brand` reaching the audiogram, caption and graphics stages. It needs `--image` (a local file) or a `background`
1141
1183
  colour in the project — this skill fetches nothing and invents no cover art; `--template
1142
1184
  audiogram` without `--image` is refused before anything runs, and a project with an `audiogram`
1143
1185
  and more than one clip is refused (it is drawn from one audio source) — and it is
@@ -1331,8 +1373,18 @@ batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--jobs N|auto]
1331
1373
  ```
1332
1374
  `batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
1333
1375
  placeholders, chained) or `project` (a render project applied per file).
1334
- Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
1335
- already done with the same recipe. Use `--dry-run` to preview the plan. The recipe's
1376
+ Outputs land in `output_dir` with `suffix`, named with the source's extension
1377
+ unless the recipe sets `ext` -- except a `.webm` source, whose output is `.mp4`
1378
+ (the steps re-encode to H.264/AAC, which WebM cannot hold; a recipe that keeps
1379
+ WebM sets `"ext": "webm"`). `glob` matches names without regard to case on every
1380
+ platform (`"*.mp4"` finds `GOPR0001.MP4`; the letters inside a `[...]` class keep
1381
+ theirs), and a glob that matches nothing is a note. A cache skips files already
1382
+ done with the same recipe: an item's key is its source's `file_key()` (name,
1383
+ size, mtime in nanoseconds, first and last MiB), the recipe, the GPU setting,
1384
+ the files the steps name and, for a project recipe, the project file and every
1385
+ file it names (music, cues, brand and its logo and font, overlays, chapters,
1386
+ the clips other than the one each item replaces), read again on every
1387
+ `--watch` pass. Use `--dry-run` to preview the plan. The recipe's
1336
1388
  keys are `glob`, `steps`, `project`, `clip_key`, `output_dir`, `suffix` and `ext`; any
1337
1389
  other key is refused before an item runs, with the nearest valid name when one is close, else
1338
1390
  with the list of valid keys.
@@ -1748,8 +1800,8 @@ stays text (escaped), and under `--karaoke` `<font color>` is dropped for the hi
1748
1800
  colours. The ASS is kept next to the
1749
1801
  user can hand-tune timings and re-run with `--ass`.
1750
1802
  Emoji (1.15): `--emoji-assets DIR` is a directory of PNGs named by code point
1751
- (`1f389.png`, `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png` — the Twemoji/Noto
1752
- convention), also read from `brand.json` `styles.caption.emoji_assets` and
1803
+ (`1f389.png`, `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png` — the Twemoji
1804
+ convention; Noto Emoji's `emoji_u1f389.png`, `emoji_u0031_20e3.png` are found too), also read from `brand.json` `styles.caption.emoji_assets` and
1753
1805
  `FFMPEG_SKILL_EMOJI_ASSETS`. With one, the cue text keeps its place in the ASS
1754
1806
  with an invisible placeholder reserving the emoji's box and each PNG is
1755
1807
  composited on top (`--emoji-scale` sizes the box, `--emoji-max` caps the count).
@@ -128,11 +128,22 @@ def srt_timing_times(line: str) -> Tuple[str, str]:
128
128
  return a.strip(), words[0] if words else ""
129
129
 
130
130
 
131
+ # WebVTT's inline word timestamps and its class, voice and language spans (YouTube's automatic
132
+ # captions: `hello<00:00:01.500><c> world</c>`); <i>, <b> and <u> are SRT's too and stay
133
+ _VTT_INLINE = re.compile(r"<\d{1,2}:\d{2}(?::\d{2})?\.\d{3}>|</?(?:c|v|lang)(?:[.\s][^>]*)?>")
134
+
135
+
131
136
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
132
137
  cues: List[Tuple[float, float, str]] = []
133
- for times, body in srt_blocks(read_text_or_die(path, "--srt")):
138
+ source = read_text_or_die(path, "--srt")
139
+ # only in a file that says it is WebVTT: ffmpeg's own WebVTT reader drops these tags, and the
140
+ # cues this function returns were burned with them in the text (#404)
141
+ vtt = source.lstrip().upper().startswith("WEBVTT")
142
+ for times, body in srt_blocks(source):
134
143
  a, b = srt_timing_times(times)
135
144
  text = "\n".join(body).strip()
145
+ if vtt:
146
+ text = _VTT_INLINE.sub("", text).strip()
136
147
  try:
137
148
  cues.append((parse_time(a), parse_time(b), text))
138
149
  except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
@@ -121,6 +121,17 @@ def drawtext_shaping() -> "Dict[str, bool]":
121
121
  return dict(_SHAPING_BUILD_CACHE)
122
122
 
123
123
 
124
+ def shaping_refusal(script: str) -> str:
125
+ """Why drawtext cannot draw `script` here, as the clause after "<Language> text": on any build
126
+ for the scripts harfbuzz reorders, on this build for Arabic and Hebrew, which a build with
127
+ fribidi draws (#402: both were refused as "on any ffmpeg build")."""
128
+ if script in BIDI_SCRIPTS:
129
+ return ("cannot be shaped by drawtext on this ffmpeg build (it was built without libfribidi, which "
130
+ "drawtext needs for right-to-left order and Arabic joining; a build with it draws this text)")
131
+ return ("cannot be shaped by drawtext on any ffmpeg build (the marks are reordered by harfbuzz, which "
132
+ "drawtext does not use)")
133
+
134
+
124
135
  def needs_shaping(script: str) -> bool:
125
136
  """Whether drawtext would render `script` wrongly on this build."""
126
137
  if script in SHAPING_SCRIPTS:
@@ -778,8 +778,10 @@ def decode_step(path: str, meta: Dict[str, Any], kinds: Sequence[str], window: f
778
778
  # (bounded, so a failing hour-long stream is not decoded whole)
779
779
  frames[kind]["tail"] = tail_frames(max(0.0, end - origin - window - DECODE_LONG_GOP_SECONDS))
780
780
  bad = [k for k in kinds if not all(frames[k].values())]
781
+ # no provenance: it says where an expected value came from, and this step has none (#403: it
782
+ # said "measurement", which is not one of the contract's values)
781
783
  step: Dict[str, Any] = {"step": "decode", "property": "decodable", "frames": frames, "window": window,
782
- "provenance": "measurement", "ok": not bad}
784
+ "ok": not bad}
783
785
  if bad:
784
786
  step["undecodable"] = [k for k in bad if k not in unmeasured]
785
787
  if unmeasured:
@@ -187,7 +187,7 @@ def has_emoji(text: str) -> bool:
187
187
 
188
188
  def emoji_codepoint_name(cluster: str) -> str:
189
189
  """The asset filename stem for a cluster: lowercase hex code points joined by '-', the
190
- Twemoji/Noto convention (1f389, 1f469-200d-1f4bb, 1f1ef-1f1f5)."""
190
+ Twemoji convention (1f389, 1f469-200d-1f4bb, 1f1ef-1f1f5); emoji_asset_for() also tries Noto's."""
191
191
  return "-".join(f"{ord(c):x}" for c in cluster)
192
192
 
193
193
 
@@ -214,13 +214,36 @@ def emoji_asset_for(cluster: str, assets_dir: "Optional[str]") -> "Optional[str]
214
214
  if not assets_dir or not os.path.isdir(assets_dir):
215
215
  return None
216
216
  for name in _emoji_name_candidates(cluster):
217
- for ext in (".png", ".PNG"):
218
- candidate = os.path.join(assets_dir, name + ext)
219
- if os.path.isfile(candidate):
220
- return candidate
217
+ # Twemoji's name (1f469-200d-1f4bb.png, 31-20e3.png), then Noto Emoji's, whose code points
218
+ # are at least four hex digits (emoji_u1f469_200d_1f4bb.png, emoji_u0031_20e3.png): the hint
219
+ # names noto-emoji/png/128, whose files were never found (#404)
220
+ for stem in (name, "emoji_u" + "_".join(cp.zfill(4) for cp in name.split("-"))):
221
+ for ext in (".png", ".PNG"):
222
+ candidate = os.path.join(assets_dir, stem + ext)
223
+ if os.path.isfile(candidate):
224
+ return candidate
221
225
  return None
222
226
 
223
227
 
228
+ def emoji_mono_on_colour_note(support: "Dict[str, Any]") -> str:
229
+ """--emoji mono on a build whose libass draws the emoji face in colour: nothing here makes that
230
+ glyph monochrome (mono and color build the same text), so the result says color (#403: the
231
+ warning said "no colour path" beside a detail saying it renders in colour)."""
232
+ return ("--emoji mono: libass draws the emoji face in colour on this build and nothing here makes it "
233
+ "monochrome, so the emoji are drawn in colour (mode: color). " + str(support.get("detail") or ""))
234
+
235
+
236
+ def emoji_missing_asset_note(missing: "List[str]", support: "Dict[str, Any]") -> str:
237
+ """What happens to the clusters --emoji-assets has no PNG for: they stay in the text for libass,
238
+ which is said as measured (libass_glyph), not as "drawn by the text font" (#403)."""
239
+ names = ", ".join(missing)
240
+ if support.get("libass_glyph") is False:
241
+ return (f"no PNG in the assets directory for {names} -- those clusters stay in the text, and libass draws no "
242
+ "emoji glyph on this build: an empty box at best")
243
+ return (f"no PNG in the assets directory for {names} -- those clusters stay in the text for libass to draw "
244
+ "with an installed face" + ("" if support.get("libass_glyph") else " (whether one has the glyph was not measured)"))
245
+
246
+
224
247
  EMOJI_ASSET_HINT = (
225
248
  "point --emoji-assets at a directory of PNGs named by code point (1f389.png): "
226
249
  "twemoji/assets/72x72 (Twemoji, CC-BY 4.0) or noto-emoji/png/128 (Noto Emoji, OFL/Apache-2.0) "
@@ -13,7 +13,7 @@ import statistics
13
13
  from fractions import Fraction
14
14
  from typing import Any, Dict, List, Optional, Sequence, Tuple
15
15
  from _common.emit import die, warn
16
- from _common.runner import STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
16
+ from _common.runner import ffmpeg_failure_extra, STATE, dry_run_input_pending, require_tool, run, run_analysis, still_input_args
17
17
 
18
18
  # The streams probe() describes, as stream specifiers: its `video` is the first picture that is not
19
19
  # a cover (`V`), its `audio` the first sound stream. Without a -map, FFmpeg picks the largest
@@ -63,7 +63,7 @@ def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None
63
63
  proc = run_analysis(cmd, check=False, text=False)
64
64
  if proc.returncode != 0 or not proc.stdout:
65
65
  if check:
66
- die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
66
+ die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
67
67
  return []
68
68
  n = len(proc.stdout) // 2
69
69
  import struct
@@ -113,7 +113,7 @@ def decode_gray_frames(path: str, fps: float, width: int, height: int, *, start:
113
113
  proc = run_analysis(cmd, check=False, text=False)
114
114
  if proc.returncode != 0 or not proc.stdout:
115
115
  if check:
116
- die(f"could not decode frames from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
116
+ die(f"could not decode frames from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr.decode(errors="replace")))
117
117
  return []
118
118
  frame_size = width * height
119
119
  data = proc.stdout
@@ -208,10 +208,12 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
208
208
 
209
209
  role="output" marks a file this tool just wrote: a read failure is then reported as an
210
210
  output-verification failure (kind "output") instead of an input problem."""
211
- if not os.path.exists(path):
211
+ if not os.path.exists(path) or (role == "output" and STATE.dry_run):
212
212
  if role == "output" and not STATE.dry_run:
213
213
  _output_failed(path, "not written")
214
214
  if STATE.dry_run:
215
+ # also for an output path that holds a file: a dry run did not write it, and reading it
216
+ # reported an earlier run's file as this plan's result (#403: cut.py's output_duration)
215
217
  # width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
216
218
  # below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
217
219
  # probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
@@ -840,6 +842,24 @@ def measure_signal_range(path: str, standard: str, window: float = 10.0,
840
842
  return doc
841
843
 
842
844
 
845
+ def refuse_not_a_still(path: str, meta: Dict[str, Any], flag: str, instead: str) -> None:
846
+ """`flag` takes a still image and is read with `-loop 1`, an option of the image demuxers only:
847
+ a video or a GIF (one frame or many: the gif demuxer has no such option) given there failed as
848
+ "Option loop not found" / "Option framerate not found", naming neither the file nor the flag
849
+ (#404). `instead` says what takes a video."""
850
+ fmt = str(meta.get("format") or "")
851
+ if STATE.dry_run and meta.get("dry_run"):
852
+ return # a pending input: nothing was read
853
+ if fmt == "image2" or fmt.endswith("_pipe"):
854
+ return
855
+ if fmt == "gif":
856
+ die(f"{flag} {path} is a GIF, and {flag} reads a still image through the image demuxers, which do not "
857
+ "open GIF: convert it to PNG first (`look.py FILE --at 0 -o still.png` writes its first frame)", kind="input")
858
+ length = meta.get("duration")
859
+ die(f"{flag} {path} is not a still image: it is a {fmt.split(',')[0] or 'media'} file"
860
+ + (f" ({length:.3f}s)" if length else "") + f", and {flag} is read as one picture", kind="input", hint=instead)
861
+
862
+
843
863
  def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
844
864
  """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
845
865
 
@@ -1023,7 +1043,7 @@ def detect_silences(path: str, threshold: float, min_silence: float) -> "List[Tu
1023
1043
  f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
1024
1044
  proc = run_analysis(cmd, check=False, record=True)
1025
1045
  if proc.returncode != 0:
1026
- die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
1046
+ die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
1027
1047
  silences: "List[Tuple[float, float]]" = []
1028
1048
  start = None
1029
1049
  for kind, val in SIL_RE.findall(proc.stderr):
@@ -1085,7 +1105,7 @@ def detect_content(path: str, duration: float, *, video: bool = True, audio: boo
1085
1105
  cmd += ["-map", "0:a:0", "-af", f"silencedetect=noise={SILENT_PEAK_DBFS:g}dB:d={CONTENT_SILENCE_MIN}"]
1086
1106
  proc = run_analysis(cmd + ["-f", "null", "-"], check=False, record=True)
1087
1107
  if proc.returncode != 0:
1088
- die(f"content detection failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
1108
+ die(f"content detection failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
1089
1109
  end = max(duration, 0.0)
1090
1110
  out: Dict[str, Any] = {}
1091
1111
  if video:
@@ -1129,7 +1149,7 @@ def _detect_dropouts(path: str, end: float) -> "List[Tuple[float, float]]":
1129
1149
  f"silencedetect=noise={CONTENT_DROPOUT_DB:g}dB:d={CONTENT_DROPOUT_MIN}", "-f", "null", "-"],
1130
1150
  check=False, record=True)
1131
1151
  if proc.returncode != 0:
1132
- die(f"dropout detection failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
1152
+ die(f"dropout detection failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg", **ffmpeg_failure_extra(proc.stderr))
1133
1153
  gaps: "List[Tuple[float, float]]" = []
1134
1154
  start = None
1135
1155
  for kind, val in SIL_RE.findall(proc.stderr):