ffmpeg-skill 1.4.7 → 1.4.8
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/bin/install.js +15 -3
- package/docs/contract.md +388 -0
- package/package.json +2 -1
- package/scripts/_common.py +43 -7
- package/scripts/audio.py +5 -2
- package/scripts/batch.py +2 -1
- package/scripts/broll.py +1 -1
- package/scripts/caption.py +24 -6
- package/scripts/cut.py +18 -8
- package/scripts/freeze.py +9 -3
- package/scripts/grid.py +5 -0
- package/scripts/join.py +5 -2
- package/scripts/look.py +5 -1
- package/scripts/render.py +2 -0
- package/scripts/report.py +1 -1
- package/scripts/sequence.py +2 -4
package/bin/install.js
CHANGED
|
@@ -27,7 +27,7 @@ const { spawnSync } = require('child_process');
|
|
|
27
27
|
|
|
28
28
|
const SKILL_NAME = 'ffmpeg-skill';
|
|
29
29
|
const ROOT = path.resolve(__dirname, '..');
|
|
30
|
-
const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'mcp', 'package.json'];
|
|
30
|
+
const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'docs', 'mcp', 'package.json'];
|
|
31
31
|
|
|
32
32
|
const args = process.argv.slice(2);
|
|
33
33
|
const has = (flag) => args.includes(flag);
|
|
@@ -131,8 +131,20 @@ for (const t of targets) {
|
|
|
131
131
|
if (!fs.existsSync(src)) { if (item !== 'SKILL.md' && item !== 'scripts') continue; throw new Error(`missing ${item} in package`); }
|
|
132
132
|
copyRecursive(src, path.join(tmpDir, item));
|
|
133
133
|
}
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
// Swap: move the old install aside, move the new one in, then drop the old copy. If the
|
|
135
|
+
// second rename fails (a locked file on Windows, a permission error) the old install is put
|
|
136
|
+
// back, so an upgrade can fail but never leaves the target empty.
|
|
137
|
+
const bakDir = `${t.dir}.bak-${process.pid}`;
|
|
138
|
+
fs.rmSync(bakDir, { recursive: true, force: true });
|
|
139
|
+
const hadOld = fs.existsSync(t.dir);
|
|
140
|
+
if (hadOld) fs.renameSync(t.dir, bakDir);
|
|
141
|
+
try {
|
|
142
|
+
fs.renameSync(tmpDir, t.dir);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
if (hadOld) { try { fs.renameSync(bakDir, t.dir); } catch (_) { /* the old copy stays at bakDir */ } }
|
|
145
|
+
throw err;
|
|
146
|
+
}
|
|
147
|
+
if (hadOld) fs.rmSync(bakDir, { recursive: true, force: true });
|
|
136
148
|
console.log(`installed ${t.label}: ${t.dir}`);
|
|
137
149
|
} catch (err) {
|
|
138
150
|
failed = true;
|
package/docs/contract.md
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# ffmpeg-skill execution contract
|
|
2
|
+
|
|
3
|
+
`ffmpeg-skill contract --json` (or `python3 scripts/_contract.py --json`) prints a
|
|
4
|
+
machine-readable description of this skill: which tools exist, what each one needs,
|
|
5
|
+
takes and writes, how its result is verified, and what an agent may assume about
|
|
6
|
+
dry-run, input preservation and repeatability. It is the interface a planning agent
|
|
7
|
+
consumes instead of reading `SKILL.md`, which stays written for a coding agent that
|
|
8
|
+
follows the workflow by hand.
|
|
9
|
+
|
|
10
|
+
The contract is derived from the code that runs, not maintained beside it:
|
|
11
|
+
|
|
12
|
+
- the tool list is every script in `scripts/` that does not start with `_`;
|
|
13
|
+
- every `input_schema` is generated from the script's own `argparse` parser at the
|
|
14
|
+
moment the contract is printed, so a new flag appears in the contract with no other edit;
|
|
15
|
+
- the facts a parser cannot express (role, required ffmpeg components, verification
|
|
16
|
+
policy, visual-check policy) live in one table in `scripts/_contract.py` and are
|
|
17
|
+
checked against the scripts, the MCP server and the installer by `tests/test_contract.py`.
|
|
18
|
+
|
|
19
|
+
## Versions
|
|
20
|
+
|
|
21
|
+
| Field | Meaning | Changes when |
|
|
22
|
+
|---|---|---|
|
|
23
|
+
| `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
|
|
24
|
+
| `skill.version` | the npm / package.json version (`1.4.8`) | any release |
|
|
25
|
+
|
|
26
|
+
A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
|
|
27
|
+
ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
|
|
28
|
+
for provenance. Consumers should also pin `ffmpeg-skill` itself by npm version or git
|
|
29
|
+
tag, not by tracking `main` — see README, "Development", "Releasing".
|
|
30
|
+
|
|
31
|
+
The prose tool count in this file, README, `SKILL.md` and `package.json`'s description is
|
|
32
|
+
not generated (it reads naturally in a sentence), so `tests/test_contract.py`'s
|
|
33
|
+
`test_docs_tool_count_matches_the_real_tool_list` checks all five against the real count
|
|
34
|
+
from `scripts/` on every CI run instead — a stale count fails a test rather than drifting
|
|
35
|
+
silently. (`SKILL.md` was added to that check after its "the 28 scripts" sat stale through
|
|
36
|
+
twelve tool additions while the other three files were correct.)
|
|
37
|
+
|
|
38
|
+
## Stability guarantee (1.x)
|
|
39
|
+
|
|
40
|
+
1.0.0 was published on 2026-09-11 (by accident: see CHANGELOG.md's 1.0.0 entry; the number
|
|
41
|
+
is kept rather than burned). From 1.0.3 on, the number is treated as the promise it implies.
|
|
42
|
+
For the whole of 1.x:
|
|
43
|
+
|
|
44
|
+
| Surface | Promise |
|
|
45
|
+
|---|---|
|
|
46
|
+
| Tool ids (`ffmpeg-skill/<name>`) and script names | never removed or renamed |
|
|
47
|
+
| CLI arguments (`argparse` dests, flags, positionals) | never removed, renamed, or made newly required; new optional arguments may be added |
|
|
48
|
+
| `--json` output keys, and the keys of `contract --json` / `doctor --json` | never removed or given a different type; new keys may be added |
|
|
49
|
+
| Exit codes (0 success, 1 failure, 2 unknown/undecidable in `doctor`) | unchanged |
|
|
50
|
+
| `contract_version` (`1.0`) | unchanged; a ToolSpec shape change is a major |
|
|
51
|
+
| MCP `tools/list` names and `inputSchema` property names | derived from the above, so covered by the same promise |
|
|
52
|
+
| Behaviour of a tool for the same input and arguments | may change only to fix a defect or to track an FFmpeg change, and every such change gets a CHANGELOG line |
|
|
53
|
+
|
|
54
|
+
Not covered: the exact wording of `--help` text, descriptions, stderr messages, and the
|
|
55
|
+
`details`/`notes` free-text fields of JSON output; the internals under `scripts/_*.py`;
|
|
56
|
+
the evals harness; the development skills under `.claude/`.
|
|
57
|
+
|
|
58
|
+
The promise is enforced, not remembered: `tests/test_contract.py`'s
|
|
59
|
+
`test_mcp_tool_surface_matches_the_frozen_1x_snapshot` pins every tool's argument names and
|
|
60
|
+
which are required against `tests/fixtures/mcp_tools.json`. A removal, rename or newly
|
|
61
|
+
required argument fails CI; an addition fails until the snapshot is regenerated
|
|
62
|
+
(`UPDATE_MCP_SNAPSHOT=1 python3 tests/test_contract.py`), so the diff of the fixture shows a
|
|
63
|
+
reviewer exactly what grew.
|
|
64
|
+
|
|
65
|
+
## Deprecation policy
|
|
66
|
+
|
|
67
|
+
Something that has to go (an argument superseded by a better one, an output key that turned
|
|
68
|
+
out to be misleading) is retired in three steps, never in one:
|
|
69
|
+
|
|
70
|
+
1. **Deprecate** in a minor release: the old form keeps working unchanged, a one-line warning
|
|
71
|
+
naming the replacement is printed to stderr when it is used, the CHANGELOG entry says
|
|
72
|
+
"deprecated", and `--help` marks it `(deprecated: use ...)`.
|
|
73
|
+
2. **Keep** it for at least two further minor releases or 90 days, whichever is longer.
|
|
74
|
+
3. **Remove** it only in the next major (2.0.0), listed in that release's CHANGELOG under
|
|
75
|
+
"Removed", together with the version that first deprecated it.
|
|
76
|
+
|
|
77
|
+
A defect fix that changes behaviour is not a deprecation: it ships in a patch with a
|
|
78
|
+
CHANGELOG line, and if the old behaviour was something a caller could reasonably have relied
|
|
79
|
+
on, the line says so.
|
|
80
|
+
|
|
81
|
+
## Skill
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{
|
|
85
|
+
"contract_version": "1.0",
|
|
86
|
+
"skill": {"id": "ffmpeg-skill", "version": "1.4.8", "execution_mode": "local", "kind": "execution",
|
|
87
|
+
"entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
|
|
88
|
+
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
|
|
89
|
+
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
|
|
90
|
+
"execution": {"shell": false, "arbitrary_executables": false, "network": false, "input_mutation": false}
|
|
91
|
+
}
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
ffmpeg-skill is an execution skill. It measures, transforms and verifies media with
|
|
95
|
+
local FFmpeg. It does not reason, plan, decide, or hold a project model; those belong
|
|
96
|
+
to the agent that calls it.
|
|
97
|
+
|
|
98
|
+
## ToolSpec
|
|
99
|
+
|
|
100
|
+
One entry per tool under `tools`, sorted by id. Tool ids are stable:
|
|
101
|
+
`ffmpeg-skill/<script name>` (`ffmpeg-skill/cut`, `ffmpeg-skill/loudness`, …).
|
|
102
|
+
|
|
103
|
+
| Field | Meaning |
|
|
104
|
+
|---|---|
|
|
105
|
+
| `id`, `name`, `version`, `executable` | `ffmpeg-skill/cut`, `cut`, skill version, `scripts/cut.py` |
|
|
106
|
+
| `role` | `analysis`, `analysis_and_execution`, `execution` or `verification` (see below) |
|
|
107
|
+
| `capabilities.required` | ffmpeg components the tool always needs |
|
|
108
|
+
| `capabilities.optional[]` | `{capability, when}`: needed only for that flag or input |
|
|
109
|
+
| `inputs`, `outputs` | asset kinds consumed and artifact kinds produced, in words |
|
|
110
|
+
| `input_schema` | generated from argparse: `properties` keyed by dest with `type`, `cli`, `enum`, `default`, `description`; `required`; `positional` (order); `mutually_exclusive` |
|
|
111
|
+
| `output_schema` | what `--json` prints on stdout |
|
|
112
|
+
| `supports_dry_run`, `dry_run` | whether `--dry-run` plans without running ffmpeg or writing files |
|
|
113
|
+
| `supports_json` | whether `--json` exists |
|
|
114
|
+
| `mutates_input` | always `false`: no tool overwrites its input |
|
|
115
|
+
| `produces_artifact` | writes a file (media, PNG, HTML, EDL) |
|
|
116
|
+
| `verification` | `{required, tools}`: which tools to run on the output afterwards |
|
|
117
|
+
| `requires_visual_verification` | the picture changed; run `ffmpeg-skill/look` and inspect the PNG |
|
|
118
|
+
| `reencodes_video`, `reencodes_audio` | `"always"` / `"never"` / `"conditional"`, meaning *when that stream is present in the input* — not whether the tool touches the file at all. `"conditional"` tools (`cut`, `export`, `render`, `batch`, `verify`, `caption`, `color`) carry a `reencode_note` explaining what it depends on — for `caption`, `--mode burn` (default) always re-encodes both streams, `--mode mux` copies both untouched; for `color`, `--strip-dovi` and `--retag` are a stream copy of both (retag only re-encodes if the copy attempt fails), while `--to-sdr` / `--lut` / `--correct` always re-encode both. Several visual tools (`fit`, `overlay`, `graphics`, `join`, `multicam`, `silence`) are `"always"` on audio too: this codebase never mixes `-c:v` re-encode with `-c:a copy` in one call, so a caller cannot assume the original audio codec survives just because only the picture changed |
|
|
119
|
+
| `audio_only` | accepts an audio-only input (WAV, MP3, M4A, FLAC, OGG, Opus) |
|
|
120
|
+
| `video_required` | refuses an input without a video stream ("input has no video stream") |
|
|
121
|
+
| | `join` has `audio_only: true` and `video_required: false` since 0.9.1: audio-only inputs are joined as audio (no `look` needed then); mixing audio and video inputs is refused |
|
|
122
|
+
| `deterministic_inputs`, `idempotency_hint` | see Repeatability |
|
|
123
|
+
| `mcp` | the MCP tool name and its positional arguments |
|
|
124
|
+
|
|
125
|
+
### Roles
|
|
126
|
+
|
|
127
|
+
| Role | Tools |
|
|
128
|
+
|---|---|
|
|
129
|
+
| `analysis` (measures, writes no media) | probe, scenes |
|
|
130
|
+
| `analysis_and_execution` (measures by default or with a flag, can also write) | silence (`--list`), loudness (`--measure-only`), sync (offset JSON without `-o`) |
|
|
131
|
+
| `execution` (writes a new artifact) | cut, fit, caption, overlay, graphics, multicam, audio, join, color, export, render, batch |
|
|
132
|
+
| `verification` (checks or shows an artifact) | check, look, verify, report |
|
|
133
|
+
|
|
134
|
+
### Verification policy
|
|
135
|
+
|
|
136
|
+
The workflow in `SKILL.md` is "probe first, verify last". The contract states it per tool:
|
|
137
|
+
|
|
138
|
+
| Tool | After it wrote an artifact, run |
|
|
139
|
+
|---|---|
|
|
140
|
+
| cut, silence, audio, sync, batch | probe |
|
|
141
|
+
| loudness | probe, check |
|
|
142
|
+
| export | probe, check |
|
|
143
|
+
| fit, caption, overlay, graphics, color, join, multicam | probe, look |
|
|
144
|
+
| render | probe, check, look |
|
|
145
|
+
| probe, check, look, scenes, verify, report | nothing (they are the verification) |
|
|
146
|
+
|
|
147
|
+
`requires_visual_verification` is `true` exactly for the tools that change the picture
|
|
148
|
+
(fit, caption, overlay, graphics, color, join, multicam, render). Audio-only tools and
|
|
149
|
+
audio-only inputs never need `look`; the report line is `Look: not needed`. `check`
|
|
150
|
+
rows carry `kind: format` (fix it) or `kind: judgement` (decide with the user).
|
|
151
|
+
|
|
152
|
+
### Dry run
|
|
153
|
+
|
|
154
|
+
`supports_dry_run` is measured, not declared: `tests/test_contract.py` runs every tool
|
|
155
|
+
with `--dry-run` behind a fake `ffmpeg` that records any call, and asserts that no
|
|
156
|
+
call happened and no file appeared. Under `--dry-run` a tool prints the command lines
|
|
157
|
+
it would run, reports `dry_run: true`, and never reports an output probe. The
|
|
158
|
+
exceptions are stated per tool in the contract's `dry_run` field: `probe` and `check` are
|
|
159
|
+
read-only (ffprobe still runs), `sync`, `multicam`, `scenes`, `cropdetect` and `report` still
|
|
160
|
+
run their ffmpeg/ffprobe measurements (the analysis is the tool's job; only the artifact is
|
|
161
|
+
skipped), and `verify` does not support dry-run (its steps run). `SKILL.md` and
|
|
162
|
+
`references/scripts.md` repeat the same list; the contract is the authority.
|
|
163
|
+
|
|
164
|
+
### Repeatability
|
|
165
|
+
|
|
166
|
+
No tool keeps state or uses randomness. `deterministic_inputs` is `false` only for
|
|
167
|
+
`verify`, whose output includes timings. `idempotency_hint` says what "same inputs"
|
|
168
|
+
gives you:
|
|
169
|
+
|
|
170
|
+
| Hint | Tools |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `bit_exact` | probe, check, scenes, look |
|
|
173
|
+
| `content_equivalent` (same media, bytes may differ between encoder builds) | every encoding tool, cut, sync, report |
|
|
174
|
+
| `cached` | batch (content-hash cache, re-runs skip unchanged inputs) |
|
|
175
|
+
| `environment_dependent` | verify |
|
|
176
|
+
|
|
177
|
+
## `provides`
|
|
178
|
+
|
|
179
|
+
`provides` lists these 42 tools by a cross-repository Capability id, for
|
|
180
|
+
`kajisho5/AI-video-production-OS`'s `CapabilityContract.provides`
|
|
181
|
+
(`docs/SPEC.md` there), matching the ids already assigned to this Skill in
|
|
182
|
+
that project's own `docs/CAPABILITY_MATRIX.md` section 9 ("ffmpeg-skill's
|
|
183
|
+
21 raw tools ... are Capabilities in their own right, independent of the
|
|
184
|
+
higher-level Skills that delegate to them"): `[{"id": "ffmpeg-skill.<tool>",
|
|
185
|
+
"lifecycle": "EXPERIMENTAL", "tool_id": "ffmpeg-skill/<tool>"}, ...]`, one
|
|
186
|
+
entry per tool, sorted by id. The Capability id uses a dot
|
|
187
|
+
(`ffmpeg-skill.cut`) - the `<domain>.<verb>` shape every other Skill's
|
|
188
|
+
Capability ids use elsewhere in that project (`video.trim`, `audio.gain`,
|
|
189
|
+
...), with `ffmpeg-skill` as the domain - while `tool_id` carries this
|
|
190
|
+
contract's own slash-shaped `id` (`ffmpeg-skill/cut`) unchanged. It is
|
|
191
|
+
purely additive: derived from `public_tools()`, saying nothing `tools[]`
|
|
192
|
+
doesn't already say, only indexed by Capability id instead of tool name.
|
|
193
|
+
|
|
194
|
+
## `capability_map`
|
|
195
|
+
|
|
196
|
+
`provides` re-indexes each tool by an id shaped like the tool name
|
|
197
|
+
(`ffmpeg-skill.cut`); it doesn't tell a caller that "I need to trim a
|
|
198
|
+
video" resolves to `cut`. `capability_map` is the small, hand-authored
|
|
199
|
+
table that closes that gap: `[{"capability": "<domain>.<verb>", "tool_id":
|
|
200
|
+
"ffmpeg-skill/<tool>", "params": {...}}, ...]`. A planner that only knows
|
|
201
|
+
an abstract goal (`video.trim`, `audio.loudness`, `subtitle.burn`,
|
|
202
|
+
`media.stream.inspect`, `media.frames.extract`, `media.proxy`) looks it up here to find
|
|
203
|
+
the tool, then builds and runs that tool's own call from its
|
|
204
|
+
`input_schema` exactly as it would have if it already knew the tool name -
|
|
205
|
+
`capability_map` never executes anything itself, and this skill never
|
|
206
|
+
picks a capability on the caller's behalf.
|
|
207
|
+
|
|
208
|
+
Some entries also fix one or more `params` where the capability names a
|
|
209
|
+
*specific* behaviour narrower than the whole tool: `video.reframe` maps
|
|
210
|
+
to `fit` with `params: {"fit": "crop"}`, because `fit.py` also does
|
|
211
|
+
duration-fit and letterbox padding, and only the crop mode is a
|
|
212
|
+
"reframe". A caller resolving `video.reframe` should treat those params
|
|
213
|
+
as fixed inputs to that tool's own schema, not as optional defaults.
|
|
214
|
+
|
|
215
|
+
This list is deliberately short and will stay short: a capability is only
|
|
216
|
+
added when resolving it is a mechanical, no-judgment lookup. There is no
|
|
217
|
+
`video.highlight` entry, for instance, because `scenes.py --highlights`
|
|
218
|
+
ranks candidates by a measured proxy (audio energy or duration), never by
|
|
219
|
+
understood content - offering it as a blindly-delegable capability would
|
|
220
|
+
misrepresent what it does (see SKILL.md, "What this skill does and does
|
|
221
|
+
not decide"). `media.proxy` (a low-bitrate, fast-decode proxy for
|
|
222
|
+
downstream analysis/preview, distinct from `export.py`'s delivery
|
|
223
|
+
presets) resolves to `proxy` - itself a mechanical resize + re-encode
|
|
224
|
+
with no opinion on which asset should be proxied or what for.
|
|
225
|
+
|
|
226
|
+
## Capabilities
|
|
227
|
+
|
|
228
|
+
Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `filter:<name>`, `bsf:<name>`,
|
|
229
|
+
`external:whisper`. `capabilities.required` is the union of every tool's required list;
|
|
230
|
+
`optional` the union of the conditional ones. With detection (the default)
|
|
231
|
+
`available`, `missing` and `missing_optional` are added from `doctor`, which reads
|
|
232
|
+
`ffmpeg -encoders / -filters / -bsfs` and looks for a local whisper. Pass `--static`
|
|
233
|
+
to omit detection. Nothing from the environment other than those lists and the
|
|
234
|
+
ffmpeg/ffprobe/python versions is printed; no environment variables, no paths.
|
|
235
|
+
|
|
236
|
+
`doctor` has three states per capability. `available` and `missing` come from a listing
|
|
237
|
+
that was read; `unknown` means the listing that would prove the capability could not be
|
|
238
|
+
read (`ffmpeg -filters` in a layout the parser does not recognise, or ffmpeg exiting
|
|
239
|
+
non-zero), and it is never folded into `missing`, so an installed filter is not reported
|
|
240
|
+
absent, nor into `available`, so a failed detection is not a pass. `detection` gives the
|
|
241
|
+
status (`parsed`, `unparsed`, `failed`, `missing`), row count and detail of each listing;
|
|
242
|
+
`errors` lists the unreadable ones. The filter parser recognises the FFmpeg 6/7 layout
|
|
243
|
+
(three flag characters, `..C acompressor A->A`) and the FFmpeg 8 layout (two, `T.
|
|
244
|
+
acompressor A->A`) by the io-spec token, so the flag width does not matter; fixtures for
|
|
245
|
+
both live in `tests/fixtures/`.
|
|
246
|
+
|
|
247
|
+
`ffmpeg-skill doctor` exits 0 when every required capability is available, 1 when one is
|
|
248
|
+
missing, 2 when none is missing but a required one is unknown. `ok` is true only for 0.
|
|
249
|
+
The keys of 0.9.0 (`available`, `missing`, `missing_optional`, `ok`) are unchanged.
|
|
250
|
+
|
|
251
|
+
`doctor`'s `tools` field folds that same per-capability `state` into a per-tool answer:
|
|
252
|
+
`{"<tool>": {"usable": "yes"|"no"|"unknown", "missing": [...], "fix": "...", "unknown": [...]}}`.
|
|
253
|
+
`missing`/`unknown` list only that tool's own required capabilities that are in that state
|
|
254
|
+
(`missing` is absent when there is none, same for `unknown`); `fix` is a one-line, plain-language
|
|
255
|
+
remedy for each missing capability, joined with "; " when there is more than one. This exists so
|
|
256
|
+
a caller does not have to cross-reference `available`/`missing` against each tool's own required
|
|
257
|
+
capabilities by hand to answer "can I run `caption.py` on this machine right now" -- `doctor`
|
|
258
|
+
passing overall does not mean every tool is usable (a plain Homebrew `ffmpeg` on macOS is `ok`
|
|
259
|
+
for tools that don't need `subtitles`/`drawtext`/`zscale`, but `caption.usable` is `"no"`).
|
|
260
|
+
|
|
261
|
+
`doctor`'s `gpu_encoders` field reports GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`,
|
|
262
|
+
`vaapi`, `amf`) present in this ffmpeg *build*, read from `-encoders` alone — `{"status":
|
|
263
|
+
"parsed"|"unparsed"|"failed"|"missing", "present": [...]}`. It proves the build shipped the
|
|
264
|
+
capability, not that the GPU/driver on this machine will accept a job (that needs a real
|
|
265
|
+
encode, which this introspection never runs). No tool declares or requires a GPU encoder, so
|
|
266
|
+
`gpu_encoders` never affects `ok` or any tool's `usable` — it exists purely so a caller can ask
|
|
267
|
+
the same honest yes/no/unknown question about GPU support that filter/encoder detection already
|
|
268
|
+
answers for everything else, without a tool here needing to use one.
|
|
269
|
+
|
|
270
|
+
`doctor`'s `fonts` field reports whether the default drawtext font (`caption.py`'s
|
|
271
|
+
`--animate`/`--karaoke`, `graphics.py`'s templates — `BRAND_DEFAULTS["font"]`, `"DejaVu Sans"`)
|
|
272
|
+
is actually installed — `{"default_font": "...", "status": "available"|"missing"|"unknown",
|
|
273
|
+
"detail": "..."}`. drawtext's `font=` is a fontconfig name lookup, and fontconfig silently
|
|
274
|
+
substitutes the closest match for *any* name, known or not — a missing font never fails the
|
|
275
|
+
encode, so drawtext's own exit code cannot detect it. `fc-match` is queried instead: `available`
|
|
276
|
+
when it resolves the name to itself, `missing` when it substitutes a different family, `unknown`
|
|
277
|
+
when `fc-match` itself is not on PATH or fails. Like `gpu_encoders`, this is purely informational
|
|
278
|
+
and never affects `ok` or any tool's `usable` — a substituted font is not a broken tool, just a
|
|
279
|
+
typeface the caller didn't ask for.
|
|
280
|
+
|
|
281
|
+
## Invocation
|
|
282
|
+
|
|
283
|
+
Structured arguments are the canonical way to call a tool, on the CLI or through MCP.
|
|
284
|
+
The mapping is stated in `invocation.structured.argument_mapping`: positionals in
|
|
285
|
+
`input_schema.positional` order, `key` → `--key` with `_` → `-`, booleans as bare flags,
|
|
286
|
+
arrays repeated, `output` → `-o`, `loudness.lufs` → `-I`. `--json` is appended for every
|
|
287
|
+
tool except `probe` (JSON by default) and `look`.
|
|
288
|
+
|
|
289
|
+
The MCP server also accepts `{"argv": [...]}` for CLI compatibility. That path is
|
|
290
|
+
marked `canonical: false`: it is still bound to the named script and never reaches a
|
|
291
|
+
shell, but an agent ecosystem should use the structured form. No tool, CLI or MCP,
|
|
292
|
+
runs a shell, evaluates strings, or executes anything other than the named script,
|
|
293
|
+
`ffmpeg` and `ffprobe`.
|
|
294
|
+
|
|
295
|
+
## JSON output
|
|
296
|
+
|
|
297
|
+
Success (`exit 0`): one document matching `output_schema`, always with
|
|
298
|
+
`status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
|
|
299
|
+
file was written. `probe` prints its measurement document directly.
|
|
300
|
+
|
|
301
|
+
Success is decided by `verify_output` in `_common.py`, not by the ffmpeg exit code alone:
|
|
302
|
+
the file must exist, be non-empty and give ffprobe at least one stream. A tool that ran
|
|
303
|
+
ffmpeg successfully but has no usable artifact fails with `kind: output` (a 0-byte file is
|
|
304
|
+
removed so a later step cannot mistake it for a result).
|
|
305
|
+
|
|
306
|
+
Failure (non-zero exit; 127 when ffmpeg/ffprobe is missing): the message on stderr as
|
|
307
|
+
before, and, when `--json` was given, on stdout:
|
|
308
|
+
|
|
309
|
+
```json
|
|
310
|
+
{"status": "failed", "exit_code": 1,
|
|
311
|
+
"error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": "...",
|
|
312
|
+
"code": "INPUT_INVALID | DEPENDENCY_MISSING | FFMPEG_EXECUTION_FAILED | OUTPUT_INVALID | TIMEOUT | VERIFICATION_FAILED | INTERNAL_ERROR",
|
|
313
|
+
"retryable": false},
|
|
314
|
+
"commands": ["ffmpeg ..."]}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
`message` carries the script's own reason (missing input, ffprobe failure, the last
|
|
318
|
+
stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
|
|
319
|
+
flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
|
|
320
|
+
or run so the caller can retry or report without re-deriving the command. `code` is a
|
|
321
|
+
purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
|
|
322
|
+
doesn't already make) for a caller that wants a stable enum instead of matching `kind`
|
|
323
|
+
strings. `retryable` is currently always `false`: none of the kinds are distinguishable
|
|
324
|
+
today from a deterministic failure that would fail identically on a blind retry, so nothing
|
|
325
|
+
here claims otherwise until real exit-code/stderr sniffing exists to back that up.
|
|
326
|
+
|
|
327
|
+
## MCP relationship
|
|
328
|
+
|
|
329
|
+
`mcp/server.py` is a transport. It holds no tool table and no schema of its own:
|
|
330
|
+
|
|
331
|
+
```
|
|
332
|
+
argparse parser → ToolSpec.input_schema → contract → MCP tools/list inputSchema
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
At start-up the server builds the ToolSpecs (`_contract.build(detect=False)`) and
|
|
336
|
+
derives each `tools/list` entry with `_contract.mcp_tool`: the name is the ToolSpec
|
|
337
|
+
name, the order is the contract's sorted order, and `inputSchema` is
|
|
338
|
+
`_contract.mcp_input_schema(ToolSpec)`. `tools/call` maps structured arguments to
|
|
339
|
+
argv with the ToolSpec's `mcp.positional` and `mcp.argument_exceptions`. A new
|
|
340
|
+
public script, a removed one, or a changed parser therefore changes the MCP surface
|
|
341
|
+
with no edit to `mcp/`; `tests/test_contract.py` proves this by copying the skill,
|
|
342
|
+
adding, removing and editing scripts, and reading `tools/list` again.
|
|
343
|
+
|
|
344
|
+
### Translation, and what JSON Schema cannot say
|
|
345
|
+
|
|
346
|
+
| ToolSpec.input_schema | MCP inputSchema |
|
|
347
|
+
|---|---|
|
|
348
|
+
| `properties.<dest>.type / enum / default / description / items` | copied as is |
|
|
349
|
+
| `properties.<dest>.cli`, `.common` | dropped (ffmpeg-skill-only keys) |
|
|
350
|
+
| `positional` | same properties, passed by name; description gets a `(positional N)` prefix |
|
|
351
|
+
| `required` | `required` of the structured branch |
|
|
352
|
+
| `mutually_exclusive` groups | `allOf: [{not: {required: [a, b]}} …]` for every pair |
|
|
353
|
+
| `one_of_required` groups | `anyOf: [{required: [a]}, …]` |
|
|
354
|
+
| raw `argv` compatibility | an `argv` array property; top-level `anyOf: [{required: [argv]}, <structured branch>]` |
|
|
355
|
+
| `additionalProperties: false` | kept |
|
|
356
|
+
|
|
357
|
+
Two things are documented rather than encoded, because JSON Schema has no way to
|
|
358
|
+
express them: when `argv` is present every other key is ignored (stated in the `argv`
|
|
359
|
+
description), and MCP has no notion of positional order, so positionals are named
|
|
360
|
+
properties whose order is only informative. `%(default)s` help interpolation is
|
|
361
|
+
already applied when the ToolSpec is built.
|
|
362
|
+
|
|
363
|
+
The `tools/list` document is deterministic (byte-identical across processes and
|
|
364
|
+
identical to the translation of `contract --json`), which the tests check.
|
|
365
|
+
|
|
366
|
+
## Consuming the contract from an agent
|
|
367
|
+
|
|
368
|
+
A planning agent (for example video-production-agent's SkillRegistry) can:
|
|
369
|
+
|
|
370
|
+
1. run `ffmpeg-skill contract --json` once and register the skill by `skill.id` and the
|
|
371
|
+
tools by `id`;
|
|
372
|
+
2. resolve `capabilities.required` against `capabilities.available` before planning;
|
|
373
|
+
3. pick a tool by `role`, `inputs`/`outputs`, `video_required` and `audio_only`;
|
|
374
|
+
4. build the call from `input_schema` and the argument mapping, plan with `--dry-run`;
|
|
375
|
+
5. run, parse `output_schema`, then run `verification.tools`, adding `look` when
|
|
376
|
+
`requires_visual_verification` is true.
|
|
377
|
+
|
|
378
|
+
The measurement documents (`probe`, `check`, `scenes`, `sync`) are ffmpeg-skill's own
|
|
379
|
+
shapes, not another system's Observation model; convert them in the agent's adapter.
|
|
380
|
+
ffmpeg-skill contains no agent-specific code.
|
|
381
|
+
|
|
382
|
+
## Where things live
|
|
383
|
+
|
|
384
|
+
- `scripts/_contract.py`: the generator (`--json`, `--static`, `doctor`)
|
|
385
|
+
- `bin/install.js`: `ffmpeg-skill contract` and `ffmpeg-skill doctor`
|
|
386
|
+
- `tests/test_contract.py`: schema, consistency (scripts = MCP = installer), MCP inputSchema derived from the contract (equality, determinism, drift, round trips), dry-run, JSON shapes, verification policy, real-media run
|
|
387
|
+
- `evals/contract/`: questions an agent must answer from the contract alone, with the expected answers checked against the live contract
|
|
388
|
+
- `tests/release_check.sh`: runs the contract from the packed and installed copies before a release
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.8",
|
|
4
4
|
"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
5
|
"keywords": [
|
|
6
6
|
"ffmpeg",
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"references/scripts.md",
|
|
34
34
|
"references/devices.md",
|
|
35
35
|
"references/ci-platform-pitfalls.md",
|
|
36
|
+
"docs/contract.md",
|
|
36
37
|
"SKILL.md",
|
|
37
38
|
"README.md",
|
|
38
39
|
"LICENSE"
|
package/scripts/_common.py
CHANGED
|
@@ -107,11 +107,14 @@ def ffmpeg_version() -> "Tuple[int, int]":
|
|
|
107
107
|
if _FFMPEG_VERSION is None:
|
|
108
108
|
_FFMPEG_VERSION = (0, 0)
|
|
109
109
|
try:
|
|
110
|
-
out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True
|
|
110
|
+
out = subprocess.run(["ffprobe", "-version"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True,
|
|
111
|
+
timeout=PROBE_TIMEOUT).stdout
|
|
111
112
|
m = re.search(r"ffprobe version\s+n?(\d+)\.(\d+)", out)
|
|
112
113
|
if m:
|
|
113
114
|
_FFMPEG_VERSION = (int(m.group(1)), int(m.group(2)))
|
|
114
|
-
except OSError:
|
|
115
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
116
|
+
# (0, 0) = unknown: every version branch then takes the older, universally accepted
|
|
117
|
+
# spelling, the same "unknown is not missing" stance doctor takes.
|
|
115
118
|
pass
|
|
116
119
|
return _FFMPEG_VERSION
|
|
117
120
|
|
|
@@ -206,9 +209,9 @@ X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium"
|
|
|
206
209
|
class Context:
|
|
207
210
|
"""Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
|
|
208
211
|
|
|
209
|
-
Scripts read it as attributes (``STATE.dry_run``)
|
|
210
|
-
|
|
211
|
-
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
212
|
+
Scripts read it as attributes (``STATE.dry_run``); the dict-style shims that once served
|
|
213
|
+
older call sites are gone. Keeping it a single explicit object rather than module globals
|
|
214
|
+
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
212
215
|
"""
|
|
213
216
|
|
|
214
217
|
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
@@ -912,6 +915,15 @@ class MissingFpsError(ValueError):
|
|
|
912
915
|
silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
|
|
913
916
|
|
|
914
917
|
|
|
918
|
+
def concat_list_line(path: str) -> str:
|
|
919
|
+
"""One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
|
|
920
|
+
inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
|
|
921
|
+
with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
|
|
922
|
+
name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
|
|
923
|
+
escaped = str(path).replace("\\", "/").replace("'", "'\\''")
|
|
924
|
+
return f"file '{escaped}'"
|
|
925
|
+
|
|
926
|
+
|
|
915
927
|
def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
916
928
|
"""Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
|
|
917
929
|
or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
|
|
@@ -928,7 +940,12 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
|
|
|
928
940
|
frame, whole_fps = int(f), int(round(fps))
|
|
929
941
|
if not (0 <= frame < whole_fps):
|
|
930
942
|
raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
|
|
931
|
-
|
|
943
|
+
# Non-drop-frame: the timecode counts whole_fps frames per timecode-second, so the real
|
|
944
|
+
# time is the total frame count over the true rate (at 29.97 an hour of timecode is
|
|
945
|
+
# 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
|
|
946
|
+
# disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
|
|
947
|
+
total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
|
|
948
|
+
return total_frames / fps
|
|
932
949
|
if len(parts) > 3:
|
|
933
950
|
raise ValueError(f"bad time: {value}")
|
|
934
951
|
total = 0.0
|
|
@@ -1002,7 +1019,26 @@ def default_font_file(font_name: str) -> Optional[str]:
|
|
|
1002
1019
|
"""
|
|
1003
1020
|
if platform.system() == "Windows":
|
|
1004
1021
|
windir = os.environ.get("WINDIR", "C:\\Windows")
|
|
1005
|
-
|
|
1022
|
+
fonts = Path(windir) / "Fonts"
|
|
1023
|
+
# The requested family first: a file whose name starts with the family name with spaces
|
|
1024
|
+
# removed (Noto Sans CJK JP -> NotoSansCJKjp-Regular.otf, Meiryo -> meiryo.ttc), then the
|
|
1025
|
+
# common CJK system fonts when the request looks CJK (so Japanese text does not render as
|
|
1026
|
+
# boxes in Arial), and Arial only as the last resort.
|
|
1027
|
+
wanted = re.sub(r"[^a-z0-9]", "", (font_name or "").lower())
|
|
1028
|
+
try:
|
|
1029
|
+
files = sorted(fonts.iterdir()) if fonts.is_dir() else []
|
|
1030
|
+
except OSError:
|
|
1031
|
+
files = []
|
|
1032
|
+
if wanted:
|
|
1033
|
+
for f in files:
|
|
1034
|
+
stem = re.sub(r"[^a-z0-9]", "", f.stem.lower())
|
|
1035
|
+
if f.suffix.lower() in (".ttf", ".otf", ".ttc") and stem.startswith(wanted):
|
|
1036
|
+
return str(f)
|
|
1037
|
+
if any(k in wanted for k in ("cjk", "gothic", "mincho", "meiryo", "yugoth", "msgothic", "malgun", "simhei", "simsun", "jp", "kr", "sc", "tc")):
|
|
1038
|
+
for name in ("NotoSansCJKjp-Regular.otf", "NotoSansCJK-Regular.ttc", "meiryo.ttc", "YuGothM.ttc", "msgothic.ttc", "malgun.ttf", "msyh.ttc"):
|
|
1039
|
+
if (fonts / name).exists():
|
|
1040
|
+
return str(fonts / name)
|
|
1041
|
+
candidate = fonts / "arial.ttf"
|
|
1006
1042
|
return str(candidate) if candidate.exists() else None
|
|
1007
1043
|
exe = shutil.which("fc-match")
|
|
1008
1044
|
if not exe:
|
package/scripts/audio.py
CHANGED
|
@@ -91,8 +91,9 @@ def main() -> int:
|
|
|
91
91
|
fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
|
|
92
92
|
fades.add_argument("--fade-out", type=float, default=0.0, help="seconds; fades the whole final mix (voice included)")
|
|
93
93
|
music.add_argument("--music-fade-out", type=float, default=0.0, help="seconds; fades only the music bed at the end, voice untouched")
|
|
94
|
-
|
|
95
|
-
|
|
94
|
+
channels = fades.add_mutually_exclusive_group()
|
|
95
|
+
channels.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
|
|
96
|
+
channels.add_argument("--mono", action="store_true", help="force 1-channel output")
|
|
96
97
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
97
98
|
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
98
99
|
dyn = ap.add_argument_group("dynamics (typed; each flag is one option of ffmpeg's acompressor / alimiter / agate)")
|
|
@@ -151,6 +152,8 @@ def main() -> int:
|
|
|
151
152
|
if args.voice:
|
|
152
153
|
fx.append(VOICE_CHAIN)
|
|
153
154
|
elif args.denoise:
|
|
155
|
+
if not 10 <= args.denoise_strength <= 60:
|
|
156
|
+
die(f"--denoise-strength must be 10..60 (dB of noise floor to remove), got {args.denoise_strength:g}")
|
|
154
157
|
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
155
158
|
if args.gain:
|
|
156
159
|
fx.append(f"volume={args.gain:g}dB")
|
package/scripts/batch.py
CHANGED
|
@@ -34,7 +34,8 @@ from typing import Any, Dict, List
|
|
|
34
34
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die
|
|
35
35
|
|
|
36
36
|
HERE = Path(__file__).resolve().parent
|
|
37
|
-
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".
|
|
37
|
+
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".ts", ".gif",
|
|
38
|
+
".wav", ".m4a", ".mp3", ".flac", ".aac", ".ogg", ".opus", ".aif", ".aiff"}
|
|
38
39
|
# recipe steps name the script to run as plain, untrusted JSON -- run_step() joins it onto HERE
|
|
39
40
|
# with the `/` operator, which silently ignores the left side when the right side is itself an
|
|
40
41
|
# absolute path (Path("/scripts") / "/tmp/evil.py" == Path("/tmp/evil.py")), and does nothing to
|
package/scripts/broll.py
CHANGED
|
@@ -104,7 +104,7 @@ def main() -> int:
|
|
|
104
104
|
for i, c in enumerate(cutaways):
|
|
105
105
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
106
106
|
parts.append(f"[{i + 1}:v]trim=start={c['from']:.3f}:duration={c['length']:.3f},setpts=PTS-STARTPTS+{c['at']:.3f}/TB,"
|
|
107
|
-
f"{geo},setsar=1,fps={fps:g},format=yuv420p[b{i}]")
|
|
107
|
+
f"{geo},setsar=1,fps={fps:g},format={'yuv420p10le' if (meta_a.get('video') or {}).get('hdr') else 'yuv420p'}[b{i}]")
|
|
108
108
|
parts.append(f"{cur}[b{i}]overlay=0:0:eof_action=pass:enable='between(t,{c['at']:.3f},{c['at'] + c['length']:.3f})'[v{i}]")
|
|
109
109
|
cur = f"[v{i}]"
|
|
110
110
|
vout = cur
|
package/scripts/caption.py
CHANGED
|
@@ -109,7 +109,7 @@ def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProc
|
|
|
109
109
|
|
|
110
110
|
def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
|
|
111
111
|
ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
|
|
112
|
-
from _common import run_analysis
|
|
112
|
+
from _common import run_analysis, STATE, die
|
|
113
113
|
wav = os.path.join(tmpdir, "audio.wav")
|
|
114
114
|
# A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
|
|
115
115
|
# is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
|
|
@@ -117,8 +117,14 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
117
117
|
run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
|
|
118
118
|
"-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
|
|
119
119
|
# 1. whisper.cpp
|
|
120
|
-
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
|
|
121
|
-
if
|
|
120
|
+
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp")
|
|
121
|
+
if not cli:
|
|
122
|
+
# older whisper.cpp builds ship the binary as plain `main`; accept it only when it lives
|
|
123
|
+
# in a directory that names whisper, so an unrelated /usr/bin/main is never run
|
|
124
|
+
main_bin = shutil.which("main")
|
|
125
|
+
if main_bin and "whisper" in os.path.dirname(os.path.realpath(main_bin)).lower():
|
|
126
|
+
cli = main_bin
|
|
127
|
+
if cli:
|
|
122
128
|
model_path = model
|
|
123
129
|
if not os.path.exists(model_path):
|
|
124
130
|
for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"), f"models/ggml-{model}.bin", f"/usr/local/share/whisper/ggml-{model}.bin"):
|
|
@@ -139,9 +145,21 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
|
|
|
139
145
|
# 2. faster-whisper (python package)
|
|
140
146
|
try:
|
|
141
147
|
from faster_whisper import WhisperModel # type: ignore
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
148
|
+
import threading
|
|
149
|
+
result: list = []
|
|
150
|
+
|
|
151
|
+
def work() -> None:
|
|
152
|
+
m = WhisperModel(model, device="cpu", compute_type="int8")
|
|
153
|
+
segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
|
|
154
|
+
result.extend((seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip())
|
|
155
|
+
|
|
156
|
+
# An in-process engine gets the same wall-clock limit as the CLI engines and ffmpeg.
|
|
157
|
+
t = threading.Thread(target=work, daemon=True)
|
|
158
|
+
t.start()
|
|
159
|
+
t.join(STATE.timeout or None)
|
|
160
|
+
if t.is_alive():
|
|
161
|
+
die(f"faster-whisper exceeded the {STATE.timeout:.0f} s time limit; raise --timeout for a long recording", code=124, kind="timeout")
|
|
162
|
+
cues = list(result)
|
|
145
163
|
if cues:
|
|
146
164
|
info("transcribed with faster-whisper")
|
|
147
165
|
write_srt(cues, out_srt)
|
package/scripts/cut.py
CHANGED
|
@@ -30,14 +30,23 @@ import sys
|
|
|
30
30
|
import tempfile
|
|
31
31
|
from typing import List, Tuple
|
|
32
32
|
|
|
33
|
-
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near
|
|
33
|
+
from _common import video_args, STATE, add_common, apply_common, audio_codec_for, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, is_audio_output, parse_time, probe, run, X264_PRESETS, keyframes_near, MissingFpsError, concat_list_line
|
|
34
34
|
|
|
35
35
|
# keyframe timestamps found next to a requested cut that the tolerance turned into a re-encode
|
|
36
36
|
# (reported so the caller can choose a lossless cut at one of them next time)
|
|
37
37
|
NEAREST_KEYFRAMES: list = []
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def
|
|
40
|
+
def _t(value: str, fps) -> float:
|
|
41
|
+
"""parse_time() with the input's fps (SMPTE hh:mm:ss:ff) and every failure as kind input."""
|
|
42
|
+
try:
|
|
43
|
+
return parse_time(value, fps)
|
|
44
|
+
except (ValueError, MissingFpsError) as e:
|
|
45
|
+
die(f"bad time {value!r}: {e}")
|
|
46
|
+
return 0.0 # unreachable
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def parse_segments(spec: str, fps=None) -> List[Tuple[float, float]]:
|
|
41
50
|
segs = []
|
|
42
51
|
for raw in spec.split(","):
|
|
43
52
|
raw = raw.strip()
|
|
@@ -46,7 +55,7 @@ def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
|
46
55
|
if "-" not in raw:
|
|
47
56
|
die(f"segment '{raw}' must look like START-END (e.g. 0:05-0:12)")
|
|
48
57
|
a, b = raw.rsplit("-", 1)
|
|
49
|
-
start, end =
|
|
58
|
+
start, end = _t(a, fps), _t(b, fps)
|
|
50
59
|
if end <= start:
|
|
51
60
|
die(f"segment '{raw}': end must be after start")
|
|
52
61
|
segs.append((start, end))
|
|
@@ -156,20 +165,21 @@ def main() -> int:
|
|
|
156
165
|
info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
|
|
157
166
|
args.accurate = True
|
|
158
167
|
|
|
168
|
+
fps = (meta.get("video") or {}).get("fps")
|
|
159
169
|
if args.segments:
|
|
160
|
-
segments = parse_segments(args.segments)
|
|
170
|
+
segments = parse_segments(args.segments, fps)
|
|
161
171
|
else:
|
|
162
|
-
start =
|
|
172
|
+
start = _t(args.start, fps)
|
|
163
173
|
if start < 0:
|
|
164
174
|
die(f"--start must not be negative, got {args.start!r}")
|
|
165
175
|
if args.end and args.duration:
|
|
166
176
|
die("use --end or --duration, not both")
|
|
167
177
|
if args.end:
|
|
168
|
-
end =
|
|
178
|
+
end = _t(args.end, fps)
|
|
169
179
|
if end < 0:
|
|
170
180
|
die(f"--end must not be negative, got {args.end!r}")
|
|
171
181
|
elif args.duration:
|
|
172
|
-
end = start +
|
|
182
|
+
end = start + _t(args.duration, fps)
|
|
173
183
|
else:
|
|
174
184
|
end = total
|
|
175
185
|
if end <= start:
|
|
@@ -197,7 +207,7 @@ def main() -> int:
|
|
|
197
207
|
listfile = os.path.join(tmp, "list.txt")
|
|
198
208
|
with open(listfile, "w", encoding="utf-8") as fh:
|
|
199
209
|
for p in parts:
|
|
200
|
-
fh.write(
|
|
210
|
+
fh.write(concat_list_line(p) + "\n")
|
|
201
211
|
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", output]
|
|
202
212
|
proc = run(cmd, check=False)
|
|
203
213
|
if proc.returncode != 0:
|
package/scripts/freeze.py
CHANGED
|
@@ -19,14 +19,14 @@ Examples:
|
|
|
19
19
|
import argparse
|
|
20
20
|
import sys
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS
|
|
22
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, video_args, X264_PRESETS, MissingFpsError, parse_time
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def main() -> int:
|
|
26
26
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
27
27
|
ap.add_argument("input")
|
|
28
28
|
ap.add_argument("-o", "--output", help="output file (default: <name>_freeze.<ext>)")
|
|
29
|
-
ap.add_argument("--at",
|
|
29
|
+
ap.add_argument("--at", help="timestamp to freeze: seconds, mm:ss, hh:mm:ss.ms or SMPTE hh:mm:ss:ff (default: the last frame)")
|
|
30
30
|
ap.add_argument("--hold", type=float, required=True, help="how long the freeze lasts, in seconds")
|
|
31
31
|
ap.add_argument("--mode", choices=["insert", "extend"], default="insert",
|
|
32
32
|
help="insert (default): hold pushes the rest of the clip later; extend: only valid at/after the clip's end, makes the last frame last longer with nothing pushed")
|
|
@@ -44,7 +44,13 @@ def main() -> int:
|
|
|
44
44
|
die("input has no video stream")
|
|
45
45
|
dur = meta.get("duration") or 0.0
|
|
46
46
|
fps = meta["video"].get("fps") or 30.0
|
|
47
|
-
|
|
47
|
+
if args.at is not None:
|
|
48
|
+
try:
|
|
49
|
+
at = parse_time(args.at, (meta.get("video") or {}).get("fps"))
|
|
50
|
+
except (ValueError, MissingFpsError) as e:
|
|
51
|
+
die(f"--at {args.at!r}: {e}")
|
|
52
|
+
else:
|
|
53
|
+
at = dur
|
|
48
54
|
if at < 0 or at > dur:
|
|
49
55
|
die(f"--at {at:g} is outside the clip (0..{dur:.3f})")
|
|
50
56
|
if args.mode == "extend" and at < dur - 0.01:
|
package/scripts/grid.py
CHANGED
|
@@ -121,6 +121,11 @@ def main() -> int:
|
|
|
121
121
|
if args.audio_from is not None:
|
|
122
122
|
audio_source = "[aout]" if (args.pad and durations[args.audio_from] < target_duration) else f"{args.audio_from}:a:0"
|
|
123
123
|
cmd += ["-map", audio_source]
|
|
124
|
+
# A grid is an 8-bit SDR composite by design (a comparison/contact artefact, not a
|
|
125
|
+
# deliverable); an HDR input is flattened like look.py's contact sheet flattens it. Say so
|
|
126
|
+
# once so the caller is not surprised by the 8-bit output.
|
|
127
|
+
if any((m.get("video") or {}).get("hdr") for m in metas):
|
|
128
|
+
info("note: an HDR input is composited into an 8-bit SDR grid (grid.py is a comparison artefact); use color.py --to-sdr first for a graded conversion")
|
|
124
129
|
cmd += video_args(None, args.crf, args.preset)
|
|
125
130
|
cmd += cfr_args(None, args.fps)
|
|
126
131
|
if args.audio_from is not None:
|
package/scripts/join.py
CHANGED
|
@@ -168,7 +168,10 @@ def main() -> int:
|
|
|
168
168
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
|
|
169
169
|
else:
|
|
170
170
|
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
|
|
171
|
-
|
|
171
|
+
# Any HDR input makes the join HDR (10-bit, HEVC via video_args on that clip's tags): an SDR
|
|
172
|
+
# first clip used to drag an HDR second clip down to 8-bit without a tone map.
|
|
173
|
+
hdr_meta = next((m for m in metas if (m.get("video") or {}).get("hdr")), None)
|
|
174
|
+
pixfmt = "yuv420p10le" if hdr_meta else "yuv420p"
|
|
172
175
|
for i in range(n):
|
|
173
176
|
parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
|
|
174
177
|
parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
|
|
@@ -189,7 +192,7 @@ def main() -> int:
|
|
|
189
192
|
|
|
190
193
|
output = args.output or default_output(args.inputs[0], "joined", "mp4")
|
|
191
194
|
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
192
|
-
cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
195
|
+
cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
|
|
193
196
|
run(cmd)
|
|
194
197
|
expected = sum(durs) - d * (n - 1)
|
|
195
198
|
r = probe(output, role="output")
|
package/scripts/look.py
CHANGED
|
@@ -85,7 +85,11 @@ def main() -> int:
|
|
|
85
85
|
sec = parse_time(t)
|
|
86
86
|
if dur and sec > dur:
|
|
87
87
|
die(f"--at {t} is beyond the duration ({dur:.2f}s)")
|
|
88
|
-
|
|
88
|
+
if args.output and len(args.at) == 1 and Path(args.output).suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
|
|
89
|
+
out = args.output # one frame, one named image file: the caller's -o is the contract
|
|
90
|
+
else:
|
|
91
|
+
# several frames, or -o given as a stem/prefix without an image extension
|
|
92
|
+
out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
|
|
89
93
|
stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
|
|
90
94
|
cmd = ffmpeg_base() + ["-ss", f"{sec:.3f}", "-i", args.input, "-vf", f"scale={args.width}:-2{tc.replace(',' + timecode_filter(font_prefix), '')}{stamp}", "-frames:v", "1", out]
|
|
91
95
|
run(cmd)
|
package/scripts/render.py
CHANGED
|
@@ -166,6 +166,8 @@ def main() -> int:
|
|
|
166
166
|
part = src
|
|
167
167
|
if c.get("speed"):
|
|
168
168
|
spd = float(c["speed"])
|
|
169
|
+
if not (spd > 0) or spd != spd or spd == float("inf"):
|
|
170
|
+
die(f"clip {i}: speed must be a positive number, got {c['speed']!r}")
|
|
169
171
|
dur = (probe(part).get("duration") or 0.0) if not STATE.dry_run else 10.0
|
|
170
172
|
fitted = str(work / f"clip{i:02d}_speed.mp4")
|
|
171
173
|
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
package/scripts/report.py
CHANGED
package/scripts/sequence.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
import tempfile
|
|
20
20
|
from pathlib import Path
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
22
|
+
from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS, concat_list_line
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def even(n: float) -> int:
|
|
@@ -28,9 +28,7 @@ def even(n: float) -> int:
|
|
|
28
28
|
|
|
29
29
|
|
|
30
30
|
def _concat_list_line(path: Path) -> str:
|
|
31
|
-
|
|
32
|
-
escaped = str(path).replace("\\", "/").replace("'", "'\\''")
|
|
33
|
-
return f"file '{escaped}'"
|
|
31
|
+
return concat_list_line(str(path))
|
|
34
32
|
|
|
35
33
|
|
|
36
34
|
def main() -> int:
|