ffmpeg-skill 3.8.9 → 3.8.11

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 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.
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 the emoji are left out of the text: with an `emoji_not_drawn` warning when that happened because nothing here draws the glyph, and with only an info line when `--emoji none` asked for it (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`.
@@ -441,7 +441,7 @@ Without the other 30, an agent writes raw ffmpeg for those jobs, and a mistake t
441
441
  - `join.py` puts transitions between clips that differ in size, frame rate or audio layout. Done by hand, those clips have to be normalised first or xfade fails.
442
442
  - `sync.py` and `multicam.py` find the offset between two recordings by audio cross-correlation. No ffmpeg filter measures it.
443
443
 
444
- Every command a tool ran is in its `--json` `commands`, shell-quoted so it can be pasted and run again, and `--dry-run` prints the commands without running anything. In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
444
+ Every command a tool records as run is in its `--json` `commands` (`batch.py` lists none of its steps' commands, and a measurement pass is listed only where the tool records it), shell-quoted so it can be pasted and run again, except a line that names a file the tool wrote for the run and removed when it exited (such as a concat list, an ffmetadata file, a drawtext text file, a two-pass log or a stage's intermediate), and `--dry-run` on a writing tool prints the commands without writing the output (some dry runs still run a measurement, and `verify.py` ignores `--dry-run`: see below). In the 100-prompt agent eval (1.17.0, [Tested on real footage](#tested-on-real-footage)) the agent made no raw ffmpeg calls and reported no false successes.
445
445
 
446
446
  ### What does `verified` check, and what can it not tell me?
447
447
 
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.9`) | any release |
24
+ | `skill.version` | the npm / package.json version (`3.8.11`) | 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.9", "execution_mode": "local", "kind": "execution",
152
+ "skill": {"id": "ffmpeg-skill", "version": "3.8.11", "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"},
@@ -936,7 +936,8 @@ before, and, when `--json` was given, on stdout:
936
936
  `message` carries the script's own reason (missing input, ffprobe failure, the last
937
937
  stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
938
938
  flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
939
- or run so the caller can retry or report without re-deriving the command; `kind: ffmpeg`
939
+ or run so the caller can retry or report without re-deriving the command (a line that names a file
940
+ the tool wrote for the run and removed when it exited, such as a concat list, is a record of it); `kind: ffmpeg`
940
941
  failures add `ffmpeg_returncode` (ffmpeg's own exit code; the process exits 1). `code` is a
941
942
  purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
942
943
  doesn't already make) for a caller that wants a stable enum instead of matching `kind`
@@ -976,8 +977,10 @@ says it is kept without the step that was stopped, and the document carries it a
976
977
  **Source carry-over.** Every tool that writes a media file compares what its first input carried
977
978
  besides the picture and the sound with the file it wrote, and says so in `source_carry:
978
979
  {kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
979
- `audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
980
- `timecode`. A timecode is compared by its value: it is kept when the written start timecode
980
+ `audio_channels` (a surround source only: more than two), `attachments` (Matroska attached files,
981
+ such as the fonts of a subtitle track; only a Matroska output holds them), `video_streams` (a
982
+ source with more than one playable picture: the tools write one), `creation_time`,
983
+ `audio_language` and `timecode`. A timecode is compared by its value: it is kept when the written start timecode
981
984
  labels the frame the output starts on, which is the source's own label or, where the tool moved
982
985
  the picture's start (`cut` from a later frame, `fit --method trim`, `sync --trim-second`, a
983
986
  `render.py` clip with an `in`), the label of the source frame the output now starts on. A join of
@@ -985,9 +988,12 @@ several ranges (`cut --segments`, `--remove` leaving two or more) writes no star
985
988
  no single start labels the frames after the first join. The key is present only when the source
986
989
  had at least one of them, so a result for a plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
987
990
  or more inputs have a picture or sound (which one's items the output should carry is the tool's
988
- choice). An item bound to a stream the output does not have at all is not compared (a sound-only
991
+ choice). `render.py`, whose stages run as their own tools, compares a project whose clips are all
992
+ one file, with no music, replacement or effects track and no video layer, with that file; its
993
+ timecode counts as kept when every stage that compared it kept it. An item bound to a stream the output does not have at all is not compared (a sound-only
989
994
  extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
990
- it. A dropped `subtitles`, `chapters`, `cover_art` or `audio_channels` also records one
995
+ it. A dropped `subtitles`, `chapters`, `cover_art`, `audio_channels`, `attachments` or
996
+ `video_streams` also records one
991
997
  `source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
992
998
  `dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
993
999
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "3.8.9",
3
+ "version": "3.8.11",
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": [
@@ -193,7 +193,8 @@ libass and drawtext draw an empty box per character they have no glyph for, and
193
193
  ffmpeg still exits 0 — a video full of tofu is the classic "it worked" failure.
194
194
  Since 1.12 `caption.py`, `graphics.py` and `overlay.py --text` detect the script
195
195
  of the text they are about to draw (Japanese, Chinese, Korean, Arabic, Hebrew,
196
- Devanagari, Bengali, Tamil, Thai, Lao, Cyrillic, Greek) and resolve a font file that covers it,
196
+ Devanagari, Bengali, Tamil, Thai, Lao, Cyrillic, Greek; after 3.8.7 also Gurmukhi, Gujarati,
197
+ Telugu, Kannada, Malayalam, Sinhala, Khmer and Myanmar) and resolve a font file that covers it,
197
198
  printing one line — `font: /usr/share/fonts/.../wqy-zenhei.ttc (covers ko)`.
198
199
  **No font for the script is a failed job** (`kind: input`), not a warning.
199
200
 
@@ -412,7 +412,9 @@ burned-in line that does not move. That is a border blend, not inpainting,
412
412
  and a logo that travels or subtitles that change position are out of
413
413
  scope. The filter reads a 1 px ring outside the box, so the box cannot
414
414
  touch the frame edge, and it only interpolates 8-bit YUV (an HDR or 10-bit
415
- source is refused; `color.py --to-sdr` first). The rest of the frame, and
415
+ source is refused; `color.py --to-sdr` first). Blur and pixelate cover the
416
+ rectangle exactly at an odd `--x`/`--y` too (on 4:2:0 the chroma sample on an
417
+ odd edge is shared with the pixel outside). The rest of the frame, and
416
418
  the whole clip's timeline, are untouched -- to cover only part of the
417
419
  timeline, `cut.py` the clip into segments first.
418
420
 
@@ -471,6 +473,8 @@ background.py -o OUT.mov --duration T --width W --height H --transparent
471
473
  No input file: ffmpeg's own `color`/`gradients` source filters generate the
472
474
  clip directly. For a title-card background, a placeholder layer, or a base
473
475
  for `overlay.py` to composite onto. `--width`/`--height` must be even.
476
+ `--angle DEG` (any value, 0 = left to right, 90 = top to bottom) runs the gradient from the corner
477
+ the direction starts from (C1) to the opposite one (C2).
474
478
  `--transparent` writes a canvas with alpha 0 in every pixel in an alpha line:
475
479
  ProRes 4444 with 16-bit alpha (default; `.mov`/`.mkv`), `qtrle` (`.mov`), `png`
476
480
  (`.mov`/`.mkv`) or `vp9` (`.webm`/`.mkv`). It is the base layer
@@ -485,7 +489,9 @@ reverse.py INPUT [--no-audio] [-o OUT]
485
489
  Reverses video (and audio, unless `--no-audio`) with ffmpeg's `reverse`/
486
490
  `areverse` filters, which buffer the whole clip in memory — keep this to
487
491
  clips it makes sense to reverse (seconds to a couple of minutes), not
488
- something this tool limits for you.
492
+ something this tool limits for you. A `memory_heavy` warning says when the
493
+ decoded frames come to more than about 2 GiB (size, chroma, depth and frame
494
+ count, not the length alone).
489
495
 
490
496
  ### stabilize.py — motion stabilisation
491
497
  ```
@@ -567,10 +573,11 @@ extend` only works with `--at` at (or past) the clip's end and just makes
567
573
  the last frame last `--hold` seconds longer, with nothing pushed; `--mode insert` at
568
574
  the clip's end (no `--at`) does the same. Audio is
569
575
  silent during the held frame in `--mode insert` (there is no source audio
570
- for a frozen moment that didn't exist before). `--mode insert` drops a
571
- subtitle/data track rather than copy it with timestamps that no longer match
572
- the pushed picture (`dropped_non_av_streams: true` in the result), as
573
- `fit.py --method speed` does; `--mode extend` keeps it.
576
+ for a frozen moment that didn't exist before). A hold inside the clip or at
577
+ its start drops a subtitle/data track rather than copy it with timestamps that
578
+ no longer match the pushed picture (`dropped_non_av_streams: true` in the
579
+ result), as `fit.py --method speed` does; a hold of the last frame (`--mode
580
+ extend`, or no `--at`) pushes nothing and keeps it.
574
581
 
575
582
  ### pad.py — add black/silent padding at the start/end
576
583
  ```
@@ -1740,7 +1747,9 @@ paraphrase a caption to make it fit. Below the floor the cue is split exactly as
1740
1747
  before and `fit_exhausted: true` says so, with `--min-size` named as the flag
1741
1748
  that would go smaller and `|` as the manual break. `--min-size` above `--size`
1742
1749
  is a `kind: input` refusal. `--mode mux` is unaffected: soft subtitles carry no
1743
- size, so the SRT is the one 1.16 wrote.
1750
+ size, so a muxed track is neither fitted, wrapped nor split (a player wraps it at
1751
+ the size it draws it; through 3.8.7 it was wrapped at the burn-in size), and
1752
+ `--max-lines` or `--wrap measured` with `--mode mux` is refused.
1744
1753
 
1745
1754
  Results, alongside the existing caption stats:
1746
1755
  `fit_size`, `size_requested`, `size_used`, `size_floor`, `size_pct_height`,
@@ -1846,10 +1855,14 @@ never changes: **R1** never inside a word, and a hyphenated token may break only
1846
1855
  after its hyphen (never after a non-breaking `‑`, nor a leading/trailing
1847
1856
  one); **R2** no line that is a lone digit, one or two punctuation characters, or
1848
1857
  a single kana, checked at every boundary rather than only the last; **R3** for
1849
- Japanese and Chinese, a break is preferred after `。、!?」』)` and (Japanese
1858
+ Japanese and Chinese, a break is preferred after `。、!?」』),;:` and (Japanese
1850
1859
  only) after a particle — a particle attaches to the word before it, so kinsoku
1851
1860
  forbids opening a line with one — discouraged between a kanji stem and its
1852
- okurigana, and forbidden before a small kana, `ー` or a closing bracket; **R4**
1861
+ okurigana, and forbidden before a small kana, `ー`, a closing bracket or the
1862
+ fullwidth `,;:.` (and after an opening bracket); in `phrase` mode the first
1863
+ fill does not take such a break either while a permitted one fits, so a cue of three or more lines, and each
1864
+ piece of a cue split past `--max-lines`, keeps the punctuation on the line it
1865
+ ends; **R4**
1853
1866
  for en/es/pt/fr/de/it, an article or preposition is kept with the phrase it
1854
1867
  governs: the break before it is preferred and the break after it penalised (a
1855
1868
  frozen table, matched case-folded; `--lang`, else the script detector, picks the
@@ -1868,9 +1881,10 @@ toggleable streams and copies the video and audio bit for bit. The suffix splits
1868
1881
  on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
1869
1882
  token is not itself a file on disk, so `C:\subs\en.srt` and a file named
1870
1883
  `a:b.srt` are never mangled; a single `--srt` with no suffix still takes
1871
- `--language`. `--offset` shifts every added track (each later one as a
1872
- `<output>_trackN.srt` copy); `--max-lines` and `--min-duration` lay out the first
1873
- `--srt` only, and a note says so when they changed it. `--track-title` names a track (repeated in `--srt` order;
1884
+ `--language` (in a mux only: a burn reads `--language` as the script and wrap
1885
+ hint, not as a track tag). `--offset` shifts every added track (each later one as a
1886
+ `<output>_trackN.srt` copy); `--min-duration` lengthens the first `--srt`'s cues
1887
+ only, and a note says so when it changed them. `--track-title` names a track (repeated in `--srt` order;
1874
1888
  otherwise a frozen display-name table, and a code the table does not know gets
1875
1889
  the code itself — never a guessed or translated name), `--default-track LANG`
1876
1890
  marks one for auto-selection. Every other new track is explicitly marked *not*
@@ -1893,7 +1907,9 @@ language.
1893
1907
 
1894
1908
  Fonts by script (1.12): with no `--font` and no font named in your brand file, the
1895
1909
  script of the cue text (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari,
1896
- Thai, Cyrillic, Greek) picks a font file that covers it, logged as `font: <file>
1910
+ Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, Sinhala, Thai,
1911
+ Lao, Khmer, Myanmar, Cyrillic, Greek) picks a font file
1912
+ that covers it, logged as `font: <file>
1897
1913
  (covers ko)`. Han-only text is read as Chinese unless `--lang ja|ko` (or
1898
1914
  brand.json `"lang"`) says otherwise; `--language` is the same flag, and still
1899
1915
  tags the subtitle stream under `--mode mux` and sets `--transcribe`'s language.
@@ -1907,7 +1923,14 @@ does not cover the script gets one line and a font resolved by script anyway. `d
1907
1923
 
1908
1924
  `--karaoke` uses real per-word timings when the transcript has them (a whisper
1909
1925
  `<stem>.json` or `<stem>.words.json` next to the SRT, `{"segments": [{"words":
1910
- [{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides.
1926
+ [{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides. The
1927
+ highlight waits for the first word (the silence before it is an empty `\k` in
1928
+ `sweep`, an event with no active word in `word`), and `--offset` moves the word
1929
+ timings with the cues. With `--karaoke-style word` each word is its own event, and
1930
+ `--animate` is spread over them as the cue's one envelope: it fades in at the
1931
+ first event and out at the last, and a slide or a pop carries on across them.
1932
+ `--animate slide` ends on the line's own anchor (`--position`, a cue's `{\anN}`,
1933
+ the side margins).
1911
1934
 
1912
1935
  `--mode burn` (default) renders subtitles into the picture and always
1913
1936
  re-encodes both streams. `--mode mux` copies video and audio untouched and
@@ -29,7 +29,7 @@ from typing import Any, Dict, List, Optional, Sequence, Tuple
29
29
  EMOJI_SENTINEL = "\ue000"
30
30
 
31
31
 
32
- def emoji_placeholder(box_px: float) -> str:
32
+ def emoji_placeholder(box_px: float, back_alpha: int = 0) -> str:
33
33
  r"""The ASS override that reserves exactly `box_px` of advance and draws nothing.
34
34
 
35
35
  Measured, not assumed (1.15 spec, open question 2). U+2588 FULL BLOCK is NOT 1.0 em: its
@@ -40,9 +40,12 @@ def emoji_placeholder(box_px: float) -> str:
40
40
  whitespace carried by `\fsp` (letter spacing, in script pixels) on a zero-width space: the
41
41
  next glyph starts exactly `box_px` later, with no font dependence at all (verified by render,
42
42
  including inside a karaoke run, where the placeholder is its own zero-duration \kf segment).
43
- `\r` restores the style for the rest of the line.
43
+ The text after it gets back exactly what this override changed -- the four alphas (the box
44
+ colour's is `back_alpha`, the Style's own) and a letter spacing of 0, which every Style here
45
+ has. A `\r` did the same and also reset everything before it on the line: the pop's
46
+ \fscx/\t, a per-cue \fs, a cue's <i>/<b> and colour (A69, #457).
44
47
  """
45
- return "{\\alpha&HFF&\\fsp%.1f}\u200b{\\r}" % box_px
48
+ return "{\\alpha&HFF&\\fsp%.1f}\u200b{\\alpha&H00&\\4a&H%02X&\\fsp0}" % (box_px, int(back_alpha) & 0xFF)
46
49
 
47
50
 
48
51
  def ass_time(sec: float) -> str:
@@ -381,7 +384,7 @@ def text_overlay_ass(elements: Sequence[Dict[str, Any]], *, play_w: int, play_h:
381
384
  events.append(
382
385
  f"Dialogue: 0,{ass_time(el['start'])},{ass_time(el['end'])},{name},,0,0,0,,"
383
386
  "{" + "".join(tags) + "}" + ass_text(el["text"]).replace(
384
- EMOJI_SENTINEL, emoji_placeholder(el.get("box_px") or el["size"])))
387
+ EMOJI_SENTINEL, emoji_placeholder(el.get("box_px") or el["size"], int(el.get("box_alpha", 0)))))
385
388
  body = "\n".join(header + ["", "[Events]",
386
389
  "Format: Layer, Start, End, Style, Name, MarginL, MarginR, "
387
390
  "MarginV, Effect, Text"] + events) + "\n"
@@ -385,6 +385,25 @@ def stream_starts(meta: Optional[Dict[str, Any]]) -> Tuple[float, float]:
385
385
  return out[0], out[1]
386
386
 
387
387
 
388
+ # The size of the decoded clip above which a tool that holds every frame (reverse, a boomerang)
389
+ # says `memory_heavy`.
390
+ HELD_FRAMES_WARN_BYTES = 2 * 1024 ** 3
391
+
392
+
393
+ def held_frames_bytes(v: Dict[str, Any], frames: float) -> int:
394
+ """About how many bytes `frames` decoded frames of the picture `v` (a probe()'s `video`) take:
395
+ the picture's size, its chroma planes (4:2:0 1.5x, 4:2:2 2x, 4:4:4 3x, an alpha plane one
396
+ more) and 2 bytes a sample above 8 bits. The one estimate reverse.py and loop.py --boomerang
397
+ warn from: reverse.py's was the clip's duration alone, so a 30 s 4K60 clip (tens of GB) got
398
+ no warning and a 61 s 320x180 one did (A174)."""
399
+ pix = str(v.get("pix_fmt") or "")
400
+ planes = 3.0 if "444" in pix or pix.startswith(("gbr", "rgb", "bgr")) else 2.0 if "422" in pix else 1.5
401
+ if pix.startswith(("yuva", "gbra", "rgba", "bgra", "argb", "abgr", "ya")):
402
+ planes += 1.0
403
+ depth = 2 if (v.get("bit_depth") or 8) > 8 else 1
404
+ return int((v.get("width") or 0) * (v.get("height") or 0) * planes * depth * max(0.0, frames))
405
+
406
+
388
407
  def frame_rate(meta: Optional[Dict[str, Any]]) -> float:
389
408
  """The video's frame rate as cut.py's grid takes it: r_frame_rate, else fps; 0.0 when unknown."""
390
409
  v = (meta or {}).get("video") or {}
@@ -167,10 +167,11 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
167
167
  # source_carry: the items compared, in report order. The first group is content a viewer or
168
168
  # listener notices missing and raises the warning; the second is tags, listed but never warned
169
169
  # about (every camera file has a creation_time, so a warning for it would fire on every run).
170
- CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
170
+ CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels", "attachments", "video_streams")
171
171
  CARRY_TAGS = ("creation_time", "audio_language", "timecode")
172
172
  _CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
173
- "audio_channels": "audio channels"}
173
+ "audio_channels": "audio channels", "attachments": "attached files (a subtitle track's fonts)",
174
+ "video_streams": "video streams"}
174
175
 
175
176
 
176
177
  def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
@@ -205,7 +206,9 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
205
206
  continue
206
207
  if item == "audio_channels" and src[item] <= 2:
207
208
  continue # mono and stereo are what every tool writes; only a surround layout can be folded down
208
- if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
209
+ if item == "video_streams" and src[item] <= 1:
210
+ continue # one picture is what every tool writes; only a second one can be left out
211
+ if item in ("subtitles", "timecode", "attachments", "video_streams") and src["has_video"] and not out["has_video"]:
209
212
  continue
210
213
  if item in ("audio_channels", "audio_language") and not out["has_audio"]:
211
214
  continue
@@ -227,8 +230,8 @@ def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -
227
230
  if loud:
228
231
  parts = []
229
232
  for i in loud:
230
- if i == "audio_channels":
231
- parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
233
+ if i in ("audio_channels", "video_streams"):
234
+ parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} {_CARRY_NAMES[i]}")
232
235
  else:
233
236
  parts.append("its " + _CARRY_NAMES[i])
234
237
  warn("the source carried " + ", ".join(parts) + " that this output does not have "
@@ -383,7 +383,9 @@ def emoji_support(assets: "Optional[str]" = None, probe: bool = True, fonts_dir:
383
383
  mode = "png"
384
384
  elif family and libass_glyph is False:
385
385
  mode = "none"
386
- elif family:
386
+ elif family or libass_glyph:
387
+ # a face libass draws the emoji from, colour family listed or not (A64, #457: a glyph from
388
+ # a monochrome face was answered `none`, against this docstring and libass_glyph: true)
387
389
  mode = "mono"
388
390
  elif not fc_answered:
389
391
  # No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
@@ -404,6 +406,8 @@ def emoji_support(assets: "Optional[str]" = None, probe: bool = True, fonts_dir:
404
406
  detail = f"{family} installed; the colour render probe was not run"
405
407
  elif assets_dir:
406
408
  detail = "no colour emoji family installed; using the PNG assets directory"
409
+ elif libass_glyph:
410
+ detail = "no colour emoji family installed; libass draws the emoji monochrome from an installed face"
407
411
  else:
408
412
  detail = "no colour emoji family installed and no --emoji-assets directory"
409
413
  result = {"mode": mode, "color_font": family, "color_font_file": file,
@@ -109,21 +109,32 @@ def resolve_font(font_name: str) -> "Dict[str, object]":
109
109
 
110
110
 
111
111
  def font_choice(asked: "Optional[str]", *, font_file: "Optional[str]" = None, script_file: "Optional[str]" = None,
112
- script_family: "Optional[str]" = None, renderer: str = "drawtext") -> "Dict[str, object]":
112
+ script_family: "Optional[str]" = None, renderer: str = "drawtext",
113
+ bold: bool = False) -> "Dict[str, object]":
113
114
  """The font a text-drawing tool draws with, as its result's `font_used`: {asked, file, family,
114
115
  how, substituted}. `how`: font_file (the caller's own file), script (a font picked to cover the
115
116
  text's script, 1.12), resolved (the asked family resolved to a file: fc-match, or the Windows
116
117
  Fonts folder), by_name (no file resolved; ffmpeg/libass look the family up themselves, so the
117
118
  file is not known here). For libass (`renderer="libass"`) a resolved file is the one fontconfig
118
119
  picks for the family, which is what libass loads through fontconfig; on Windows libass is
119
- given the family by name, so nothing is resolved for it."""
120
+ given the family by name, so nothing is resolved for it.
121
+
122
+ `bold` (libass drawing a Bold style): libass asks fontconfig for the family at bold weight, so
123
+ the file reported is the one `fc-match FAMILY:bold` names -- the family's bold face where it
124
+ has one, its regular face (emboldened by libass) where it has none. The regular face was
125
+ reported whatever was drawn (A70, #457). Not on Windows, where nothing is resolved for libass."""
126
+ weight = ":bold" if bold and renderer == "libass" and platform.system() != "Windows" else ""
120
127
  if font_file:
121
128
  return {"asked": font_file, "file": font_file, "family": None, "how": "font_file", "substituted": False}
122
129
  if script_file:
130
+ if weight and script_family:
131
+ face = resolve_font(script_family + weight)
132
+ if face["file"] and not face["substituted"]:
133
+ script_file = face["file"]
123
134
  return {"asked": asked, "file": script_file, "family": script_family, "how": "script", "substituted": False}
124
135
  if renderer == "libass" and platform.system() == "Windows":
125
136
  return {"asked": asked, "file": None, "family": asked, "how": "by_name", "substituted": False}
126
- found = resolve_font(asked or "")
137
+ found = resolve_font((asked or "") + weight)
127
138
  if not found["file"]:
128
139
  return {"asked": asked, "file": None, "family": asked, "how": "by_name", "substituted": False}
129
140
  return {"asked": asked, "file": found["file"], "family": found["family"], "how": "resolved",
@@ -147,13 +158,18 @@ def record_font_choice(choice: "Dict[str, object]") -> "Dict[str, object]":
147
158
  # request and ffmpeg exits 0 either way. The tools now detect the script of the text they are about
148
159
  # to draw and resolve a font file that actually covers it; nothing found is a failed job, not a
149
160
  # warning (a video full of boxes is not a delivery).
150
- SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "ta", "th", "lo", "ru", "el", "latin")
161
+ # A65 (#457): gu, pa, te, kn, ml, si, km and my were in SHAPING_SCRIPTS but had no range
162
+ # below, so their text was detected as Latin, drawn in the Latin font and reported verified.
163
+ SCRIPTS = ("ja", "zh", "ko", "ar", "he", "hi", "bn", "pa", "gu", "ta", "te", "kn", "ml", "si", "th", "lo",
164
+ "km", "my", "ru", "el", "latin")
151
165
 
152
166
 
153
167
  LANGUAGE_NAMES = {
154
168
  "ja": "Japanese", "zh": "Chinese", "ko": "Korean", "ar": "Arabic", "he": "Hebrew",
155
169
  "hi": "Devanagari (Hindi/Marathi/Nepali)", "th": "Thai", "ru": "Cyrillic (Russian and others)",
156
170
  "el": "Greek", "latin": "Latin", "bn": "Bengali", "ta": "Tamil", "lo": "Lao",
171
+ "pa": "Gurmukhi (Punjabi)", "gu": "Gujarati", "te": "Telugu", "kn": "Kannada", "ml": "Malayalam",
172
+ "si": "Sinhala", "km": "Khmer", "my": "Myanmar (Burmese)",
157
173
  "zh-hant": "Traditional Chinese",
158
174
  }
159
175
 
@@ -161,7 +177,8 @@ LANGUAGE_NAMES = {
161
177
  # fontconfig's own :lang= codes for each script we detect (zh uses zh-cn, the Simplified subset
162
178
  # every CJK font that claims zh carries; the rest are the plain two-letter codes).
163
179
  FC_LANG = {"ja": "ja", "zh": "zh-cn", "ko": "ko", "ar": "ar", "he": "he", "hi": "hi", "th": "th", "ru": "ru", "el": "el",
164
- "bn": "bn", "ta": "ta", "lo": "lo", "zh-hant": "zh-tw"}
180
+ "bn": "bn", "ta": "ta", "lo": "lo", "zh-hant": "zh-tw",
181
+ "pa": "pa", "gu": "gu", "te": "te", "kn": "kn", "ml": "ml", "si": "si", "km": "km", "my": "my"}
165
182
 
166
183
 
167
184
  # Families tried in order, best first. The names are matched case-insensitively against the start
@@ -180,6 +197,14 @@ PREFERRED_FAMILIES = {
180
197
  "bn": ["Noto Sans Bengali", "Noto Serif Bengali", "Lohit Bengali", "Mukti Narrow", "Vrinda", "Nirmala UI", "FreeSerif"],
181
198
  "ta": ["Noto Sans Tamil", "Noto Serif Tamil", "Lohit Tamil", "Latha", "Nirmala UI", "FreeSerif"],
182
199
  "lo": ["Noto Sans Lao", "Noto Serif Lao", "Phetsarath OT", "Souliyo Unicode", "Saysettha OT", "DokChampa", "Leelawadee UI"],
200
+ "pa": ["Noto Sans Gurmukhi", "Noto Serif Gurmukhi", "Lohit Gurmukhi", "Raavi", "Nirmala UI", "FreeSerif"],
201
+ "gu": ["Noto Sans Gujarati", "Noto Serif Gujarati", "Lohit Gujarati", "Shruti", "Nirmala UI", "FreeSerif"],
202
+ "te": ["Noto Sans Telugu", "Noto Serif Telugu", "Lohit Telugu", "Gautami", "Nirmala UI"],
203
+ "kn": ["Noto Sans Kannada", "Noto Serif Kannada", "Lohit Kannada", "Tunga", "Nirmala UI"],
204
+ "ml": ["Noto Sans Malayalam", "Noto Serif Malayalam", "Lohit Malayalam", "Kartika", "Nirmala UI"],
205
+ "si": ["Noto Sans Sinhala", "Noto Serif Sinhala", "LKLUG", "Iskoola Pota", "Nirmala UI"],
206
+ "km": ["Noto Sans Khmer", "Noto Serif Khmer", "Khmer OS", "Leelawadee UI", "Khmer UI"],
207
+ "my": ["Noto Sans Myanmar", "Noto Serif Myanmar", "Padauk", "Myanmar Text"],
183
208
  "ru": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
184
209
  "el": ["Noto Sans", "DejaVu Sans", "Liberation Sans", "FreeSans", "FreeSerif"],
185
210
  }
@@ -201,6 +226,10 @@ WINDOWS_FONTS = {
201
226
  "bn": [("Nirmala.ttf", "Nirmala UI"), ("Nirmala.ttc", "Nirmala UI"), ("vrinda.ttf", "Vrinda")],
202
227
  "ta": [("Nirmala.ttf", "Nirmala UI"), ("Nirmala.ttc", "Nirmala UI"), ("latha.ttf", "Latha")],
203
228
  "lo": [("leelawui.ttf", "Leelawadee UI"), ("DokChamp.ttf", "DokChampa")],
229
+ # Nirmala UI carries every Indic script Windows supports, Sinhala included
230
+ **{k: [("Nirmala.ttf", "Nirmala UI"), ("Nirmala.ttc", "Nirmala UI")] for k in ("pa", "gu", "te", "kn", "ml", "si")},
231
+ "km": [("leelawui.ttf", "Leelawadee UI"), ("KhmerUI.ttf", "Khmer UI")],
232
+ "my": [("mmrtext.ttf", "Myanmar Text")],
204
233
  "ru": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
205
234
  "el": [("arial.ttf", "Arial"), ("segoeui.ttf", "Segoe UI")],
206
235
  }
@@ -233,9 +262,17 @@ _SCRIPT_RANGES = (
233
262
  ("he", ((0x0590, 0x05FF), (0xFB1D, 0xFB4F))),
234
263
  ("hi", ((0x0900, 0x097F), (0xA8E0, 0xA8FF))),
235
264
  ("bn", ((0x0980, 0x09FF),)),
265
+ ("pa", ((0x0A00, 0x0A7F),)),
266
+ ("gu", ((0x0A80, 0x0AFF),)),
236
267
  ("ta", ((0x0B80, 0x0BFF),)),
268
+ ("te", ((0x0C00, 0x0C7F),)),
269
+ ("kn", ((0x0C80, 0x0CFF),)),
270
+ ("ml", ((0x0D00, 0x0D7F),)),
271
+ ("si", ((0x0D80, 0x0DFF), (0x111E0, 0x111FF))),
237
272
  ("th", ((0x0E00, 0x0E7F),)),
238
273
  ("lo", ((0x0E80, 0x0EFF),)),
274
+ ("km", ((0x1780, 0x17FF), (0x19E0, 0x19FF))),
275
+ ("my", ((0x1000, 0x109F), (0xA9E0, 0xA9FF), (0xAA60, 0xAA7F))),
239
276
  ("ru", ((0x0400, 0x04FF), (0x0500, 0x052F), (0x2DE0, 0x2DFF))),
240
277
  ("el", ((0x0370, 0x03FF), (0x1F00, 0x1FFF))),
241
278
  )
@@ -433,6 +433,11 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
433
433
  "subtitles": len(subs),
434
434
  "chapters": len(raw.get("chapters") or []),
435
435
  "cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
436
+ # Matroska attachments (a subtitle track's fonts, A256) and the playable pictures beside
437
+ # the one probe() describes, which a tool that writes one picture leaves out (A258)
438
+ "attachments": sum(1 for s in streams if s.get("codec_type") == "attachment"),
439
+ "video_streams": sum(1 for s in streams if s.get("codec_type") == "video"
440
+ and not s.get("disposition", {}).get("attached_pic", 0)),
436
441
  "creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
437
442
  "audio_language": 1 if lang and lang.lower() != "und" else 0,
438
443
  "timecode": 1 if out["timecode"].get("value") else 0,
@@ -2253,6 +2253,45 @@ def ffmpeg_failure_extra(stderr: str) -> Dict[str, Any]:
2253
2253
  return {"hint": cls[1], "error_extra": {"reason": cls[0], "details": cls[2]}} if cls else {}
2254
2254
 
2255
2255
 
2256
+
2257
+
2258
+ MATROSKA_EXT = frozenset({".mkv", ".mka", ".mks"})
2259
+
2260
+
2261
+ def carried_stream_maps(src: Optional[str], output: str, *, chapters: bool = True, attachments: bool = True) -> List[str]:
2262
+ """What a command that keeps a source's subtitle/data streams (`-map 0:s? -map 0:d?`, or
2263
+ `-map 0`) adds so the written file carries what the source does, and nothing it does not:
2264
+
2265
+ - `-map -0:N` for an MP4/MOV chapter track. FFmpeg reads the QuickTime chapter text track as a
2266
+ data stream (codec tag `text`) and its muxer writes a new one from the chapters it is given;
2267
+ the copied one was written as a `gpmd` data track holding the old titles, so
2268
+ `metadata.py --clear-chapters` left them behind and every pass added a track (A173).
2269
+ - `-map 0:t? -c:t copy` into Matroska: a subtitle track's fonts are attachments, which no
2270
+ `-map 0:s?`/`0:d?` selects, so every tool that kept the subtitles dropped the fonts they are
2271
+ drawn with (A256). Other containers hold no attachments; source_carry reports them dropped.
2272
+
2273
+ Nothing when the source cannot be read here (the command then runs as before)."""
2274
+ if not src or not os.path.isfile(src):
2275
+ return []
2276
+ try:
2277
+ out = subprocess.run([found_exe("ffprobe"), "-v", "error", "-show_entries",
2278
+ "stream=index,codec_type,codec_tag_string:chapter=id", "-of", "json", src],
2279
+ stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
2280
+ errors="replace", timeout=_probe_limit()).stdout
2281
+ doc = json.loads(out or "{}")
2282
+ except (OSError, subprocess.TimeoutExpired, ValueError):
2283
+ return []
2284
+ streams = doc.get("streams") or []
2285
+ maps: List[str] = []
2286
+ if chapters and doc.get("chapters"):
2287
+ for s in streams:
2288
+ if s.get("codec_type") == "data" and str(s.get("codec_tag_string") or "").lower() == "text":
2289
+ maps += ["-map", f"-0:{s.get('index')}"]
2290
+ if attachments and os.path.splitext(output)[1].lower() in MATROSKA_EXT and any(s.get("codec_type") == "attachment" for s in streams):
2291
+ maps += ["-map", "0:t?", "-c:t", "copy"]
2292
+ return maps
2293
+
2294
+
2256
2295
  def fail_if_classified(proc: subprocess.CompletedProcess) -> None:
2257
2296
  """Stop with a failed attempt's own error when ffmpeg named a cause a second encode would hit
2258
2297
  the same way (a full disk, a missing encoder or filter, an unreadable input): the rule
@@ -2334,7 +2373,9 @@ def run_keeping_subtitles(cmd: List[str], output: str, relabel_timecode: bool =
2334
2373
  so a source's tmcd data track is not copied -- its label, at the source's rate, would win over
2335
2374
  the new one; the MOV/MP4 muxer builds the track again from the label."""
2336
2375
  skip_tmcd = ["-map", "-0:d:m:timecode"] if relabel_timecode else []
2337
- first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + ["-c:s", "copy", "-c:d", "copy", output], check=False)
2376
+ src = next((str(cmd[i + 1]) for i, a in enumerate(cmd[:-1]) if a == "-i"), None)
2377
+ first = run(cmd + ["-map", "0:s?", "-map", "0:d?"] + skip_tmcd + carried_stream_maps(src, output)
2378
+ + ["-c:s", "copy", "-c:d", "copy", output], check=False)
2338
2379
  if first.returncode == 0:
2339
2380
  return False
2340
2381
  if classify_ffmpeg_failure(first.stderr or "") or not _has_non_av_streams(cmd):