ffmpeg-skill 1.4.6 → 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 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
- fs.rmSync(t.dir, { recursive: true, force: true });
135
- fs.renameSync(tmpDir, t.dir);
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;
@@ -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.6",
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"
@@ -1,6 +1,6 @@
1
1
  # Script reference
2
2
 
3
- Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) and `check` (skips only the loudness-measurement pass) still run ffprobe/ffmpeg, `sync`/`multicam`/`scenes`/`cropdetect`/`report` still run ffmpeg/ffprobe to measure or analyse (they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
3
+ Every script prints the same information with `--help`; this file exists so the agent can read several at once. All scripts accept `--dry-run`, `--json`, `--fast`, `--progress`, `--timeout SECONDS`, `--overwrite`, `-o OUT` -- but `--dry-run` only guarantees nothing is written for writing tools: `probe` (read-only, `--dry-run` changes nothing) still runs ffprobe, `check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` still run their ffmpeg/ffprobe measurements (a dry-run plan rests on real numbers; they just don't write the final artifact), and `verify` accepts the flag but ignores it entirely. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`).
4
4
 
5
5
  ## Contents
6
6
  - probe.py — inspect
@@ -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).stdout
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``) or, for older call sites, like a dict
210
- (``STATE.dry_run``). Keeping it a single explicit object rather than module globals makes
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")
@@ -462,12 +465,17 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
462
465
  return proc
463
466
 
464
467
 
465
- def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -> subprocess.CompletedProcess:
466
- """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats):
467
- output to `-f null` or a pipe, nothing written. These are not run() calls -- they run under
468
- --dry-run too, since the analysis is the tool's whole job -- but they get the same wall-clock
469
- limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg` failure
470
- instead of an exit-0 "0 scenes found" over a file ffmpeg could not read."""
468
+ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True, record: bool = False) -> subprocess.CompletedProcess:
469
+ """Run an ffmpeg *measurement* (scene scores, crop rectangles, decoded PCM, signal stats,
470
+ silence detection, loudness, stabilisation pass 1): output to `-f null`, a pipe or a temp
471
+ file, no deliverable written. These are not run() calls -- they run under --dry-run too,
472
+ since a plan built on a fake measurement is not a plan (silence.py used to report "0
473
+ silences" and loudness.py a made-up -20 LUFS under --dry-run) -- but they get the same
474
+ wall-clock limit as any other ffmpeg invocation and, with check=True, the same `kind: ffmpeg`
475
+ failure instead of an exit-0 "0 scenes found" over a file ffmpeg could not read. record=True
476
+ lists the command in the --json `commands` like run() does."""
477
+ if record:
478
+ STATE.commands.append(_cmdline(cmd))
471
479
  limit = _limit_for(cmd)
472
480
  try:
473
481
  proc = subprocess.run(list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=text, timeout=limit)
@@ -479,6 +487,17 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -
479
487
  return proc
480
488
 
481
489
 
490
+ def dry_run_input_pending(path: str) -> bool:
491
+ """True when a measurement cannot run because its input does not exist yet under --dry-run:
492
+ in a render.py/batch.py plan each stage's input is the previous stage's output, which a dry
493
+ run never wrote. The measurement is then skipped (with a note) rather than failing the plan;
494
+ on a real file the measurement runs even under --dry-run."""
495
+ if STATE.dry_run and not os.path.exists(path):
496
+ info(f"[dry-run] {path} does not exist yet (an earlier dry-run stage would write it); measurement skipped")
497
+ return True
498
+ return False
499
+
500
+
482
501
  def child_limit(per_call: Optional[float] = None) -> Optional[float]:
483
502
  """Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
484
503
  stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
@@ -896,6 +915,15 @@ class MissingFpsError(ValueError):
896
915
  silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
897
916
 
898
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
+
899
927
  def parse_time(value: str, fps: Optional[float] = None) -> float:
900
928
  """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
901
929
  or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
@@ -912,7 +940,12 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
912
940
  frame, whole_fps = int(f), int(round(fps))
913
941
  if not (0 <= frame < whole_fps):
914
942
  raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
915
- return int(h) * 3600 + int(m) * 60 + int(s) + frame / fps
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
916
949
  if len(parts) > 3:
917
950
  raise ValueError(f"bad time: {value}")
918
951
  total = 0.0
@@ -986,7 +1019,26 @@ def default_font_file(font_name: str) -> Optional[str]:
986
1019
  """
987
1020
  if platform.system() == "Windows":
988
1021
  windir = os.environ.get("WINDIR", "C:\\Windows")
989
- candidate = Path(windir) / "Fonts" / "arial.ttf"
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"
990
1042
  return str(candidate) if candidate.exists() else None
991
1043
  exe = shutil.which("fc-match")
992
1044
  if not exe:
@@ -1142,6 +1194,9 @@ def db_to_linear(db: float) -> float:
1142
1194
  def read_text_or_die(path: str, flag: str) -> str:
1143
1195
  """Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
1144
1196
  with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
1197
+ if os.path.isdir(path):
1198
+ # checked first: Windows raises PermissionError, not IsADirectoryError, for a directory
1199
+ die(f"{flag}: {path} is a directory, not a text file")
1145
1200
  try:
1146
1201
  with open(path, "r", encoding="utf-8") as fh:
1147
1202
  return fh.read()
@@ -215,10 +215,13 @@ DRY_RUN_ANALYSIS = {
215
215
  "scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
216
216
  "report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
217
217
  "cropdetect": "the cropdetect filter runs over the sampled windows to measure bars; this tool never writes a file regardless of --dry-run",
218
+ "silence": "silencedetect runs so the reported silences and keep ranges are real; the cut output is not written",
219
+ "loudness": "the loudnorm measurement pass runs so input_i and the planned pass-2 command are real; the normalised output is not written",
220
+ "check": "read-only tool; the loudness measurement runs under --dry-run too, so every row is present",
221
+ "stabilize": "vidstabdetect (pass 1, into a temp file) runs; the stabilised output (pass 2) is not written",
218
222
  }
219
223
  DRY_RUN_NOTES = {
220
224
  "probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
221
- "check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
222
225
  "verify": "not supported: the flag is accepted but the steps run and outputs are written",
223
226
  }
224
227
 
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
- fades.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
95
- fades.add_argument("--mono", action="store_true", help="force 1-channel output")
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
@@ -31,10 +31,11 @@ import time
31
31
  from pathlib import Path
32
32
  from typing import Any, Dict, List
33
33
 
34
- from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool
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", ".wav", ".m4a", ".mp3", ".flac"}
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
@@ -102,7 +103,10 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict
102
103
  final = final_path(src, recipe, outdir)
103
104
  t0 = time.time()
104
105
  if recipe.get("project"):
105
- proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
106
+ try:
107
+ proj = json.loads(read_text_or_die(str(recipe["project"]), "recipe.project"))
108
+ except ValueError as e:
109
+ die(f"recipe.project: {recipe['project']} is not valid JSON: {e}")
106
110
  idx = int(recipe.get("clip_key", 0))
107
111
  proj.setdefault("clips", [{}])
108
112
  while len(proj["clips"]) <= idx:
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
@@ -36,7 +36,7 @@ import sys
36
36
  from pathlib import Path
37
37
  from typing import List, Optional, Tuple
38
38
 
39
- from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS
39
+ from _common import STATE, color_hex, load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, fmt_smpte_time, info, MissingFpsError, parse_time, probe, run, x264_args, X264_PRESETS, read_text_or_die
40
40
 
41
41
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
42
42
 
@@ -48,33 +48,32 @@ TIME_RE = re.compile(
48
48
  def parse_text_cues(path: str, auto_seconds: float, gap: float, fps: Optional[float] = None) -> List[Tuple[float, float, str]]:
49
49
  cues: List[Tuple[float, float, str]] = []
50
50
  cursor = 0.0
51
- with open(path, encoding="utf-8") as fh:
52
- for raw in fh:
53
- line = raw.rstrip("\n")
54
- if not line.strip():
55
- continue
56
- m = TIME_RE.match(line)
57
- if m:
58
- try:
59
- start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
60
- except MissingFpsError as e:
61
- die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
62
- except ValueError:
63
- # TIME_RE matched (so m.group("text") is the real cue text, not the broken
64
- # timestamp), but one of the two timestamps itself failed to parse (e.g. a
65
- # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
66
- # burn the whole raw line, broken timestamp included, into the caption instead
67
- # of just the text after it.
68
- start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
69
- else:
70
- text = m.group("text").strip()
51
+ for raw in read_text_or_die(path, "--text").lstrip("\ufeff").splitlines(True):
52
+ line = raw.rstrip("\n")
53
+ if not line.strip():
54
+ continue
55
+ m = TIME_RE.match(line)
56
+ if m:
57
+ try:
58
+ start, end = parse_time(m.group("a"), fps), parse_time(m.group("b"), fps)
59
+ except MissingFpsError as e:
60
+ die(f"cue '{line}': {e} -- pass --fps, or --input's own fps is used automatically when given")
61
+ except ValueError:
62
+ # TIME_RE matched (so m.group("text") is the real cue text, not the broken
63
+ # timestamp), but one of the two timestamps itself failed to parse (e.g. a
64
+ # malformed "00:00:03.15.999") -- falling back to `line.strip()` here used to
65
+ # burn the whole raw line, broken timestamp included, into the caption instead
66
+ # of just the text after it.
67
+ start, end, text = cursor, cursor + auto_seconds, m.group("text").strip()
71
68
  else:
72
- start, end, text = cursor, cursor + auto_seconds, line.strip()
73
- if end <= start:
74
- die(f"cue '{line}': end must be after start")
75
- text = text.replace(" | ", "\n").replace("|", "\n")
76
- cues.append((start, end, text))
77
- cursor = end + gap
69
+ text = m.group("text").strip()
70
+ else:
71
+ start, end, text = cursor, cursor + auto_seconds, line.strip()
72
+ if end <= start:
73
+ die(f"cue '{line}': end must be after start")
74
+ text = text.replace(" | ", "\n").replace("|", "\n")
75
+ cues.append((start, end, text))
76
+ cursor = end + gap
78
77
  if not cues:
79
78
  die(f"no cues found in {path}")
80
79
  return cues
@@ -110,7 +109,7 @@ def _asr_run(cmd: List[str], subprocess, name: str) -> "subprocess.CompletedProc
110
109
 
111
110
  def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str], model: str, audio_stream: int,
112
111
  ffmpeg: str, shutil, subprocess) -> List[Tuple[float, float, str]]:
113
- from _common import run_analysis
112
+ from _common import run_analysis, STATE, die
114
113
  wav = os.path.join(tmpdir, "audio.wav")
115
114
  # A wav in our own temp dir: a measurement input for the engine, not a deliverable, so it
116
115
  # is not a run() call (no --dry-run gate, not recorded), but it keeps the time limit and
@@ -118,8 +117,14 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
118
117
  run_analysis([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video,
119
118
  "-map", f"0:a:{audio_stream}", "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav])
120
119
  # 1. whisper.cpp
121
- cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
122
- if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
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:
123
128
  model_path = model
124
129
  if not os.path.exists(model_path):
125
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"):
@@ -140,9 +145,21 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
140
145
  # 2. faster-whisper (python package)
141
146
  try:
142
147
  from faster_whisper import WhisperModel # type: ignore
143
- m = WhisperModel(model, device="cpu", compute_type="int8")
144
- segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
145
- cues = [(seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip()]
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)
146
163
  if cues:
147
164
  info("transcribed with faster-whisper")
148
165
  write_srt(cues, out_srt)
@@ -173,8 +190,7 @@ def _transcribe_in(tmpdir: str, video: str, out_srt: str, language: Optional[str
173
190
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
174
191
  cues: List[Tuple[float, float, str]] = []
175
192
  block: List[str] = []
176
- with open(path, encoding="utf-8-sig") as fh:
177
- content = fh.read().replace("\r\n", "\n") + "\n\n"
193
+ content = read_text_or_die(path, "--srt").lstrip("\ufeff").replace("\r\n", "\n") + "\n\n"
178
194
  for line in content.split("\n"):
179
195
  if line.strip():
180
196
  block.append(line)
package/scripts/check.py CHANGED
@@ -25,7 +25,7 @@ import sys
25
25
  from fractions import Fraction
26
26
  from typing import Any, Dict, List
27
27
 
28
- from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run
28
+ from _common import STATE, add_common, apply_common, die, emit, info, probe, require_tool, run, run_analysis, dry_run_input_pending
29
29
 
30
30
  SPECS: Dict[str, Dict[str, Any]] = {
31
31
  "youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
@@ -41,8 +41,10 @@ SPECS: Dict[str, Dict[str, Any]] = {
41
41
 
42
42
 
43
43
  def measure_loudness(path: str) -> Dict[str, float]:
44
+ if dry_run_input_pending(path):
45
+ return {}
44
46
  ffmpeg = require_tool("ffmpeg")
45
- proc = run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], quiet=True, check=False)
47
+ proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], check=False, record=True)
46
48
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
47
49
  if not m:
48
50
  return {}
@@ -42,14 +42,22 @@ def detect(path: str, seconds: float, samples: int, limit: float, round_to: int,
42
42
  ffmpeg = require_tool("ffmpeg")
43
43
  per_window = max(0.5, seconds / max(1, samples))
44
44
  rects: List[Tuple[int, int, int, int]] = []
45
+ failures: List[List[str]] = []
45
46
  for i in range(samples):
46
47
  start = 0.0 if duration <= 0 else (duration - per_window) * i / max(1, samples - 1) if samples > 1 else 0.0
47
48
  start = max(0.0, start)
48
49
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{per_window:.3f}",
49
50
  "-vf", f"cropdetect=limit={limit:g}:round={round_to}:reset=1", "-f", "null", "-"]
50
- proc = run_analysis(cmd)
51
+ proc = run_analysis(cmd, check=False)
52
+ if proc.returncode != 0:
53
+ # One window ffmpeg cannot decode (a damaged stretch) is skipped; the other windows
54
+ # still measure. Only when every window fails is there nothing to report.
55
+ failures.append(proc.stderr.strip().splitlines()[-1:] or ["?"])
56
+ continue
51
57
  for m in CROP_RE.finditer(proc.stderr):
52
58
  rects.append((int(m.group(1)), int(m.group(2)), int(m.group(3)), int(m.group(4))))
59
+ if failures and len(failures) == samples:
60
+ die(f"cropdetect could not decode any of the {samples} sampled windows: {failures[-1][0][:300]}", kind="ffmpeg")
53
61
  return rects
54
62
 
55
63
 
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 parse_segments(spec: str) -> List[Tuple[float, float]]:
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 = parse_time(a), parse_time(b)
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 = parse_time(args.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 = parse_time(args.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 + parse_time(args.duration)
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("file '" + p.replace("'", "'\\''") + "'\n")
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", type=float, help="timestamp to freeze, in seconds (default: the last frame)")
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
- at = args.at if args.at is not None else dur
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
- pixfmt = "yuv420p10le" if (metas[0].get("video") or {}).get("hdr") else "yuv420p"
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
- out = os.path.join(outdir, f"{args.output and Path(args.output).stem or stem}_{sec:.3f}s.png")
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)
@@ -18,16 +18,18 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
21
+ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, run_analysis, dry_run_input_pending
22
22
 
23
23
 
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
- if STATE.dry_run:
27
- return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False}
26
+ if dry_run_input_pending(path):
27
+ return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0", "silent": False, "placeholder": True}
28
28
  ffmpeg = require_tool("ffmpeg")
29
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
30
- proc = run(cmd, check=False)
30
+ # Pass 1 is a measurement: it runs under --dry-run too, so the planned pass-2 command and
31
+ # the reported input_i are real (before 1.4.6 a dry run returned a made-up -20 LUFS).
32
+ proc = run_analysis(cmd, check=False, record=True)
31
33
  m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
32
34
  if proc.returncode != 0 or not m:
33
35
  die(f"loudness measurement failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
@@ -86,6 +88,10 @@ def main() -> int:
86
88
  cmd.append(output)
87
89
  run(cmd)
88
90
 
91
+ if STATE.dry_run:
92
+ # pass 1 measured the input for real; there is no output to measure
93
+ emit(output, measured={k: stats[k] for k in ("input_i", "input_tp", "input_lra", "input_thresh", "target_offset", "silent")})
94
+ return 0
89
95
  after = measure(output, args.lufs, args.tp, args.lra)
90
96
  if not after.get("silent"):
91
97
  info(f"result: {float(after['input_i']):.1f} LUFS, TP {float(after['input_tp']):.1f} dBTP (target {args.lufs} LUFS)")
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)
@@ -358,7 +360,7 @@ def main() -> int:
358
360
  check_result = None
359
361
  exit_code = 0
360
362
  if ck and ck.get("platform") and not STATE.dry_run:
361
- proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"])
363
+ proc = run_tool([str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"] + child_args())
362
364
  try:
363
365
  check_result = json.loads(proc.stdout)
364
366
  except ValueError:
@@ -386,7 +388,8 @@ def main() -> int:
386
388
  # spec (or the check itself could not run): a failed delivery, reported as one.
387
389
  failed_rows = [r["check"] for r in (check_result or {}).get("checks", []) if r.get("status") == "FAIL"]
388
390
  die(f"rendered {output} but the {ck['platform']} check failed" + (f": {', '.join(failed_rows)}" if failed_rows else ""),
389
- kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result)
391
+ kind="verification", output=output, dry_run=STATE.dry_run, stages=stages_done, check=check_result,
392
+ probe=probe(output, role="output"))
390
393
  info(f"rendered {output} via {' → '.join(stages_done)}")
391
394
  emit(output, stages=stages_done, check=check_result)
392
395
  return 0
package/scripts/report.py CHANGED
@@ -18,7 +18,7 @@ import tempfile
18
18
  from pathlib import Path
19
19
  from typing import Any, Dict, List, Optional
20
20
 
21
- from _common import STATE, add_common, apply_common, die, emit, info, probe, read_text_or_die, run_tool
21
+ from _common import STATE, add_common, apply_common, child_args, die, emit, info, probe, read_text_or_die, run_tool
22
22
 
23
23
  HERE = Path(__file__).resolve().parent
24
24
 
@@ -26,14 +26,14 @@ HERE = Path(__file__).resolve().parent
26
26
  def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
27
27
  with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
28
28
  png = os.path.join(tmp, "sheet.png")
29
- proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png])
29
+ proc = run_tool([str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png] + child_args())
30
30
  if proc.returncode != 0 or not os.path.exists(png):
31
31
  return None
32
32
  return base64.b64encode(Path(png).read_bytes()).decode("ascii")
33
33
 
34
34
 
35
35
  def loudness(path: str) -> Dict[str, Any]:
36
- proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"])
36
+ proc = run_tool([str(HERE / "loudness.py"), path, "--measure-only"] + child_args())
37
37
  try:
38
38
  d = json.loads(proc.stdout)
39
39
  return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
@@ -42,7 +42,7 @@ def loudness(path: str) -> Dict[str, Any]:
42
42
 
43
43
 
44
44
  def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
45
- proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"])
45
+ proc = run_tool([str(HERE / "check.py"), path, "--platform", platform, "--json"] + child_args())
46
46
  try:
47
47
  doc = json.loads(proc.stdout)
48
48
  except ValueError:
@@ -57,7 +57,7 @@ def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
57
57
 
58
58
 
59
59
  def fmt_dur(sec: Optional[float]) -> str:
60
- if not sec:
60
+ if sec is None:
61
61
  return "?"
62
62
  m, s = divmod(sec, 60)
63
63
  h, m = divmod(int(m), 60)
@@ -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
- # concat demuxer file paths: backslash and single-quote need escaping inside the quoted form.
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:
@@ -12,20 +12,23 @@ Examples:
12
12
  python3 silence.py talk.mp4 --edl keep.txt # also save the kept ranges (START-END per line, cut.py --segments format)
13
13
  """
14
14
  import argparse
15
+ import os
15
16
  import re
16
17
  import sys
17
18
  from typing import List, Tuple
18
19
 
19
- from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs
20
+ from _common import STATE, video_args, add_common, apply_common, audio_codec_for, cfr_args, default_output, die, emit, ffmpeg_base, info, is_audio_output, print_json, probe, require_tool, run, x264_args, X264_PRESETS, measured_level_dbfs, run_analysis, dry_run_input_pending
20
21
 
21
22
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
23
 
23
24
 
24
25
  def detect(path: str, threshold: float, min_silence: float) -> List[Tuple[float, float]]:
26
+ if dry_run_input_pending(path):
27
+ return []
25
28
  ffmpeg = require_tool("ffmpeg")
26
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af",
27
30
  f"silencedetect=noise={threshold}dB:d={min_silence}", "-f", "null", "-"]
28
- proc = run(cmd, quiet=True, check=False)
31
+ proc = run_analysis(cmd, check=False, record=True)
29
32
  if proc.returncode != 0:
30
33
  die(f"silencedetect failed:\n{proc.stderr.strip()[-800:]}", kind="ffmpeg")
31
34
  silences: List[Tuple[float, float]] = []
@@ -86,7 +89,7 @@ def main() -> int:
86
89
  "removed_seconds": round(removed, 3),
87
90
  }
88
91
  info(f"{len(silences)} silences, keeping {len(keeps)} ranges: {kept:.2f}s of {duration:.2f}s (removing {removed:.2f}s)")
89
- if not silences and not STATE.dry_run:
92
+ if not silences and not (STATE.dry_run and not os.path.exists(args.input)):
90
93
  # Nothing under the threshold is a valid result, not a failure -- but an agent that only
91
94
  # sees "0 silences" tends to reach for raw ffmpeg next. Say what the floor actually is and
92
95
  # what threshold would bite, so the retry is a flag change, not a workaround.
@@ -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
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
31
31
 
32
32
 
33
33
  def main() -> int:
@@ -62,18 +62,20 @@ def main() -> int:
62
62
  trf = str(Path(tmp) / "transforms.trf")
63
63
  trf_arg = escape_filter_path(trf)
64
64
 
65
- if not STATE.dry_run:
66
- ffmpeg = require_tool("ffmpeg")
67
- detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
68
- if args.tripod:
69
- # A frame number, not a boolean: frame 1 is the standard reference for "lock to
70
- # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
71
- detect_vf += ":tripod=1"
72
- detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
73
- "-vf", detect_vf, "-f", "null", "-"]
74
- proc = run(detect_cmd, check=False)
65
+ ffmpeg = require_tool("ffmpeg")
66
+ detect_vf = f"vidstabdetect=shakiness={args.shakiness}:result={trf_arg}"
67
+ if args.tripod:
68
+ # A frame number, not a boolean: frame 1 is the standard reference for "lock to
69
+ # this fixed frame" (mirrors vidstabtransform's own tripod=1 below).
70
+ detect_vf += ":tripod=1"
71
+ detect_cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", args.input,
72
+ "-vf", detect_vf, "-f", "null", "-"]
73
+ # Pass 1 is a measurement into a temp file (the transforms), so it runs under --dry-run
74
+ # as well; only pass 2, the write, is skipped there.
75
+ if not dry_run_input_pending(args.input):
76
+ proc = run_analysis(detect_cmd, check=False, record=True)
75
77
  if proc.returncode != 0:
76
- die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}")
78
+ die(f"stabilization analysis (pass 1) failed:\n{proc.stderr.strip()[-1500:]}", kind="ffmpeg")
77
79
 
78
80
  crop_mode = {"keep": 0, "black": 1}[args.crop]
79
81
  transform_vf = f"vidstabtransform=input={trf_arg}:smoothing={args.smoothing}:crop={crop_mode}:zoom={args.zoom:g}:optzoom=1"