ffmpeg-skill 3.8.8 → 3.8.10
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 +1 -1
- package/docs/contract.md +13 -3
- package/package.json +1 -1
- package/references/gotchas.md +7 -4
- package/references/scripts.md +35 -14
- package/scripts/_ass_overlay.py +7 -4
- package/scripts/_common/asr.py +12 -1
- package/scripts/_common/drawtext.py +11 -0
- package/scripts/_common/emit.py +3 -1
- package/scripts/_common/emoji.py +33 -6
- package/scripts/_common/fonts.py +42 -5
- package/scripts/_common/probe.py +27 -7
- package/scripts/_common/runner.py +133 -5
- package/scripts/_common/wrap.py +60 -18
- package/scripts/_contract.py +14 -9
- package/scripts/caption.py +260 -72
- package/scripts/color.py +36 -0
- package/scripts/cropdetect.py +24 -6
- package/scripts/cut.py +13 -5
- package/scripts/export.py +3 -2
- package/scripts/graphics.py +31 -12
- package/scripts/join.py +20 -2
- package/scripts/loudness.py +2 -1
- package/scripts/metadata.py +11 -1
- package/scripts/overlay.py +11 -6
- package/scripts/probe.py +8 -0
- package/scripts/render.py +21 -3
- package/scripts/silence.py +27 -3
- package/scripts/stabilize.py +6 -2
- package/scripts/sync.py +8 -3
- package/scripts/waveform.py +13 -6
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
|
|
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`.
|
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.10`) | 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.10", "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"},
|
|
@@ -878,7 +878,17 @@ An ffmpeg run that fails keeps `kind: ffmpeg` and exit code 1, and since 3.1.0 c
|
|
|
878
878
|
(`_common.runner.FFMPEG_FAILURE_CLASSES`): `disk_full` ("No space left on device"),
|
|
879
879
|
`encoder_missing` and `filter_missing` (`details.name`; the build lacks it, `doctor` says how to
|
|
880
880
|
get one that has it), `input_incomplete` ("moov atom not found": a cut-off recording or an
|
|
881
|
-
unfinished copy, which `--repair` cannot rebuild) and `input_unreadable`.
|
|
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
|
|
882
892
|
source's subtitle and data streams retried the whole encode without them after any failure; it
|
|
883
893
|
now retries only when the source has such a stream and the failure is none of these.
|
|
884
894
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.8.
|
|
3
|
+
"version": "3.8.10",
|
|
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
|
|
|
@@ -271,9 +272,11 @@ directory of PNGs — and on most builds only the second one exists.
|
|
|
271
272
|
to 48 pixels: invalid library handle`, and at the font's own strike
|
|
272
273
|
`Monocromatic (1bpp) fonts are not supported.`) and no file is written. That
|
|
273
274
|
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
|
|
275
|
-
|
|
276
|
-
`
|
|
275
|
+
- **PNGs**: `--emoji-assets DIR`, a directory of files named by code point:
|
|
276
|
+
Twemoji's lowercase hex joined by `-` (`1f389.png`, `1f1ef-1f1f5.png`,
|
|
277
|
+
`1f469-200d-1f4bb.png`), or Noto Emoji's `emoji_u` names, each code point at
|
|
278
|
+
least four hex digits, joined by `_` (`emoji_u1f389.png`,
|
|
279
|
+
`emoji_u0031_20e3.png`). Twemoji's `assets/72x72`
|
|
277
280
|
(CC-BY 4.0) and Noto Emoji's `png/128` (OFL/Apache-2.0) are the two people
|
|
278
281
|
already have. **The skill never downloads anything**: a missing directory is
|
|
279
282
|
`kind: input`, never a silent fetch. The same directory can come from
|
package/references/scripts.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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);
|
|
@@ -1735,7 +1740,9 @@ paraphrase a caption to make it fit. Below the floor the cue is split exactly as
|
|
|
1735
1740
|
before and `fit_exhausted: true` says so, with `--min-size` named as the flag
|
|
1736
1741
|
that would go smaller and `|` as the manual break. `--min-size` above `--size`
|
|
1737
1742
|
is a `kind: input` refusal. `--mode mux` is unaffected: soft subtitles carry no
|
|
1738
|
-
size, so
|
|
1743
|
+
size, so a muxed track is neither fitted, wrapped nor split (a player wraps it at
|
|
1744
|
+
the size it draws it; through 3.8.7 it was wrapped at the burn-in size), and
|
|
1745
|
+
`--max-lines` or `--wrap measured` with `--mode mux` is refused.
|
|
1739
1746
|
|
|
1740
1747
|
Results, alongside the existing caption stats:
|
|
1741
1748
|
`fit_size`, `size_requested`, `size_used`, `size_floor`, `size_pct_height`,
|
|
@@ -1795,8 +1802,8 @@ stays text (escaped), and under `--karaoke` `<font color>` is dropped for the hi
|
|
|
1795
1802
|
colours. The ASS is kept next to the
|
|
1796
1803
|
user can hand-tune timings and re-run with `--ass`.
|
|
1797
1804
|
Emoji (1.15): `--emoji-assets DIR` is a directory of PNGs named by code point
|
|
1798
|
-
(`1f389.png`, `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png` — the Twemoji
|
|
1799
|
-
convention), also read from `brand.json` `styles.caption.emoji_assets` and
|
|
1805
|
+
(`1f389.png`, `1f1ef-1f1f5.png`, `1f469-200d-1f4bb.png` — the Twemoji
|
|
1806
|
+
convention; Noto Emoji's `emoji_u1f389.png`, `emoji_u0031_20e3.png` are found too), also read from `brand.json` `styles.caption.emoji_assets` and
|
|
1800
1807
|
`FFMPEG_SKILL_EMOJI_ASSETS`. With one, the cue text keeps its place in the ASS
|
|
1801
1808
|
with an invisible placeholder reserving the emoji's box and each PNG is
|
|
1802
1809
|
composited on top (`--emoji-scale` sizes the box, `--emoji-max` caps the count).
|
|
@@ -1841,10 +1848,14 @@ never changes: **R1** never inside a word, and a hyphenated token may break only
|
|
|
1841
1848
|
after its hyphen (never after a non-breaking `‑`, nor a leading/trailing
|
|
1842
1849
|
one); **R2** no line that is a lone digit, one or two punctuation characters, or
|
|
1843
1850
|
a single kana, checked at every boundary rather than only the last; **R3** for
|
|
1844
|
-
Japanese and Chinese, a break is preferred after
|
|
1851
|
+
Japanese and Chinese, a break is preferred after `。、!?」』),;:` and (Japanese
|
|
1845
1852
|
only) after a particle — a particle attaches to the word before it, so kinsoku
|
|
1846
1853
|
forbids opening a line with one — discouraged between a kanji stem and its
|
|
1847
|
-
okurigana, and forbidden before a small kana,
|
|
1854
|
+
okurigana, and forbidden before a small kana, `ー`, a closing bracket or the
|
|
1855
|
+
fullwidth `,;:.` (and after an opening bracket); in `phrase` mode the first
|
|
1856
|
+
fill does not take such a break either while a permitted one fits, so a cue of three or more lines, and each
|
|
1857
|
+
piece of a cue split past `--max-lines`, keeps the punctuation on the line it
|
|
1858
|
+
ends; **R4**
|
|
1848
1859
|
for en/es/pt/fr/de/it, an article or preposition is kept with the phrase it
|
|
1849
1860
|
governs: the break before it is preferred and the break after it penalised (a
|
|
1850
1861
|
frozen table, matched case-folded; `--lang`, else the script detector, picks the
|
|
@@ -1863,9 +1874,10 @@ toggleable streams and copies the video and audio bit for bit. The suffix splits
|
|
|
1863
1874
|
on the last colon, and only when the tail is a BCP-47-shaped code *and* the whole
|
|
1864
1875
|
token is not itself a file on disk, so `C:\subs\en.srt` and a file named
|
|
1865
1876
|
`a:b.srt` are never mangled; a single `--srt` with no suffix still takes
|
|
1866
|
-
`--language
|
|
1867
|
-
|
|
1868
|
-
|
|
1877
|
+
`--language` (in a mux only: a burn reads `--language` as the script and wrap
|
|
1878
|
+
hint, not as a track tag). `--offset` shifts every added track (each later one as a
|
|
1879
|
+
`<output>_trackN.srt` copy); `--min-duration` lengthens the first `--srt`'s cues
|
|
1880
|
+
only, and a note says so when it changed them. `--track-title` names a track (repeated in `--srt` order;
|
|
1869
1881
|
otherwise a frozen display-name table, and a code the table does not know gets
|
|
1870
1882
|
the code itself — never a guessed or translated name), `--default-track LANG`
|
|
1871
1883
|
marks one for auto-selection. Every other new track is explicitly marked *not*
|
|
@@ -1888,7 +1900,9 @@ language.
|
|
|
1888
1900
|
|
|
1889
1901
|
Fonts by script (1.12): with no `--font` and no font named in your brand file, the
|
|
1890
1902
|
script of the cue text (Japanese, Chinese, Korean, Arabic, Hebrew, Devanagari,
|
|
1891
|
-
|
|
1903
|
+
Bengali, Gurmukhi, Gujarati, Tamil, Telugu, Kannada, Malayalam, Sinhala, Thai,
|
|
1904
|
+
Lao, Khmer, Myanmar, Cyrillic, Greek) picks a font file
|
|
1905
|
+
that covers it, logged as `font: <file>
|
|
1892
1906
|
(covers ko)`. Han-only text is read as Chinese unless `--lang ja|ko` (or
|
|
1893
1907
|
brand.json `"lang"`) says otherwise; `--language` is the same flag, and still
|
|
1894
1908
|
tags the subtitle stream under `--mode mux` and sets `--transcribe`'s language.
|
|
@@ -1902,7 +1916,14 @@ does not cover the script gets one line and a font resolved by script anyway. `d
|
|
|
1902
1916
|
|
|
1903
1917
|
`--karaoke` uses real per-word timings when the transcript has them (a whisper
|
|
1904
1918
|
`<stem>.json` or `<stem>.words.json` next to the SRT, `{"segments": [{"words":
|
|
1905
|
-
[{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides.
|
|
1919
|
+
[{"word", "start", "end"}]}]}`); otherwise `--karaoke-timing` decides. The
|
|
1920
|
+
highlight waits for the first word (the silence before it is an empty `\k` in
|
|
1921
|
+
`sweep`, an event with no active word in `word`), and `--offset` moves the word
|
|
1922
|
+
timings with the cues. With `--karaoke-style word` each word is its own event, and
|
|
1923
|
+
`--animate` is spread over them as the cue's one envelope: it fades in at the
|
|
1924
|
+
first event and out at the last, and a slide or a pop carries on across them.
|
|
1925
|
+
`--animate slide` ends on the line's own anchor (`--position`, a cue's `{\anN}`,
|
|
1926
|
+
the side margins).
|
|
1906
1927
|
|
|
1907
1928
|
`--mode burn` (default) renders subtitles into the picture and always
|
|
1908
1929
|
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"
|
package/scripts/_common/asr.py
CHANGED
|
@@ -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
|
-
|
|
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:
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
-
"
|
|
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:
|
package/scripts/_common/emoji.py
CHANGED
|
@@ -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
|
|
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
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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) "
|
|
@@ -360,7 +383,9 @@ def emoji_support(assets: "Optional[str]" = None, probe: bool = True, fonts_dir:
|
|
|
360
383
|
mode = "png"
|
|
361
384
|
elif family and libass_glyph is False:
|
|
362
385
|
mode = "none"
|
|
363
|
-
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)
|
|
364
389
|
mode = "mono"
|
|
365
390
|
elif not fc_answered:
|
|
366
391
|
# No fontconfig to ask (a static ffmpeg build, a bare container): the PNG path needs none,
|
|
@@ -381,6 +406,8 @@ def emoji_support(assets: "Optional[str]" = None, probe: bool = True, fonts_dir:
|
|
|
381
406
|
detail = f"{family} installed; the colour render probe was not run"
|
|
382
407
|
elif assets_dir:
|
|
383
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"
|
|
384
411
|
else:
|
|
385
412
|
detail = "no colour emoji family installed and no --emoji-assets directory"
|
|
386
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
|
@@ -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):
|