ffmpeg-skill 3.2.3 → 3.3.0
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 +12 -1
- package/bin/install.js +11 -1
- package/docs/contract.md +15 -2
- package/package.json +2 -1
- package/references/gotchas.md +7 -0
- package/scripts/_common/color.py +18 -3
- package/scripts/_common/emit.py +71 -0
- package/scripts/_common/probe.py +21 -0
- package/scripts/_common/runner.py +8 -3
- package/scripts/_contract.py +2 -0
- package/scripts/caption.py +6 -1
- package/scripts/stabilize.py +14 -3
package/README.md
CHANGED
|
@@ -20,6 +20,7 @@ No cloud · No API keys · Python standard library</p>
|
|
|
20
20
|
|
|
21
21
|
<p align="center">
|
|
22
22
|
<a href="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml"><img src="https://github.com/kajisho5/ffmpeg-skill/actions/workflows/ci.yml/badge.svg" alt="tests"></a>
|
|
23
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/kajisho5/ffmpeg-skill"><img src="https://api.scorecard.dev/projects/github.com/kajisho5/ffmpeg-skill/badge" alt="OpenSSF Scorecard"></a>
|
|
23
24
|
<a href="https://www.npmjs.com/package/ffmpeg-skill"><img src="https://img.shields.io/npm/v/ffmpeg-skill" alt="npm"></a>
|
|
24
25
|
<a href="#ffmpeg-compatibility"><img src="https://img.shields.io/badge/ffmpeg-6.1%20%7C%207.1%20%7C%208%20%7C%209%20tested-orange" alt="FFmpeg 6.1, 7.1, 8 and 9 tested in CI"></a>
|
|
25
26
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green" alt="MIT"></a>
|
|
@@ -595,6 +596,16 @@ claude plugin marketplace add kajisho5/ffmpeg-skill # older v
|
|
|
595
596
|
claude plugin install ffmpeg-skill@ffmpeg-skill # then install from it
|
|
596
597
|
```
|
|
597
598
|
|
|
599
|
+
The same repository installs as a plugin in two more hosts (checked against Codex CLI 0.167 and Gemini CLI 0.68):
|
|
600
|
+
|
|
601
|
+
```bash
|
|
602
|
+
codex plugin marketplace add kajisho5/ffmpeg-skill # Codex: add the marketplace,
|
|
603
|
+
codex plugin add ffmpeg-skill@ffmpeg-skill # then the plugin
|
|
604
|
+
gemini extensions install https://github.com/kajisho5/ffmpeg-skill # Gemini CLI
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Those hosts find a plugin's skill only at `skills/ffmpeg-skill/SKILL.md`, a generated copy of the root `SKILL.md` (`.github/scripts/build_plugin_skill.py`; a test fails when it is stale). A Cursor plugin manifest (`.cursor-plugin/plugin.json`) is in the repository for the Cursor Marketplace; it follows Cursor's reference and has not been through their review yet.
|
|
608
|
+
|
|
598
609
|
The plugin namespaces the skill as `ffmpeg-skill:ffmpeg-skill`. The manifests are [.claude-plugin/plugin.json](.claude-plugin/plugin.json), whose version follows every release automatically, and [.claude-plugin/marketplace.json](.claude-plugin/marketplace.json), which lists the plugin from the repository root. Before 2.17.1 the README's one-line `claude plugin install kajisho5/ffmpeg-skill` could not work, because there was no marketplace file.
|
|
599
610
|
|
|
600
611
|
Without Node: clone this repository and copy `SKILL.md`, `package.json`, `scripts/`, `templates/`, `references/`, `docs/` and `mcp/` into your agent's skills directory (the same set `npx ffmpeg-skill` installs; without `templates/` no `--template` renders, and without `package.json` the contract reports its version as unknown).
|
|
@@ -629,7 +640,7 @@ FFmpeg itself:
|
|
|
629
640
|
|
|
630
641
|
## Stability
|
|
631
642
|
|
|
632
|
-
3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
|
|
643
|
+
3.x keeps every tool name, CLI argument, JSON output key and exit code working: nothing is removed or renamed, and nothing optional becomes required, until 4.0. The full list of what is promised and what is not, and the three-step deprecation policy, is in [docs/contract.md](docs/contract.md#stability-guarantee-3x). It is enforced by a test that pins every tool's argument names against a snapshot, so a breaking change fails CI instead of slipping into a patch. Releases are batched: related fixes and features ship together as one version rather than one version per merged pull request, so a dependent project sees fewer, larger steps. Pin a tagged version, not `main`. 2.0.0 made the five changes announced since 1.10.0 -- `--crf` removed in favour of `--quality` (except on `export.py`, whose preset chooses the encoder), an existing output refused without `--overwrite`, `probe`'s `hdr` narrowed to a real HDR signal, `json`/`progress` out of the MCP schema, the `result_v2` preview withdrawn -- listed with what to change in [docs/contract.md](docs/contract.md#what-20-changed) and under `removed` in `contract --json`. 3.0.0 is the second break: it removed FFmpeg 5.x and Python 3.9 (announced in 2.14.0 and 2.10.9), made `cut.py` keep an `.mp4`/`.mov` copy's edit list, keep an SDR HEVC source HEVC and use `--vfr-guard sampled` by default (`--no-edit-list`, `--no-keep-hevc` and `--vfr-guard average` restore 2.x), and changed what `cut.py`'s `precision` (now the least exact segment's) and `keyframe_snapped` (now equal to `start_snapped`) mean (`contract_version` 2.0) -- see [docs/contract.md](docs/contract.md#what-30-changed).
|
|
633
644
|
|
|
634
645
|
## Development
|
|
635
646
|
|
package/bin/install.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* npx ffmpeg-skill --uninstall # remove from the selected targets
|
|
15
15
|
* npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
|
|
16
16
|
* npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
|
|
17
|
+
* npx ffmpeg-skill mcp # stdio MCP server (python3 mcp/server.py); registry clients pass `mcp`
|
|
17
18
|
*
|
|
18
19
|
* Already installed? re-run `npx ffmpeg-skill` to refresh ~/.claude/skills/ffmpeg-skill
|
|
19
20
|
* Copies are not updated automatically.
|
|
@@ -98,7 +99,8 @@ function warnMissingPython(platform, spawn, warn) {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
function main() {
|
|
101
|
-
// `contract` / `doctor` are answered by scripts/_contract.py
|
|
102
|
+
// `contract` / `doctor` are answered by scripts/_contract.py. `mcp` is the stdio server
|
|
103
|
+
// (server.json's packageArguments). Everything else installs.
|
|
102
104
|
if (args[0] === 'contract' || args[0] === 'doctor') {
|
|
103
105
|
const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
|
|
104
106
|
if (py.missing) {
|
|
@@ -107,6 +109,14 @@ if (args[0] === 'contract' || args[0] === 'doctor') {
|
|
|
107
109
|
}
|
|
108
110
|
process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
|
|
109
111
|
}
|
|
112
|
+
if (args[0] === 'mcp') {
|
|
113
|
+
const py = runWithPython([path.join(ROOT, 'mcp', 'server.py'), ...args.slice(1)], process.platform, spawnSync);
|
|
114
|
+
if (py.missing) {
|
|
115
|
+
console.error(`error: could not find a Python 3.10+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.10+ and ensure it is on PATH.`);
|
|
116
|
+
process.exit(127);
|
|
117
|
+
}
|
|
118
|
+
process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
|
|
119
|
+
}
|
|
110
120
|
|
|
111
121
|
const home = os.homedir();
|
|
112
122
|
const targets = [];
|
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.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`3.3.0`) | 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.
|
|
152
|
+
"skill": {"id": "ffmpeg-skill", "version": "3.3.0", "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"},
|
|
@@ -918,6 +918,19 @@ that is so: once a tool has written its deliverable and runs a further step on i
|
|
|
918
918
|
says it is kept without the step that was stopped, and the document carries it as `output`
|
|
919
919
|
(3.1.6, #415).
|
|
920
920
|
|
|
921
|
+
**Source carry-over.** Every tool that writes a media file compares what its first input carried
|
|
922
|
+
besides the picture and the sound with the file it wrote, and says so in `source_carry:
|
|
923
|
+
{kept: [...], dropped: [...]}`. The items are `subtitles`, `chapters`, `cover_art`,
|
|
924
|
+
`audio_channels` (a surround source only: more than two), `creation_time`, `audio_language` and
|
|
925
|
+
`timecode`. The key is present only when the source had at least one of them, so a result for a
|
|
926
|
+
plain source is unchanged. It is absent under `--dry-run`, for a still or GIF output, and when two
|
|
927
|
+
or more inputs have a picture or sound (which one's items the output should carry is the tool's
|
|
928
|
+
choice). An item bound to a stream the output does not have at all is not compared (a sound-only
|
|
929
|
+
extract has no subtitle track to lose). It is a report, not a check: `verified` does not depend on
|
|
930
|
+
it. A dropped `subtitles`, `chapters`, `cover_art` or `audio_channels` also records one
|
|
931
|
+
`source_items_dropped` warning and a notes line; a subtitle track the tool already reports through
|
|
932
|
+
`dropped_non_av_streams` is not warned about twice, and a lost tag is listed without a warning.
|
|
933
|
+
|
|
921
934
|
**Diagnostics.** What a run decided on its own -- a warning, a fallback it took, a step it
|
|
922
935
|
skipped -- is recorded as it happens (a stderr line too) and lands in the document as
|
|
923
936
|
`diagnostics: {warnings, fallbacks, skipped}`, each entry `{code, message, ...facts}`; it is
|
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
|
+
"mcpName": "io.github.kajisho5/ffmpeg-skill",
|
|
4
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.",
|
|
5
6
|
"keywords": [
|
|
6
7
|
"ffmpeg",
|
package/references/gotchas.md
CHANGED
|
@@ -54,6 +54,13 @@ on a stream copy as on a re-encode
|
|
|
54
54
|
(`cut.py` reports it: `dropped_audio_streams`, an `audio_tracks_dropped` warning). Keep ProRes masters
|
|
55
55
|
at source colour: `export.py --preset prores` does not retag.
|
|
56
56
|
|
|
57
|
+
What else the source carried is compared on every write: `source_carry: {kept, dropped}` lists
|
|
58
|
+
subtitles, chapters, a cover picture, surround channels, `creation_time`, the audio language tag
|
|
59
|
+
and the start timecode, and a lost subtitle track, chapter list, cover picture or surround layout
|
|
60
|
+
is a `source_items_dropped` warning. Read it before telling the user the file is complete; to
|
|
61
|
+
carry the items over, use the tool that keeps them (`export.py` keeps subtitle tracks,
|
|
62
|
+
`metadata.py` writes chapters and tags).
|
|
63
|
+
|
|
57
64
|
Re-encoding an HDR (iPhone, HDR10) source through an SDR path flattens the
|
|
58
65
|
colours. The scripts keep HDR; if you hand-write ffmpeg (never do — see SKILL.md),
|
|
59
66
|
do not tag BT.709 on BT.2020 pixels.
|
package/scripts/_common/color.py
CHANGED
|
@@ -69,8 +69,11 @@ def is_rgb(meta: Optional[Dict[str, Any]]) -> bool:
|
|
|
69
69
|
return bool(_RGB_PIX_FMT_RE.match(str(((meta or {}).get("video") or {}).get("pix_fmt") or "")))
|
|
70
70
|
|
|
71
71
|
|
|
72
|
-
|
|
73
|
-
|
|
72
|
+
# An odd-sized BT.601 source is padded to even for the colorspace filter and cropped back to its
|
|
73
|
+
# own size after it (bt709_matrix_filter): both wrap the conversion in the one -vf the merge finds.
|
|
74
|
+
_EVEN_PAD = "pad=ceil(iw/2)*2:ceil(ih/2)*2:0:0,"
|
|
75
|
+
_MATRIX_FILTER_RE = re.compile(r"^(?:(?:%s)?colorspace=ispace=(?:%s):iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709(?:,crop=iw-[01]:ih-[01]:0:0:exact=1)?|(?:format=rgb24,)?%s)$"
|
|
76
|
+
% (re.escape(_EVEN_PAD), "|".join(NON_BT709_MATRICES), re.escape(rgb_to_bt709_filter())))
|
|
74
77
|
_INPUT_VIDEO_REF = re.compile(r"\[(\d+):v(?::0)?\]")
|
|
75
78
|
|
|
76
79
|
|
|
@@ -102,7 +105,19 @@ def bt709_matrix_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
|
|
102
105
|
return ("format=rgb24," if v.get("pix_fmt") == "pal8" else "") + rgb_to_bt709_filter()
|
|
103
106
|
if matrix not in NON_BT709_MATRICES:
|
|
104
107
|
return None
|
|
105
|
-
|
|
108
|
+
# the colorspace filter refuses an odd width or height outright ("Invalid odd size"), and a
|
|
109
|
+
# 4:2:0 JPEG can have one (a phone screenshot, #437). The frame is padded by one row and/or
|
|
110
|
+
# column, converted, and cropped back to its own size: no source pixel is lost or resampled
|
|
111
|
+
# (a 4:2:0 frame's chroma planes are the same size either way), so a caller's own crop or
|
|
112
|
+
# scale sees the frame it measured. Nothing changes for an even-sized source. FFmpeg rotates
|
|
113
|
+
# a rotated source before the graph, so the odd axis is read after the same swap.
|
|
114
|
+
w, h = int(v.get("width") or 0), int(v.get("height") or 0)
|
|
115
|
+
if int(v.get("rotation") or 0) % 180 == 90:
|
|
116
|
+
w, h = h, w
|
|
117
|
+
conv = f"colorspace=ispace={matrix}:iprimaries=bt709:itrc=bt709:space=bt709:primaries=bt709:trc=bt709"
|
|
118
|
+
if w % 2 or h % 2:
|
|
119
|
+
return f"{_EVEN_PAD}{conv},crop=iw-{w % 2}:ih-{h % 2}:0:0:exact=1"
|
|
120
|
+
return conv
|
|
106
121
|
|
|
107
122
|
|
|
108
123
|
def bt709_label_filter(meta: Optional[Dict[str, Any]]) -> Optional[str]:
|
package/scripts/_common/emit.py
CHANGED
|
@@ -163,6 +163,74 @@ def _report_dropped_hdr10_metadata(meta: Dict[str, Any], ctx: "Context") -> None
|
|
|
163
163
|
ctx.notes.append(note_line)
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
# source_carry: the items compared, in report order. The first group is content a viewer or
|
|
167
|
+
# listener notices missing and raises the warning; the second is tags, listed but never warned
|
|
168
|
+
# about (every camera file has a creation_time, so a warning for it would fire on every run).
|
|
169
|
+
CARRY_CONTENT = ("subtitles", "chapters", "cover_art", "audio_channels")
|
|
170
|
+
CARRY_TAGS = ("creation_time", "audio_language", "timecode")
|
|
171
|
+
_CARRY_NAMES = {"subtitles": "subtitle track", "chapters": "chapters", "cover_art": "cover picture",
|
|
172
|
+
"audio_channels": "audio channels"}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def source_carry(output: Optional[str], ctx: "Context", extra: Dict[str, Any]) -> "Optional[Dict[str, List[str]]]":
|
|
176
|
+
"""What the first input carried besides its picture and sound, and whether the written file
|
|
177
|
+
still has it (#401): {"kept": [...], "dropped": [...]}, or None when the source had none of
|
|
178
|
+
it or the two files are not comparable. Measured on the written file, so the tool, encoder or
|
|
179
|
+
FFmpeg version that dropped it does not matter. A report, not a check: `verified` is not
|
|
180
|
+
touched. Not compared: two or more picture or sound sources (which one's items the output
|
|
181
|
+
should carry is the tool's choice), an output that is not a media file, and items bound to a
|
|
182
|
+
stream the output does not have at all (a sound-only extract has no subtitle track or
|
|
183
|
+
timecode to lose; a picture-only output has no audio channels). Audio channels are an item
|
|
184
|
+
only for a surround source (more than two)."""
|
|
185
|
+
from .probe import MEDIA_EXT, _STILL_EXT, carry_inventory
|
|
186
|
+
first = getattr(STATE, "first_input", None)
|
|
187
|
+
if not first or not output:
|
|
188
|
+
return None
|
|
189
|
+
# a tool that reads its own output back (cut.py measures the cut it wrote) probed it as an
|
|
190
|
+
# input too: that is not a second source
|
|
191
|
+
own = os.path.realpath(output)
|
|
192
|
+
if any(len(set(getattr(STATE, name, None) or ()) - {own}) > 1 for name in ("video_inputs", "audio_inputs")):
|
|
193
|
+
return None
|
|
194
|
+
ext = os.path.splitext(output)[1].lower()
|
|
195
|
+
if ext not in MEDIA_EXT or ext in _STILL_EXT:
|
|
196
|
+
return None # a still, a contact sheet or an animated GIF holds none of these
|
|
197
|
+
src, out = carry_inventory(first[0]), carry_inventory(output)
|
|
198
|
+
if not src or not out or os.path.realpath(first[0]) == os.path.realpath(output):
|
|
199
|
+
return None
|
|
200
|
+
kept: List[str] = []
|
|
201
|
+
dropped: List[str] = []
|
|
202
|
+
for item in CARRY_CONTENT + CARRY_TAGS:
|
|
203
|
+
if not src.get(item):
|
|
204
|
+
continue
|
|
205
|
+
if item == "audio_channels" and src[item] <= 2:
|
|
206
|
+
continue # mono and stereo are what every tool writes; only a surround layout can be folded down
|
|
207
|
+
if item in ("subtitles", "timecode") and src["has_video"] and not out["has_video"]:
|
|
208
|
+
continue
|
|
209
|
+
if item in ("audio_channels", "audio_language") and not out["has_audio"]:
|
|
210
|
+
continue
|
|
211
|
+
(kept if out.get(item, 0) >= src[item] else dropped).append(item)
|
|
212
|
+
if not kept and not dropped:
|
|
213
|
+
return None
|
|
214
|
+
said = set()
|
|
215
|
+
if extra.get("dropped_non_av_streams") or any(w.get("code") == "non_av_streams_dropped" for w in ctx.fallbacks):
|
|
216
|
+
said.add("subtitles") # the tool's own field already says the track went
|
|
217
|
+
loud = [i for i in dropped if i in CARRY_CONTENT and i not in said]
|
|
218
|
+
if loud:
|
|
219
|
+
parts = []
|
|
220
|
+
for i in loud:
|
|
221
|
+
if i == "audio_channels":
|
|
222
|
+
parts.append(f"{src[i] - out.get(i, 0)} of its {src[i]} audio channels")
|
|
223
|
+
else:
|
|
224
|
+
parts.append("its " + _CARRY_NAMES[i])
|
|
225
|
+
warn("the source carried " + ", ".join(parts) + " that this output does not have "
|
|
226
|
+
"(source_carry lists everything kept and dropped)",
|
|
227
|
+
"source_items_dropped", ctx=ctx, dropped=loud)
|
|
228
|
+
note_line = ctx.warnings[-1]["message"] if ctx.warnings else None
|
|
229
|
+
if note_line and note_line not in ctx.notes:
|
|
230
|
+
ctx.notes.append(note_line)
|
|
231
|
+
return {"kept": kept, "dropped": dropped}
|
|
232
|
+
|
|
233
|
+
|
|
166
234
|
def refuse(kind: str, reason: str, msg: str, *, hint: Optional[str] = None, code: int = 1,
|
|
167
235
|
ctx: "Optional[Context]" = None, extra: "Optional[Dict[str, Any]]" = None, **details: Any) -> "None":
|
|
168
236
|
"""die() for a refusal a caller has to act on: `error.reason` is a stable snake_case code for
|
|
@@ -710,6 +778,9 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
710
778
|
meta = verify_output(output) # dies (status: failed, kind: output) if the artifact is unusable
|
|
711
779
|
_report_dropped_audio_tracks(meta, ctx)
|
|
712
780
|
_report_dropped_hdr10_metadata(meta, ctx)
|
|
781
|
+
carry = source_carry(output, ctx, extra)
|
|
782
|
+
if carry and "source_carry" not in extra:
|
|
783
|
+
extra["source_carry"] = carry
|
|
713
784
|
if ctx.json:
|
|
714
785
|
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": ctx.dry_run, "commands": list(ctx.commands)}
|
|
715
786
|
if meta:
|
package/scripts/_common/probe.py
CHANGED
|
@@ -402,9 +402,30 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
402
402
|
(out.get("video") or {}).get("hdr10_static"))
|
|
403
403
|
if role == "input" and out["timecode"].get("value") and getattr(STATE, "source_timecode", None) is None:
|
|
404
404
|
STATE.source_timecode = (out["timecode"]["value"], duration)
|
|
405
|
+
# what a source carries besides its picture and sound, for emit()'s source_carry comparison:
|
|
406
|
+
# kept beside the probe, not in it, so no probe document changes
|
|
407
|
+
lang = (audio.get("tags") or {}).get("language") if audio else None
|
|
408
|
+
_CARRY[os.path.realpath(path)] = {
|
|
409
|
+
"subtitles": len(subs),
|
|
410
|
+
"chapters": len(raw.get("chapters") or []),
|
|
411
|
+
"cover_art": sum(1 for s in streams if s.get("codec_type") == "video" and s.get("disposition", {}).get("attached_pic", 0)),
|
|
412
|
+
"creation_time": 1 if any(k.lower() == "creation_time" for k in (fmt.get("tags") or {})) else 0,
|
|
413
|
+
"audio_language": 1 if lang and lang.lower() != "und" else 0,
|
|
414
|
+
"timecode": 1 if out["timecode"].get("value") else 0,
|
|
415
|
+
"audio_channels": (_to_int(audio.get("channels")) or 0) if audio else 0,
|
|
416
|
+
"has_video": bool(video), "has_audio": bool(audio),
|
|
417
|
+
}
|
|
405
418
|
return out
|
|
406
419
|
|
|
407
420
|
|
|
421
|
+
_CARRY: Dict[str, Dict[str, Any]] = {}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def carry_inventory(path: str) -> "Optional[Dict[str, Any]]":
|
|
425
|
+
"""The carry-over items probe() last read off `path` (see emit()'s source_carry), or None."""
|
|
426
|
+
return _CARRY.get(os.path.realpath(path)) if path else None
|
|
427
|
+
|
|
428
|
+
|
|
408
429
|
def _hdr10_static(ffprobe: str, path: str, stream: Dict[str, Any]) -> "Optional[Dict[str, Any]]":
|
|
409
430
|
"""A PQ stream's mastering display and content light level, from the stream (an mdcv/clli box,
|
|
410
431
|
a Matroska colour element) or else its first frame (the HEVC SEI / AV1 metadata OBU): one
|
|
@@ -1067,9 +1067,14 @@ class _OutputLock:
|
|
|
1067
1067
|
def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
1068
1068
|
"""An odd-sized source (641x359 screen captures, some 4:4:4 masters) fails every yuv420p
|
|
1069
1069
|
encode with "width/height not divisible by 2" (sweep F8, 15 tools). Return the same command
|
|
1070
|
-
with an even-dimension scale
|
|
1070
|
+
with an even-dimension scale appended to its -vf chain (or a new -vf when the command had
|
|
1071
1071
|
none); None when the failure is something else or the graph is a -filter_complex the
|
|
1072
|
-
caller has to fix itself.
|
|
1072
|
+
caller has to fix itself.
|
|
1073
|
+
|
|
1074
|
+
Appended, not prepended: what the encoder refuses is the frame the chain hands it, and a
|
|
1075
|
+
chain can make an even frame odd again (squaring non-square pixels to 636x1401, a conversion
|
|
1076
|
+
that restores the source's own size). Through 3.2.3 the scale went first, so caption.py on an
|
|
1077
|
+
odd-sized clip retried and failed the same way (#438)."""
|
|
1073
1078
|
if "not divisible by 2" not in stderr or EVEN_SCALE in cmd or any(EVEN_SCALE in a for a in cmd):
|
|
1074
1079
|
return None
|
|
1075
1080
|
if "-filter_complex" in cmd:
|
|
@@ -1077,7 +1082,7 @@ def _odd_dimension_retry(cmd: List[str], stderr: str) -> Optional[List[str]]:
|
|
|
1077
1082
|
new = list(cmd)
|
|
1078
1083
|
if "-vf" in new:
|
|
1079
1084
|
i = new.index("-vf") + 1
|
|
1080
|
-
new[i] =
|
|
1085
|
+
new[i] = new[i] + "," + EVEN_SCALE
|
|
1081
1086
|
return new
|
|
1082
1087
|
if "-c:v" in new and new[new.index("-c:v") + 1] == "copy":
|
|
1083
1088
|
return None
|
package/scripts/_contract.py
CHANGED
|
@@ -472,6 +472,7 @@ DIAGNOSTIC_CODES = {
|
|
|
472
472
|
# fonts and text
|
|
473
473
|
"emoji_not_drawn": ("warnings", "caption.py --emoji auto: no glyph, colour path or PNG asset draws these emoji here (doctor fonts.emoji mode none), so they were left out of the drawn text; {count, reason}"),
|
|
474
474
|
"hdr10_metadata_dropped": ("warnings", "a PQ output measured without the source's HDR10 mastering-display and/or content-light metadata (an encoder with no option for it, e.g. prores_ks or libaom on FFmpeg before 7); {dropped, codec}"),
|
|
475
|
+
"source_items_dropped": ("warnings", "the written file lacks a subtitle track, chapters, a cover picture or surround channels the first input carried (source_carry lists everything kept and dropped, tags included); not repeated for a subtitle track the tool already reports through dropped_non_av_streams; {dropped}"),
|
|
475
476
|
"audio_tracks_dropped": ("warnings", "the source has more audio tracks than the output kept (the tools write one; cut.py also reports dropped_audio_streams). Not compared when two or more inputs have sound (grid --audio-from, a mix): which source the output carries is the tool's choice; {source, kept, dropped}"),
|
|
476
477
|
"grid_no_drawtext": ("fallbacks", "grid.py --label auto on a build without the drawtext filter: the cells were not labelled, as with --label none"),
|
|
477
478
|
"scenes_sheet_no_drawtext": ("fallbacks", "scenes.py --sheet on a build without the drawtext filter: the sheet was drawn without timecode stamps, as with --no-timecode"),
|
|
@@ -614,6 +615,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
|
|
|
614
615
|
"verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
|
|
615
616
|
"verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}, "stage": {"type": "string"}, "property": {"type": "string"}, "expected": {}, "measured": {}, "delta": {}, "tolerance": {}, "provenance": {"enum": ["specification", "user_specified", "independent_measurement", "input_premeasurement"]}, "attribution": {"enum": ["artifact", "reference", "instrument", "provenance", "unresolved"]}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets; ok false with reason not_measured when it could not be read), check (render), report.py's sections (loudness, compliance, contact_sheet) and batch.py's items (item). render.py also lists every stage's own steps beyond its artifact probe, tagged with `stage` (a cached stage's as recorded), so its `verified` is the conjunction of every stage's. Since 2.18 a step may carry the property it measured with expected, measured, delta, tolerance and the provenance of the expected value; cut, join and export add a `streams` step comparing the output's video/audio streams with the input's; a step with ok false carries `attribution` (unresolved until a tool knows the cause)"},
|
|
616
617
|
"unverified": {"type": "array", "items": {"type": "string"}, "description": "2.18: the properties of the output this tool did not measure (the unverified range), e.g. caption's rendered_placement; `verified` covers the measured properties only"},
|
|
618
|
+
"source_carry": {"type": "object", "description": "what the first input carried besides its picture and sound, compared with the written file: {kept: [...], dropped: [...]} over subtitles, chapters, cover_art, audio_channels (a surround source only), creation_time, audio_language, timecode. Present only when the source had at least one of them; absent under --dry-run, for a still or GIF output, and when two or more inputs have a picture or sound. A report, not a check: `verified` does not depend on it. A dropped subtitles/chapters/cover_art/audio_channels also records a source_items_dropped warning"},
|
|
617
619
|
"diagnostics": DIAGNOSTICS_SCHEMA}
|
|
618
620
|
extra: Dict[str, Any] = {}
|
|
619
621
|
if name == "check":
|
package/scripts/caption.py
CHANGED
|
@@ -75,6 +75,11 @@ __all__ = ["parse_srt", "write_srt", "transcribe", "whisper_word_timings", "die_
|
|
|
75
75
|
"text_width_em"]
|
|
76
76
|
|
|
77
77
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
78
|
+
# libass reads a force_style Alignment in the legacy SSA numbering (1-3 bottom, 5-7 top, 9-11
|
|
79
|
+
# middle), not the v4+ numpad one a Style line uses: only the bottom row is the same number in
|
|
80
|
+
# both. Through 3.2.3 the SRT burn passed the numpad value, so --position top landed mid-left,
|
|
81
|
+
# center top-left, top-left top-right (#436, measured on libass 0.17.1 and FFmpeg 9.0.2).
|
|
82
|
+
FORCE_STYLE_ALIGN = {"bottom": 2, "top": 6, "center": 10, "bottom-left": 1, "bottom-right": 3, "top-left": 5, "top-right": 7}
|
|
78
83
|
|
|
79
84
|
TIME_RE = re.compile(
|
|
80
85
|
r"^\s*(?P<a>[\d:.,@]+)\s*(?:-->|-|–|to)\s*(?P<b>[\d:.,@]+)\s+(?P<text>.+)$" # @ = the 1.9 @fps suffix
|
|
@@ -1750,7 +1755,7 @@ def main() -> int:
|
|
|
1750
1755
|
f"Outline={args.outline:g}",
|
|
1751
1756
|
f"Shadow={args.shadow:g}",
|
|
1752
1757
|
f"Bold={-1 if args.bold else 0}",
|
|
1753
|
-
f"Alignment={
|
|
1758
|
+
f"Alignment={FORCE_STYLE_ALIGN[args.position]}",
|
|
1754
1759
|
f"MarginV={args.margin}",
|
|
1755
1760
|
]
|
|
1756
1761
|
force = ",".join(style).replace("\\", "\\\\").replace("'", "\\'")
|
package/scripts/stabilize.py
CHANGED
|
@@ -27,7 +27,7 @@ import sys
|
|
|
27
27
|
import tempfile
|
|
28
28
|
from pathlib import Path
|
|
29
29
|
|
|
30
|
-
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs
|
|
30
|
+
from _common import STATE, add_common, apply_common, aac_args, cfr_args, default_output, die, emit, escape_filter_path, ffmpeg_base, info, probe, require_tool, run, video_args, X264_PRESETS, run_analysis, dry_run_input_pending, fmt_secs, record_fallback, EVEN_SCALE
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
def main() -> int:
|
|
@@ -63,7 +63,18 @@ def main() -> int:
|
|
|
63
63
|
trf_arg = escape_filter_path(trf)
|
|
64
64
|
|
|
65
65
|
ffmpeg = require_tool("ffmpeg")
|
|
66
|
-
|
|
66
|
+
# vid.stab asserts on an odd frame (ffmpeg aborts in pass 1, #438), so both passes see an
|
|
67
|
+
# even one. Recorded like the runner's own odd-size retry: the output is a pixel narrower
|
|
68
|
+
# or shorter than the source.
|
|
69
|
+
v = meta["video"]
|
|
70
|
+
w, h = int(v.get("width") or 0), int(v.get("height") or 0)
|
|
71
|
+
if int(v.get("rotation") or 0) % 180 == 90:
|
|
72
|
+
w, h = h, w
|
|
73
|
+
even = f"{EVEN_SCALE}," if (w % 2 or h % 2) else ""
|
|
74
|
+
if even:
|
|
75
|
+
record_fallback("source has odd dimensions; scaling to even before stabilizing (vid.stab needs it)",
|
|
76
|
+
"odd_dimensions_scaled_even", output=output, scale=EVEN_SCALE)
|
|
77
|
+
detect_vf = f"{even}vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
|
|
67
78
|
if args.tripod:
|
|
68
79
|
# A frame number, not a boolean: frame 1 is the standard reference for "lock to
|
|
69
80
|
# this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
|
|
@@ -78,7 +89,7 @@ def main() -> int:
|
|
|
78
89
|
die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
|
|
79
90
|
|
|
80
91
|
crop_mode = {"keep": 0, "black": 1}[args.crop]
|
|
81
|
-
transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
|
|
92
|
+
transform_vf = f"{even}vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"
|
|
82
93
|
if args.tripod:
|
|
83
94
|
# Equivalent to relative=0:smoothing=0 -- overrides --smoothing, since averaging a
|
|
84
95
|
# camera path makes no sense once every frame is locked to one fixed reference.
|