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 +2 -2
- package/docs/contract.md +13 -7
- package/package.json +1 -1
- package/references/gotchas.md +2 -1
- package/references/scripts.md +37 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/decision.py +19 -0
- package/scripts/_common/emit.py +8 -5
- package/scripts/_common/emoji.py +5 -1
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +5 -0
- package/scripts/_common/runner.py +42 -1
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +3 -3
- package/scripts/background.py +30 -10
- package/scripts/broll.py +8 -2
- package/scripts/caption.py +223 -71
- package/scripts/color.py +8 -5
- package/scripts/crop.py +3 -1
- package/scripts/export.py +16 -7
- package/scripts/freeze.py +6 -3
- package/scripts/graphics.py +3 -1
- package/scripts/grid.py +4 -2
- package/scripts/loop.py +9 -5
- package/scripts/metadata.py +4 -0
- package/scripts/redact.py +14 -2
- package/scripts/render.py +64 -2
- package/scripts/reverse.py +20 -7
- package/scripts/straighten.py +14 -3
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
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
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), `
|
|
980
|
-
|
|
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).
|
|
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
|
|
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.
|
|
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": [
|
package/references/gotchas.md
CHANGED
|
@@ -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
|
|
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
|
|
package/references/scripts.md
CHANGED
|
@@ -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).
|
|
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).
|
|
571
|
-
subtitle/data track rather than copy it with timestamps that
|
|
572
|
-
the pushed picture (`dropped_non_av_streams: true` in the
|
|
573
|
-
`fit.py --method speed` does;
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
1872
|
-
|
|
1873
|
-
|
|
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
|
-
|
|
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
|
package/scripts/_ass_overlay.py
CHANGED
|
@@ -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
|
-
|
|
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{\\
|
|
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 {}
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
|
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
|
|
231
|
-
parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]}
|
|
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 "
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -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,
|
package/scripts/_common/fonts.py
CHANGED
|
@@ -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"
|
|
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
|
-
|
|
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
|
)
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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
|
-
|
|
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):
|