ffmpeg-skill 1.4.7 → 1.4.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -316,6 +316,21 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
316
316
 
317
317
  `doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). No tool here uses one yet — every tool still assumes CPU x264/x265 — so this is purely informational and never affects `ok` or any tool's `usable`. GPU-accelerated encoding stays deliberately off the roadmap until there's a real-hardware-verified design for it (build-presence alone is not proof a job will succeed) — not a promised feature, just an honest "not yet, and not without proof it actually works."
318
318
 
319
+ ## Gotchas and best practices
320
+
321
+ The short list for humans. The agent-facing version, with the reasoning, is the "Things that look right but are wrong" and "Gotchas" sections of [SKILL.md](SKILL.md).
322
+
323
+ - **Variable frame rate (phone and screen recordings).** `probe.py` flags it; every re-encoding tool conforms to a constant rate automatically, and `cut.py` switches to frame-accurate mode on its own because copy-cuts on VFR land on the wrong frame. Choose the rate yourself with `fit.py input.mp4 --fps 30` when the measured average is odd.
324
+ - **Lossless cuts snap to keyframes.** A stream-copy cut can start up to one GOP earlier than asked. `cut.py` re-encodes when the snap exceeds 0.5 s (`--tolerance` changes the limit). For a strictly lossless file pass `--tolerance -1`, and expect the cut to land on the nearest earlier keyframe; the JSON result lists them under `nearest_keyframes`.
325
+ - **HDR stays HDR.** When the probe reports HDR (HDR10, HLG, Dolby Vision, BT.2020), the tools keep it rather than flatten it. Convert deliberately with `color.py --to-sdr` before H.264 deliverables or LUT work. `export.py` platform presets are SDR and warn on HDR input.
326
+ - **Loudness targets.** −14 LUFS / −1 dBTP for YouTube and social platforms (the `loudness.py` default), `-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast. A clip measured at −40 LUFS or below is room tone, not content; raising it raises the noise. Check true peak as well as LUFS: `check.py file --platform podcast` measures both.
327
+ - **Frame changes first, text second.** Captions and overlays burned before a crop or resize end up off-frame. Reframe, then caption.
328
+ - **Cropping 16:9 to 9:16 discards 70 % of the width.** `fit.py --fit crop` centres by default; pass `--crop-x`/`--crop-y` toward the subject, or pad with `--fit pad --pad-fill blur`. Look at the contact sheet before deciding.
329
+ - **Non-Latin captions need a font with the glyphs.** Without one you get boxes, not an error. Name it (`caption.py --font "Noto Sans CJK JP"`) or point at the file (`overlay.py --font-file /path/to/NotoSansCJK-Regular.ttc`).
330
+ - **Silence detection finds nothing?** The default threshold is −35 dBFS. The tool prints a hint with the track's measured level; raise the threshold (`silence.py --threshold -25`) or shorten `--min-silence`.
331
+ - **Sync results carry a confidence.** Below 0.3, or an offset near the edge of the analysis window, is probably wrong: enlarge `--analyze-seconds` or find a clap. Recordings over ten minutes from separate devices need `sync.py --fix-drift`.
332
+ - **Long chains belong in a plan.** Three hand-chained re-encodes lose quality and are hard to change; `render.py` runs the whole edit from one JSON file, and `--dry-run` shows every ffmpeg command before anything is written.
333
+
319
334
  ## FFmpeg compatibility
320
335
 
321
336
  The tools need FFmpeg 5.0 or later and Python 3.9 or later (standard library only). What CI actually exercises on every pull request is FFmpeg 5.1.1 (static build), 6.1 (Ubuntu apt), 7.1 (Debian trixie apt), 8.x (macOS Homebrew) and 9.x (Windows gyan.dev), on Python 3.9 and 3.13 (the two ends of the supported range). The capability parser has been run against the listings of these builds:
package/SKILL.md CHANGED
@@ -5,7 +5,7 @@ description: 'Edit video and audio with local FFmpeg from natural-language reque
5
5
 
6
6
  # ffmpeg-skill
7
7
 
8
- Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact; `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
8
+ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>/scripts/<name>.py`. Every script has `--help`, and all of them accept `--dry-run`, `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress`, `--timeout SECONDS` (a single ffmpeg run is killed past this and reported as `kind: timeout`; default 1800) and `--overwrite` (consent to replace an output that already exists; without it the tool warns today and refuses from 2.0). Writing tools run nothing under `--dry-run`; `probe`/`check`/`sync`/`multicam`/`scenes`/`cropdetect`/`report`/`silence`/`loudness`/`stabilize` may still run ffmpeg/ffprobe to measure or analyse — they just don't write their final artifact (nor side files such as `--edl`, `--sheet` or a generated `.ass`); `verify` accepts the flag but ignores it. Exact per-tool semantics: `contract --json`'s `dry_run` field (or `docs/contract.md`). Details for every flag: `references/scripts.md`. Device-specific behaviour (iPhone HDR, GoPro, DJI, screen recordings, Zoom): `references/devices.md`.
9
9
 
10
10
  ## Workflow (always follow this order)
11
11
 
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,389 @@
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.9`) | 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.9", "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`, `report`, `silence`,
160
+ `loudness` and `stabilize` still run their ffmpeg/ffprobe measurements (the analysis is the
161
+ tool's job; only the artifact is skipped, including side files such as `--edl`, `--sheet` or a
162
+ generated `.ass`), and `verify` does not support dry-run (its steps run). `SKILL.md` and
163
+ `references/scripts.md` repeat the same list; the contract is the authority.
164
+
165
+ ### Repeatability
166
+
167
+ No tool keeps state or uses randomness. `deterministic_inputs` is `false` only for
168
+ `verify`, whose output includes timings. `idempotency_hint` says what "same inputs"
169
+ gives you:
170
+
171
+ | Hint | Tools |
172
+ |---|---|
173
+ | `bit_exact` | probe, check, scenes, look |
174
+ | `content_equivalent` (same media, bytes may differ between encoder builds) | every encoding tool, cut, sync, report |
175
+ | `cached` | batch (content-hash cache, re-runs skip unchanged inputs) |
176
+ | `environment_dependent` | verify |
177
+
178
+ ## `provides`
179
+
180
+ `provides` lists these 42 tools by a cross-repository Capability id, for
181
+ `kajisho5/AI-video-production-OS`'s `CapabilityContract.provides`
182
+ (`docs/SPEC.md` there), matching the ids already assigned to this Skill in
183
+ that project's own `docs/CAPABILITY_MATRIX.md` section 9 ("ffmpeg-skill's
184
+ 21 raw tools ... are Capabilities in their own right, independent of the
185
+ higher-level Skills that delegate to them"): `[{"id": "ffmpeg-skill.<tool>",
186
+ "lifecycle": "EXPERIMENTAL", "tool_id": "ffmpeg-skill/<tool>"}, ...]`, one
187
+ entry per tool, sorted by id. The Capability id uses a dot
188
+ (`ffmpeg-skill.cut`) - the `<domain>.<verb>` shape every other Skill's
189
+ Capability ids use elsewhere in that project (`video.trim`, `audio.gain`,
190
+ ...), with `ffmpeg-skill` as the domain - while `tool_id` carries this
191
+ contract's own slash-shaped `id` (`ffmpeg-skill/cut`) unchanged. It is
192
+ purely additive: derived from `public_tools()`, saying nothing `tools[]`
193
+ doesn't already say, only indexed by Capability id instead of tool name.
194
+
195
+ ## `capability_map`
196
+
197
+ `provides` re-indexes each tool by an id shaped like the tool name
198
+ (`ffmpeg-skill.cut`); it doesn't tell a caller that "I need to trim a
199
+ video" resolves to `cut`. `capability_map` is the small, hand-authored
200
+ table that closes that gap: `[{"capability": "<domain>.<verb>", "tool_id":
201
+ "ffmpeg-skill/<tool>", "params": {...}}, ...]`. A planner that only knows
202
+ an abstract goal (`video.trim`, `audio.loudness`, `subtitle.burn`,
203
+ `media.stream.inspect`, `media.frames.extract`, `media.proxy`) looks it up here to find
204
+ the tool, then builds and runs that tool's own call from its
205
+ `input_schema` exactly as it would have if it already knew the tool name -
206
+ `capability_map` never executes anything itself, and this skill never
207
+ picks a capability on the caller's behalf.
208
+
209
+ Some entries also fix one or more `params` where the capability names a
210
+ *specific* behaviour narrower than the whole tool: `video.reframe` maps
211
+ to `fit` with `params: {"fit": "crop"}`, because `fit.py` also does
212
+ duration-fit and letterbox padding, and only the crop mode is a
213
+ "reframe". A caller resolving `video.reframe` should treat those params
214
+ as fixed inputs to that tool's own schema, not as optional defaults.
215
+
216
+ This list is deliberately short and will stay short: a capability is only
217
+ added when resolving it is a mechanical, no-judgment lookup. There is no
218
+ `video.highlight` entry, for instance, because `scenes.py --highlights`
219
+ ranks candidates by a measured proxy (audio energy or duration), never by
220
+ understood content - offering it as a blindly-delegable capability would
221
+ misrepresent what it does (see SKILL.md, "What this skill does and does
222
+ not decide"). `media.proxy` (a low-bitrate, fast-decode proxy for
223
+ downstream analysis/preview, distinct from `export.py`'s delivery
224
+ presets) resolves to `proxy` - itself a mechanical resize + re-encode
225
+ with no opinion on which asset should be proxied or what for.
226
+
227
+ ## Capabilities
228
+
229
+ Names: `ffmpeg`, `ffprobe`, `encoder:<name>`, `filter:<name>`, `bsf:<name>`,
230
+ `external:whisper`. `capabilities.required` is the union of every tool's required list;
231
+ `optional` the union of the conditional ones. With detection (the default)
232
+ `available`, `missing` and `missing_optional` are added from `doctor`, which reads
233
+ `ffmpeg -encoders / -filters / -bsfs` and looks for a local whisper. Pass `--static`
234
+ to omit detection. Nothing from the environment other than those lists and the
235
+ ffmpeg/ffprobe/python versions is printed; no environment variables, no paths.
236
+
237
+ `doctor` has three states per capability. `available` and `missing` come from a listing
238
+ that was read; `unknown` means the listing that would prove the capability could not be
239
+ read (`ffmpeg -filters` in a layout the parser does not recognise, or ffmpeg exiting
240
+ non-zero), and it is never folded into `missing`, so an installed filter is not reported
241
+ absent, nor into `available`, so a failed detection is not a pass. `detection` gives the
242
+ status (`parsed`, `unparsed`, `failed`, `missing`), row count and detail of each listing;
243
+ `errors` lists the unreadable ones. The filter parser recognises the FFmpeg 6/7 layout
244
+ (three flag characters, `..C acompressor A->A`) and the FFmpeg 8 layout (two, `T.
245
+ acompressor A->A`) by the io-spec token, so the flag width does not matter; fixtures for
246
+ both live in `tests/fixtures/`.
247
+
248
+ `ffmpeg-skill doctor` exits 0 when every required capability is available, 1 when one is
249
+ missing, 2 when none is missing but a required one is unknown. `ok` is true only for 0.
250
+ The keys of 0.9.0 (`available`, `missing`, `missing_optional`, `ok`) are unchanged.
251
+
252
+ `doctor`'s `tools` field folds that same per-capability `state` into a per-tool answer:
253
+ `{"<tool>": {"usable": "yes"|"no"|"unknown", "missing": [...], "fix": "...", "unknown": [...]}}`.
254
+ `missing`/`unknown` list only that tool's own required capabilities that are in that state
255
+ (`missing` is absent when there is none, same for `unknown`); `fix` is a one-line, plain-language
256
+ remedy for each missing capability, joined with "; " when there is more than one. This exists so
257
+ a caller does not have to cross-reference `available`/`missing` against each tool's own required
258
+ capabilities by hand to answer "can I run `caption.py` on this machine right now" -- `doctor`
259
+ passing overall does not mean every tool is usable (a plain Homebrew `ffmpeg` on macOS is `ok`
260
+ for tools that don't need `subtitles`/`drawtext`/`zscale`, but `caption.usable` is `"no"`).
261
+
262
+ `doctor`'s `gpu_encoders` field reports GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`,
263
+ `vaapi`, `amf`) present in this ffmpeg *build*, read from `-encoders` alone — `{"status":
264
+ "parsed"|"unparsed"|"failed"|"missing", "present": [...]}`. It proves the build shipped the
265
+ capability, not that the GPU/driver on this machine will accept a job (that needs a real
266
+ encode, which this introspection never runs). No tool declares or requires a GPU encoder, so
267
+ `gpu_encoders` never affects `ok` or any tool's `usable` — it exists purely so a caller can ask
268
+ the same honest yes/no/unknown question about GPU support that filter/encoder detection already
269
+ answers for everything else, without a tool here needing to use one.
270
+
271
+ `doctor`'s `fonts` field reports whether the default drawtext font (`caption.py`'s
272
+ `--animate`/`--karaoke`, `graphics.py`'s templates — `BRAND_DEFAULTS["font"]`, `"DejaVu Sans"`)
273
+ is actually installed — `{"default_font": "...", "status": "available"|"missing"|"unknown",
274
+ "detail": "..."}`. drawtext's `font=` is a fontconfig name lookup, and fontconfig silently
275
+ substitutes the closest match for *any* name, known or not — a missing font never fails the
276
+ encode, so drawtext's own exit code cannot detect it. `fc-match` is queried instead: `available`
277
+ when it resolves the name to itself, `missing` when it substitutes a different family, `unknown`
278
+ when `fc-match` itself is not on PATH or fails. Like `gpu_encoders`, this is purely informational
279
+ and never affects `ok` or any tool's `usable` — a substituted font is not a broken tool, just a
280
+ typeface the caller didn't ask for.
281
+
282
+ ## Invocation
283
+
284
+ Structured arguments are the canonical way to call a tool, on the CLI or through MCP.
285
+ The mapping is stated in `invocation.structured.argument_mapping`: positionals in
286
+ `input_schema.positional` order, `key` → `--key` with `_` → `-`, booleans as bare flags,
287
+ arrays repeated, `output` → `-o`, `loudness.lufs` → `-I`. `--json` is appended for every
288
+ tool except `probe` (JSON by default) and `look`.
289
+
290
+ The MCP server also accepts `{"argv": [...]}` for CLI compatibility. That path is
291
+ marked `canonical: false`: it is still bound to the named script and never reaches a
292
+ shell, but an agent ecosystem should use the structured form. No tool, CLI or MCP,
293
+ runs a shell, evaluates strings, or executes anything other than the named script,
294
+ `ffmpeg` and `ffprobe`.
295
+
296
+ ## JSON output
297
+
298
+ Success (`exit 0`): one document matching `output_schema`, always with
299
+ `status: "completed"`, `output`, `dry_run`, `commands`, and `probe` of the output when a
300
+ file was written. `probe` prints its measurement document directly.
301
+
302
+ Success is decided by `verify_output` in `_common.py`, not by the ffmpeg exit code alone:
303
+ the file must exist, be non-empty and give ffprobe at least one stream. A tool that ran
304
+ ffmpeg successfully but has no usable artifact fails with `kind: output` (a 0-byte file is
305
+ removed so a later step cannot mistake it for a result).
306
+
307
+ Failure (non-zero exit; 127 when ffmpeg/ffprobe is missing): the message on stderr as
308
+ before, and, when `--json` was given, on stdout:
309
+
310
+ ```json
311
+ {"status": "failed", "exit_code": 1,
312
+ "error": {"kind": "input | ffmpeg | output | missing_tool | timeout | verification", "message": "...",
313
+ "code": "INPUT_INVALID | DEPENDENCY_MISSING | FFMPEG_EXECUTION_FAILED | OUTPUT_INVALID | TIMEOUT | VERIFICATION_FAILED | INTERNAL_ERROR",
314
+ "retryable": false},
315
+ "commands": ["ffmpeg ..."]}
316
+ ```
317
+
318
+ `message` carries the script's own reason (missing input, ffprobe failure, the last
319
+ stderr lines of ffmpeg, the verification that failed); an optional `error.hint` names the
320
+ flag change that would make a retry meaningful (never a diagnosis of the media); `commands` lists what was planned
321
+ or run so the caller can retry or report without re-deriving the command. `code` is a
322
+ purely additive, statically-mapped relabelling of `kind` (never a new distinction `kind`
323
+ doesn't already make) for a caller that wants a stable enum instead of matching `kind`
324
+ strings. `retryable` is currently always `false`: none of the kinds are distinguishable
325
+ today from a deterministic failure that would fail identically on a blind retry, so nothing
326
+ here claims otherwise until real exit-code/stderr sniffing exists to back that up.
327
+
328
+ ## MCP relationship
329
+
330
+ `mcp/server.py` is a transport. It holds no tool table and no schema of its own:
331
+
332
+ ```
333
+ argparse parser → ToolSpec.input_schema → contract → MCP tools/list inputSchema
334
+ ```
335
+
336
+ At start-up the server builds the ToolSpecs (`_contract.build(detect=False)`) and
337
+ derives each `tools/list` entry with `_contract.mcp_tool`: the name is the ToolSpec
338
+ name, the order is the contract's sorted order, and `inputSchema` is
339
+ `_contract.mcp_input_schema(ToolSpec)`. `tools/call` maps structured arguments to
340
+ argv with the ToolSpec's `mcp.positional` and `mcp.argument_exceptions`. A new
341
+ public script, a removed one, or a changed parser therefore changes the MCP surface
342
+ with no edit to `mcp/`; `tests/test_contract.py` proves this by copying the skill,
343
+ adding, removing and editing scripts, and reading `tools/list` again.
344
+
345
+ ### Translation, and what JSON Schema cannot say
346
+
347
+ | ToolSpec.input_schema | MCP inputSchema |
348
+ |---|---|
349
+ | `properties.<dest>.type / enum / default / description / items` | copied as is |
350
+ | `properties.<dest>.cli`, `.common` | dropped (ffmpeg-skill-only keys) |
351
+ | `positional` | same properties, passed by name; description gets a `(positional N)` prefix |
352
+ | `required` | `required` of the structured branch |
353
+ | `mutually_exclusive` groups | `allOf: [{not: {required: [a, b]}} …]` for every pair |
354
+ | `one_of_required` groups | `anyOf: [{required: [a]}, …]` |
355
+ | raw `argv` compatibility | an `argv` array property; top-level `anyOf: [{required: [argv]}, <structured branch>]` |
356
+ | `additionalProperties: false` | kept |
357
+
358
+ Two things are documented rather than encoded, because JSON Schema has no way to
359
+ express them: when `argv` is present every other key is ignored (stated in the `argv`
360
+ description), and MCP has no notion of positional order, so positionals are named
361
+ properties whose order is only informative. `%(default)s` help interpolation is
362
+ already applied when the ToolSpec is built.
363
+
364
+ The `tools/list` document is deterministic (byte-identical across processes and
365
+ identical to the translation of `contract --json`), which the tests check.
366
+
367
+ ## Consuming the contract from an agent
368
+
369
+ A planning agent (for example video-production-agent's SkillRegistry) can:
370
+
371
+ 1. run `ffmpeg-skill contract --json` once and register the skill by `skill.id` and the
372
+ tools by `id`;
373
+ 2. resolve `capabilities.required` against `capabilities.available` before planning;
374
+ 3. pick a tool by `role`, `inputs`/`outputs`, `video_required` and `audio_only`;
375
+ 4. build the call from `input_schema` and the argument mapping, plan with `--dry-run`;
376
+ 5. run, parse `output_schema`, then run `verification.tools`, adding `look` when
377
+ `requires_visual_verification` is true.
378
+
379
+ The measurement documents (`probe`, `check`, `scenes`, `sync`) are ffmpeg-skill's own
380
+ shapes, not another system's Observation model; convert them in the agent's adapter.
381
+ ffmpeg-skill contains no agent-specific code.
382
+
383
+ ## Where things live
384
+
385
+ - `scripts/_contract.py`: the generator (`--json`, `--static`, `doctor`)
386
+ - `bin/install.js`: `ffmpeg-skill contract` and `ffmpeg-skill doctor`
387
+ - `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
388
+ - `evals/contract/`: questions an agent must answer from the contract alone, with the expected answers checked against the live contract
389
+ - `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.7",
3
+ "version": "1.4.9",
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"
@@ -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")
@@ -353,6 +356,26 @@ def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
353
356
  continue
354
357
 
355
358
 
359
+ def refuse_output_is_input(output: str, *inputs: str) -> None:
360
+ """Tool-level twin of the run() guard, for tools whose final ffmpeg command does not name
361
+ the user's input at all. `cut.py --segments` cuts each part into a temp dir and then concats
362
+ a list file: the last command's only `-i` is that list, so `-o` equal to the input sailed
363
+ through _check_no_overwrite_input() and replaced the source with the join (fourth audit,
364
+ P0). Call it once the output path is known, before any part of the input is consumed."""
365
+ try:
366
+ out_real = os.path.realpath(output)
367
+ except OSError:
368
+ return
369
+ for inp in inputs:
370
+ try:
371
+ same = os.path.realpath(inp) == out_real
372
+ except OSError:
373
+ continue
374
+ if same:
375
+ die(f"refusing to run: output {output!r} is the same file as input {inp!r} "
376
+ f"(the result would replace the source) -- choose a different --output/-o path", kind="input")
377
+
378
+
356
379
  def _check_existing_output(cmd: Sequence[str]) -> None:
357
380
  """An output path that already exists is someone's file: a previous result, a source the
358
381
  agent mis-named, a deliverable from another run. ffmpeg's -y (which every command carries so
@@ -912,6 +935,15 @@ class MissingFpsError(ValueError):
912
935
  silently swallowing a mistyped/missing --fps as an auto-timed line of digits."""
913
936
 
914
937
 
938
+ def concat_list_line(path: str) -> str:
939
+ """One `file '...'` line for the concat demuxer. The demuxer reads backslash as an escape
940
+ inside the quoted form, so a Windows path (C:\\Users\\...\\part000.mp4) must be written
941
+ with forward slashes -- ffmpeg opens either spelling on Windows -- and a single quote in the
942
+ name is closed, escaped and reopened. Shared by cut.py (multi-segment) and sequence.py."""
943
+ escaped = str(path).replace("\\", "/").replace("'", "'\\''")
944
+ return f"file '{escaped}'"
945
+
946
+
915
947
  def parse_time(value: str, fps: Optional[float] = None) -> float:
916
948
  """Accept seconds ('12.5'), mm:ss ('1:30'), hh:mm:ss(.ms) ('00:01:30.250'), SRT '00:01:30,250',
917
949
  or -- when `fps` is given -- SMPTE non-drop-frame timecode 'hh:mm:ss:ff' ('00:01:30:15')."""
@@ -928,7 +960,12 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
928
960
  frame, whole_fps = int(f), int(round(fps))
929
961
  if not (0 <= frame < whole_fps):
930
962
  raise ValueError(f"bad SMPTE timecode '{value}': frame {frame} is out of range for {fps:g} fps (0-{whole_fps - 1})")
931
- return int(h) * 3600 + int(m) * 60 + int(s) + frame / fps
963
+ # Non-drop-frame: the timecode counts whole_fps frames per timecode-second, so the real
964
+ # time is the total frame count over the true rate (at 29.97 an hour of timecode is
965
+ # 3596.4 s of video). This is exactly what fmt_smpte_time() inverts; before, the two
966
+ # disagreed by ~0.1 % on the fractional NTSC rates and drifted apart over long files.
967
+ total_frames = (int(h) * 3600 + int(m) * 60 + int(s)) * whole_fps + frame
968
+ return total_frames / fps
932
969
  if len(parts) > 3:
933
970
  raise ValueError(f"bad time: {value}")
934
971
  total = 0.0
@@ -937,6 +974,19 @@ def parse_time(value: str, fps: Optional[float] = None) -> float:
937
974
  return total
938
975
 
939
976
 
977
+ def time_arg(value: str, flag: str, fps: Optional[float] = None) -> float:
978
+ """parse_time() for a command-line flag: SMPTE hh:mm:ss:ff resolves with the input's fps when
979
+ the caller has one, and every parse failure is a `kind: input` refusal naming the flag (so
980
+ `--json` callers get a failure document, never a traceback)."""
981
+ try:
982
+ return parse_time(value, fps)
983
+ except MissingFpsError as e:
984
+ die(f"{flag} {value!r}: {e}")
985
+ except ValueError as e:
986
+ die(f"{flag} {value!r}: {e} (use seconds, mm:ss, hh:mm:ss.ms or, with a known fps, hh:mm:ss:ff)")
987
+ return 0.0 # unreachable
988
+
989
+
940
990
  def fmt_srt_time(seconds: float) -> str:
941
991
  if seconds < 0:
942
992
  seconds = 0.0
@@ -1002,7 +1052,26 @@ def default_font_file(font_name: str) -> Optional[str]:
1002
1052
  """
1003
1053
  if platform.system() == "Windows":
1004
1054
  windir = os.environ.get("WINDIR", "C:\\Windows")
1005
- candidate = Path(windir) / "Fonts" / "arial.ttf"
1055
+ fonts = Path(windir) / "Fonts"
1056
+ # The requested family first: a file whose name starts with the family name with spaces
1057
+ # removed (Noto Sans CJK JP -> NotoSansCJKjp-Regular.otf, Meiryo -> meiryo.ttc), then the
1058
+ # common CJK system fonts when the request looks CJK (so Japanese text does not render as
1059
+ # boxes in Arial), and Arial only as the last resort.
1060
+ wanted = re.sub(r"[^a-z0-9]", "", (font_name or "").lower())
1061
+ try:
1062
+ files = sorted(fonts.iterdir()) if fonts.is_dir() else []
1063
+ except OSError:
1064
+ files = []
1065
+ if wanted:
1066
+ for f in files:
1067
+ stem = re.sub(r"[^a-z0-9]", "", f.stem.lower())
1068
+ if f.suffix.lower() in (".ttf", ".otf", ".ttc") and stem.startswith(wanted):
1069
+ return str(f)
1070
+ if any(k in wanted for k in ("cjk", "gothic", "mincho", "meiryo", "yugoth", "msgothic", "malgun", "simhei", "simsun", "jp", "kr", "sc", "tc")):
1071
+ for name in ("NotoSansCJKjp-Regular.otf", "NotoSansCJK-Regular.ttc", "meiryo.ttc", "YuGothM.ttc", "msgothic.ttc", "malgun.ttf", "msyh.ttc"):
1072
+ if (fonts / name).exists():
1073
+ return str(fonts / name)
1074
+ candidate = fonts / "arial.ttf"
1006
1075
  return str(candidate) if candidate.exists() else None
1007
1076
  exe = shutil.which("fc-match")
1008
1077
  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
- 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")
@@ -14,7 +14,7 @@ import argparse
14
14
  import math
15
15
  import sys
16
16
 
17
- from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
17
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS, time_arg
18
18
 
19
19
 
20
20
  def main() -> int:
@@ -34,7 +34,7 @@ def main() -> int:
34
34
  args = ap.parse_args()
35
35
  apply_common(args)
36
36
 
37
- target = parse_time(args.duration)
37
+ target = time_arg(args.duration, "--duration", args.fps)
38
38
  if target <= 0:
39
39
  die("--duration must be > 0")
40
40
  if args.fps <= 0:
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", ".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
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
@@ -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") or shutil.which("main")
121
- 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:
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
- m = WhisperModel(model, device="cpu", compute_type="int8")
143
- segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
144
- 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)
145
163
  if cues:
146
164
  info("transcribed with faster-whisper")
147
165
  write_srt(cues, out_srt)
@@ -182,7 +200,10 @@ def parse_srt(path: str) -> List[Tuple[float, float, str]]:
182
200
  if times:
183
201
  a, b = times.split("-->")
184
202
  text = "\n".join(block[block.index(times) + 1:]).strip()
185
- cues.append((parse_time(a), parse_time(b), text))
203
+ try:
204
+ cues.append((parse_time(a), parse_time(b), text))
205
+ except ValueError as e: # includes MissingFpsError: SRT timings are hh:mm:ss,ms, never frames
206
+ die(f"{path}: cannot read the timing line {times.strip()!r}: {e}")
186
207
  block = []
187
208
  if not cues:
188
209
  die(f"no cues found in {path}")
@@ -513,12 +534,16 @@ def main() -> int:
513
534
  w, h = meta["video"]["width"], meta["video"]["height"]
514
535
  if meta["video"].get("rotation") in (90, -90, 270, -270):
515
536
  w, h = h, w
516
- write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
537
+ if not STATE.dry_run: # the generated ASS is an artifact of this run: a plan writes nothing
538
+ write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
517
539
  info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
518
540
  args.ass = ass_path
541
+ generated_ass = True
542
+ else:
543
+ generated_ass = False
519
544
 
520
545
  if args.ass:
521
- if not os.path.exists(args.ass):
546
+ if not generated_ass and not os.path.exists(args.ass):
522
547
  die(f"ASS file not found: {args.ass}")
523
548
  vf = f"ass={escape_filter_path(args.ass)}"
524
549
  if args.fonts_dir:
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, refuse_output_is_input
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:
@@ -182,6 +192,7 @@ def main() -> int:
182
192
  segments = [(s, min(e, total) if total else e) for s, e in segments]
183
193
 
184
194
  output = args.output or default_output(args.input, "cut")
195
+ refuse_output_is_input(output, args.input)
185
196
  ext = os.path.splitext(output)[1] or ".mp4"
186
197
 
187
198
  reencoded = False
@@ -197,7 +208,7 @@ def main() -> int:
197
208
  listfile = os.path.join(tmp, "list.txt")
198
209
  with open(listfile, "w", encoding="utf-8") as fh:
199
210
  for p in parts:
200
- fh.write("file '" + p.replace("'", "'\\''") + "'\n")
211
+ fh.write(concat_list_line(p) + "\n")
201
212
  cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile, "-c", "copy", output]
202
213
  proc = run(cmd, check=False)
203
214
  if proc.returncode != 0:
package/scripts/fit.py CHANGED
@@ -38,7 +38,7 @@ import sys
38
38
  from fractions import Fraction
39
39
  from typing import List
40
40
 
41
- from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS
41
+ from _common import video_args, STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, pad_filters, add_pad_fill_args, X264_PRESETS, time_arg
42
42
  ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
43
43
 
44
44
 
@@ -155,7 +155,7 @@ def main() -> int:
155
155
 
156
156
  # ---- duration
157
157
  if args.duration:
158
- target = parse_time(args.duration)
158
+ target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
159
159
  if target <= 0:
160
160
  die("target duration must be > 0")
161
161
  if args.method == "speed":
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:
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Optional
23
23
 
24
- from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_font_file, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, run_keeping_subtitles, video_args, drawtext_boxborderw, X264_PRESETS, time_arg
25
25
 
26
26
  TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
27
 
@@ -86,8 +86,9 @@ def main() -> int:
86
86
  if meta["video"].get("rotation") in (90, -90, 270, -270):
87
87
  W, H = H, W
88
88
  dur = meta.get("duration") or 0.0
89
- s = parse_time(args.start) if args.start else 0.0
90
- e = parse_time(args.end) if args.end else dur
89
+ fps = meta["video"].get("fps")
90
+ s = time_arg(args.start, "--start", fps) if args.start else 0.0
91
+ e = time_arg(args.end, "--end", fps) if args.end else dur
91
92
  if e <= s:
92
93
  die("--end must be after --start")
93
94
  en = f"enable='between(t,{s:.3f},{e:.3f})'"
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/insert.py CHANGED
@@ -27,7 +27,7 @@ import argparse
27
27
  import math
28
28
  import sys
29
29
 
30
- from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
30
+ from _common import add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
31
31
 
32
32
 
33
33
  def even(n: float) -> int:
@@ -52,7 +52,7 @@ def main() -> int:
52
52
  args = ap.parse_args()
53
53
  apply_common(args)
54
54
 
55
- target = parse_time(args.duration)
55
+ target = time_arg(args.duration, "--duration", args.fps)
56
56
  if target <= 0:
57
57
  die("--duration must be > 0")
58
58
  if args.fps <= 0:
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
@@ -15,7 +15,7 @@ import sys
15
15
  from pathlib import Path
16
16
  from typing import List
17
17
 
18
- from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run
18
+ from _common import add_common, apply_common, default_font_file, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, time_arg
19
19
 
20
20
  FONT = "fontcolor=white:fontsize=h/18:box=1:boxcolor=black@0.55:boxborderw=6:x=8:y=8"
21
21
 
@@ -69,7 +69,7 @@ def main() -> int:
69
69
  die("--compare needs --at TIME")
70
70
  probe(args.compare)
71
71
  for t in args.at:
72
- sec = parse_time(t)
72
+ sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
73
73
  out = args.output or os.path.join(outdir, f"{stem}_vs_{Path(args.compare).stem}_{sec:.3f}s.png")
74
74
  half = args.width // 2
75
75
  stamp = "" if args.no_timecode else f",drawtext=text='{escape_drawtext(fmt_hms(sec))}':{font_prefix}{FONT}"
@@ -82,10 +82,14 @@ def main() -> int:
82
82
  outputs.append(out)
83
83
  elif args.at:
84
84
  for t in args.at:
85
- sec = parse_time(t)
85
+ sec = time_arg(t, "--at", meta["video"].get("fps") if meta.get("video") else None)
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)
package/scripts/loop.py CHANGED
@@ -18,7 +18,7 @@ import argparse
18
18
  import math
19
19
  import sys
20
20
 
21
- from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS
21
+ from _common import add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args, X264_PRESETS, time_arg
22
22
 
23
23
 
24
24
  def main() -> int:
@@ -49,7 +49,7 @@ def main() -> int:
49
49
  target = None
50
50
  stream_loop = args.times - 1
51
51
  else:
52
- target = parse_time(args.duration)
52
+ target = time_arg(args.duration, "--duration", meta["video"].get("fps") if meta.get("video") else None)
53
53
  if target <= src_dur:
54
54
  die(f"--duration ({target:g}s) must be longer than the source ({src_dur:.3f}s) -- use cut.py to trim instead")
55
55
  stream_loop = math.ceil(target / src_dur) - 1
@@ -48,7 +48,7 @@ def parse_chapters(path: str, duration: float) -> List[Dict[str, Any]]:
48
48
  parts = line.split(None, 1)
49
49
  try:
50
50
  start = parse_time(parts[0])
51
- except ValueError:
51
+ except ValueError: # MissingFpsError is a ValueError: chapter files carry no fps
52
52
  die(f"{path}:{n}: cannot read the time in {line!r} (use seconds, mm:ss or hh:mm:ss.ms)")
53
53
  title = parts[1].strip() if len(parts) > 1 else f"Chapter {len(entries) + 1}"
54
54
  if entries and start <= entries[-1]["start"]:
@@ -24,7 +24,7 @@ import argparse
24
24
  import sys
25
25
  from typing import List, Optional
26
26
 
27
- from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS
27
+ from _common import STATE, load_brand, video_args, add_common, apply_common, default_font_file, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, run_keeping_subtitles, validate_color, x264_args, X264_PRESETS, time_arg
28
28
 
29
29
  POS = {
30
30
  "top-left": ("{m}", "{m}"),
@@ -155,8 +155,9 @@ def main() -> int:
155
155
  if args.audio_stream and not audio_streams:
156
156
  die("--audio-stream needs an input with audio streams")
157
157
  vw = meta["video"]["width"]
158
- start = parse_time(args.start) if args.start else None
159
- end = parse_time(args.end) if args.end else None
158
+ fps = meta["video"].get("fps")
159
+ start = time_arg(args.start, "--start", fps) if args.start else None
160
+ end = time_arg(args.end, "--end", fps) if args.end else None
160
161
  if start is not None and end is not None and end <= start:
161
162
  die("--end must be after --start")
162
163
  if not 0 <= args.opacity <= 1:
package/scripts/pad.py CHANGED
@@ -16,15 +16,15 @@ Examples:
16
16
  import argparse
17
17
  import sys
18
18
 
19
- from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS
19
+ from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run_keeping_subtitles, validate_color, video_args, X264_PRESETS, time_arg
20
20
 
21
21
 
22
22
  def main() -> int:
23
23
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
24
24
  ap.add_argument("input")
25
25
  ap.add_argument("-o", "--output", help="output file (default: <name>_pad.<ext>)")
26
- ap.add_argument("--start", type=float, default=0.0, help="seconds of padding to add before the clip (default 0)")
27
- ap.add_argument("--end", type=float, default=0.0, help="seconds of padding to add after the clip (default 0)")
26
+ ap.add_argument("--start", default="0", help="padding to add before the clip: seconds or mm:ss (default 0)")
27
+ ap.add_argument("--end", default="0", help="padding to add after the clip: seconds or mm:ss (default 0)")
28
28
  ap.add_argument("--color", default="black", help="padding colour (default black)")
29
29
  ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
30
30
  ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
@@ -32,6 +32,8 @@ def main() -> int:
32
32
  args = ap.parse_args()
33
33
  apply_common(args)
34
34
 
35
+ args.start = time_arg(args.start, "--start")
36
+ args.end = time_arg(args.end, "--end")
35
37
  if args.start < 0 or args.end < 0:
36
38
  die(f"--start/--end must be >= 0, got start={args.start:g} end={args.end:g}")
37
39
  if args.start == 0 and args.end == 0:
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
@@ -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)
package/scripts/scenes.py CHANGED
@@ -24,7 +24,7 @@ import re
24
24
  import sys
25
25
  from typing import Dict, List, Tuple
26
26
 
27
- from _common import add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
27
+ from _common import STATE, add_common, apply_common, default_font_file, die, emit, escape_filter_path, ffmpeg_base, info, print_json, probe, require_tool, run, run_analysis, decode_pcm_mono, rms_envelope
28
28
 
29
29
  SCORE_RE = re.compile(r"frame:(\d+)\s+pts:\d+\s+pts_time:([0-9.]+)")
30
30
 
@@ -168,9 +168,10 @@ def main() -> int:
168
168
  result["highlights_rank_by"] = args.rank_by
169
169
  info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
170
170
  if args.edl:
171
- with open(args.edl, "w", encoding="utf-8") as fh:
172
- for s, e in picks:
173
- fh.write(f"{s:.2f}-{e:.2f}\n")
171
+ if not STATE.dry_run: # the contract says --edl is not written under --dry-run
172
+ with open(args.edl, "w", encoding="utf-8") as fh:
173
+ for s, e in picks:
174
+ fh.write(f"{s:.2f}-{e:.2f}\n")
174
175
  info(f"wrote {args.edl}")
175
176
 
176
177
  if args.sheet:
@@ -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:
@@ -104,9 +104,10 @@ def main() -> int:
104
104
  info("hint: " + summary["hint"])
105
105
 
106
106
  if args.edl:
107
- with open(args.edl, "w", encoding="utf-8") as fh:
108
- for s, e in keeps:
109
- fh.write(f"{s:.3f}-{e:.3f}\n")
107
+ if not STATE.dry_run: # the EDL is an artifact like the cut itself: a plan writes nothing
108
+ with open(args.edl, "w", encoding="utf-8") as fh:
109
+ for s, e in keeps:
110
+ fh.write(f"{s:.3f}-{e:.3f}\n")
110
111
  info(f"wrote {args.edl}")
111
112
 
112
113
  if args.list: