ffmpeg-skill 1.4.4 → 1.4.6
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 +5 -3
- package/SKILL.md +18 -27
- package/mcp/server.py +16 -2
- package/package.json +4 -2
- package/references/ci-platform-pitfalls.md +15 -0
- package/scripts/_common.py +130 -20
- package/scripts/audio.py +3 -3
- package/scripts/background.py +2 -2
- package/scripts/batch.py +5 -6
- package/scripts/broll.py +5 -4
- package/scripts/caption.py +2 -2
- package/scripts/color.py +2 -2
- package/scripts/crop.py +2 -2
- package/scripts/cut.py +18 -6
- package/scripts/deinterlace.py +2 -2
- package/scripts/denoise.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +4 -4
- package/scripts/freeze.py +2 -2
- package/scripts/graphics.py +2 -2
- package/scripts/grid.py +2 -2
- package/scripts/insert.py +2 -2
- package/scripts/join.py +5 -5
- package/scripts/loop.py +2 -2
- package/scripts/loudness.py +1 -1
- package/scripts/metadata.py +4 -4
- package/scripts/multicam.py +2 -2
- package/scripts/overlay.py +2 -2
- package/scripts/pad.py +2 -2
- package/scripts/redact.py +2 -2
- package/scripts/render.py +21 -14
- package/scripts/report.py +14 -9
- package/scripts/reverse.py +2 -2
- package/scripts/scenes.py +4 -15
- package/scripts/sequence.py +2 -2
- package/scripts/silence.py +15 -2
- package/scripts/speedramp.py +2 -2
- package/scripts/sphere.py +2 -2
- package/scripts/stabilize.py +3 -3
- package/scripts/straighten.py +2 -2
- package/scripts/sync.py +4 -19
- package/scripts/waveform.py +2 -2
- package/references/process-pitfalls.md +0 -229
package/README.md
CHANGED
|
@@ -144,7 +144,7 @@ These are the rules the skill file gives the agent and the code enforces. Togeth
|
|
|
144
144
|
1. **Probe first.** No tool decides from the file name. `probe.py` measures duration, fps (with variable-frame-rate detection), resolution, rotation, bit depth, HDR format including Dolby Vision, colour tags and every audio stream before anything is cut.
|
|
145
145
|
2. **Lossless when possible.** `cut.py`, `join.py` and `loudness.py` stream-copy what they do not need to touch. Re-encoding happens only when it must: frame-accurate cuts, filters, format changes, or a keyframe farther than the tolerance.
|
|
146
146
|
3. **Plan before render.** Every tool takes `--dry-run` (print the ffmpeg command lines, write nothing), `--json` (structured result with a probe of the output), `--fast` (preview quality), `--progress` (percent and ETA), `--timeout` (a hung ffmpeg is killed and reported, never waited on forever) and `--overwrite` (explicit consent before an existing output is replaced). A test runs every tool under `--dry-run` behind a fake ffmpeg and asserts that no ffmpeg call happened and no file appeared.
|
|
147
|
-
4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all
|
|
147
|
+
4. **Machine-readable contract.** `contract --json` describes all 42 tools: input schema generated from the parser, output schema, role, required and conditional FFmpeg capabilities, dry-run support, the verification tools to run afterwards, whether a visual check is required, `mutates_input: false`. `provides` lists all 42 by a cross-repository Capability id (`ffmpeg-skill.cut`, `ffmpeg-skill.loudness`, ...) for [`kajisho5/AI-video-production-OS`](https://github.com/kajisho5/AI-video-production-OS)'s `CapabilityContract.provides` — see `docs/contract.md`.
|
|
148
148
|
5. **Contract-derived MCP.** `mcp/server.py` builds its `tools/list` from the contract. Tool names, order and `inputSchema` cannot drift from the scripts; a test keeps the two byte-identical.
|
|
149
149
|
6. **Capability detection.** `doctor` reads `ffmpeg -encoders / -filters / -bsfs` and reports which of the components the tools need are present on this build (libx264, libass, zscale, loudnorm, xfade, …), before a job fails inside ffmpeg.
|
|
150
150
|
7. **Unknown is not missing.** When a listing cannot be read (a layout the parser does not know, ffmpeg exiting non-zero) the affected capabilities are `unknown`: never `missing`, never silently `available`. An installed filter is not reported absent; a failed detection is not a pass.
|
|
@@ -303,7 +303,7 @@ The contract is generated from the code that runs, not maintained beside it. For
|
|
|
303
303
|
|
|
304
304
|
On Windows, `python3` is only on PATH if Python was installed from the Microsoft Store; a python.org install exposes `python` (or the `py` launcher) instead — if your MCP client reports the server failed to start, change `"command"` above to `"python"` (or the full path from `where python`).
|
|
305
305
|
|
|
306
|
-
`mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up: the same
|
|
306
|
+
`mcp/server.py` is a stdio JSON-RPC transport with no tool table of its own. `tools/list` is derived from the contract at start-up: the same 42 names, the same order, and `inputSchema` translated from each tool's `input_schema`. `tools/call` maps structured arguments to argv and runs the named script; a raw `argv` form is accepted for compatibility and marked non-canonical. `python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
|
|
307
307
|
|
|
308
308
|
### Capability detection
|
|
309
309
|
|
|
@@ -395,7 +395,7 @@ FFmpeg itself:
|
|
|
395
395
|
- Python 3.9+, standard library only
|
|
396
396
|
- Node 16+ only for the `npx` installer
|
|
397
397
|
|
|
398
|
-
`doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...)
|
|
398
|
+
`doctor`'s own introspection calls (`ffmpeg -filters`/`-encoders`/`-bsfs`/`-version`) time out after 10s and report `failed` rather than hanging forever — those are meant to be fast. Every tool's actual media-processing `ffmpeg` invocation (cut, fit, caption, ...) runs under `--timeout` (default 1800 s, `FFMPEG_SKILL_TIMEOUT`, `0` = none): past the limit the process is killed, its partial output removed, and the failure reported as `kind: timeout` (exit 124) — a legitimately long `--accurate` re-encode should raise the limit rather than run unbounded. `-nostdin` is always passed, so a hung ffmpeg process waiting on stdin cannot happen.
|
|
399
399
|
|
|
400
400
|
## Stability
|
|
401
401
|
|
|
@@ -424,6 +424,8 @@ Contributing a change: see [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
424
424
|
| | |
|
|
425
425
|
|---|---|
|
|
426
426
|
| [CONTRIBUTING.md](CONTRIBUTING.md) | scope, dev setup, tests, PR expectations |
|
|
427
|
+
| [docs/design-decisions.md](docs/design-decisions.md) | behaviours that look like bugs but are decisions, with rationale and the pinning test; read before filing a bug |
|
|
428
|
+
| [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) | Contributor Covenant 2.1; reports go through the SECURITY.md channel |
|
|
427
429
|
| [SECURITY.md](SECURITY.md) | how to report a vulnerability privately |
|
|
428
430
|
| [SKILL.md](SKILL.md) | what the agent reads: workflow, request → tool map, audio-only rules, report format, pitfalls |
|
|
429
431
|
| [references/scripts.md](references/scripts.md) | per-flag reference for every tool |
|
package/SKILL.md
CHANGED
|
@@ -28,20 +28,16 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
28
28
|
`cut.py` and `loudness.py` stream-copy video by default; only pass
|
|
29
29
|
`--accurate` to `cut.py` when the user needs frame-exact cuts.
|
|
30
30
|
3. **Plan with `--dry-run --json`, then execute.** Every script accepts
|
|
31
|
-
`--dry-run` (prints the ffmpeg commands that would run
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
be a placeholder, not a computed preview — see `docs/contract.md`). Use
|
|
42
|
-
them to confirm a plan before long encodes and to report exact facts.
|
|
43
|
-
`--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
|
|
44
|
-
prints percent and ETA on stderr for long encodes. Never point `-o` at a file
|
|
31
|
+
`--dry-run` (prints the ffmpeg commands that would run; writing tools write
|
|
32
|
+
nothing, analysis tools still measure — the per-tool list is in the
|
|
33
|
+
opening paragraph above and in `contract --json`'s `dry_run` field) and
|
|
34
|
+
`--json` (structured result: output path, probe of the output, commands
|
|
35
|
+
run). Trust `--json`, not a dry-run's human-readable summary line, for any
|
|
36
|
+
number after the plan (dimensions in that line can be a placeholder, not a
|
|
37
|
+
computed preview — see `docs/contract.md`). Use them to confirm a plan
|
|
38
|
+
before long encodes and to report exact facts. `--fast` gives a quick
|
|
39
|
+
preview-quality render (x264 veryfast), `--progress` prints percent and
|
|
40
|
+
ETA on stderr for long encodes. Never point `-o` at a file
|
|
45
41
|
you did not create in this job unless the user asked for it to be replaced;
|
|
46
42
|
pass `--overwrite` only then.
|
|
47
43
|
4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
|
|
@@ -95,7 +91,7 @@ Scripts live in `scripts/` next to this file; run them with `python3 <skill-dir>
|
|
|
95
91
|
|
|
96
92
|
## Before you run anything: what to ask, what to assume
|
|
97
93
|
|
|
98
|
-
Ask one short question only when the answer changes the output materially and the request does not imply it:
|
|
94
|
+
Ask one short question only when the answer changes the output materially and the request does not imply it. When several things are open at once (a vague "make it for social media" leaves destination, aspect method, length and captions unresolved), do not ask them one per turn: propose one bundle with your defaults and let the user change any part ("Reels: 9:16 with padding, trimmed to 60 s, -14 LUFS, no captions — OK, or change something?"). One question, one answer, then the run.
|
|
99
95
|
|
|
100
96
|
- **Destination** decides aspect, length limit, loudness and codec. "For Reels" answers all four. If no destination is named and the edit is a plain cut/caption, keep the source format and say so; if the user asks to "export", "post" or "deliver", ask where.
|
|
101
97
|
- **Duration** ("make it 60 s") without a method: speed up for ≤1.5× changes, trim otherwise, and state which you chose. Ask if the content is a talk (trimming loses words) and the change is large.
|
|
@@ -262,9 +258,13 @@ When a step fails, replace `Done:` with `Failed:` and keep the rest honest:
|
|
|
262
258
|
```
|
|
263
259
|
Failed: color.py --lut grade.cube exited 1 — ffmpeg: "Unable to parse LUT file" (the .cube is not a valid LUT)
|
|
264
260
|
Steps: probe -> color (failed); nothing written
|
|
261
|
+
Check: nothing to verify
|
|
262
|
+
Look: not needed (nothing written)
|
|
265
263
|
Notes: send a valid .cube, or say if you want the clip left as is
|
|
266
264
|
```
|
|
267
265
|
|
|
266
|
+
A refusal (the request asks for a judgement this skill does not make, or for something outside its scope) uses the same shape: `Failed:` names what was refused and why, `Steps:` lists what did run (usually only probe), `Look: not needed`. Both keep the five labels so a reader can scan a failed report the way they scan a successful one. When a tool's failure JSON carries `error.hint`, quote it in `Notes:` — it is the flag change that would make the retry meaningful.
|
|
267
|
+
|
|
268
268
|
Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | output | missing_tool | timeout | verification, "message": ...}}` with `--json` and exits non-zero; quote the message, do not paraphrase it into a success.
|
|
269
269
|
|
|
270
270
|
## Things that look right but are wrong
|
|
@@ -316,18 +316,9 @@ Every script prints `{"status": "failed", "error": {"kind": input | ffmpeg | out
|
|
|
316
316
|
`caption.py --fonts-dir ./fonts --font "Noto Sans CJK JP"`). Without a
|
|
317
317
|
matching font you get boxes, not an error. Install: `apt install fonts-noto-cjk`,
|
|
318
318
|
`brew install --cask font-noto-sans-cjk`.
|
|
319
|
-
- **Windows drawtext crashes on some real builds
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `overlay.py --text`
|
|
323
|
-
and `graphics.py` all resolve a concrete `--font-file` by default when one is
|
|
324
|
-
available (`fontfile=` skips fontconfig entirely and is the form confirmed
|
|
325
|
-
not to crash), so this should already be handled automatically. If a
|
|
326
|
-
drawtext tool still crashes, pass `--font-file` explicitly rather than
|
|
327
|
-
relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
|
|
328
|
-
drawtext probe and reports `filter:drawtext` missing (with the crash detail
|
|
329
|
-
in `errors[]`) rather than a false "available" from the `-filters` listing
|
|
330
|
-
alone.
|
|
319
|
+
- **Windows drawtext crashes on some real builds** (#100): the drawtext tools
|
|
320
|
+
resolve a concrete `--font-file` by default, which avoids it; if one still
|
|
321
|
+
crashes, pass `--font-file` explicitly. Details: `references/ci-platform-pitfalls.md`.
|
|
331
322
|
- **Keyframe cuts.** A lossless `cut.py` result may start up to one GOP (often
|
|
332
323
|
1–10 s) earlier than requested; the script re-encodes automatically when the
|
|
333
324
|
deviation exceeds 0.5 s. If the user insists on lossless output, pass
|
package/mcp/server.py
CHANGED
|
@@ -26,6 +26,7 @@ PROTOCOL_VERSION = "2024-11-05"
|
|
|
26
26
|
|
|
27
27
|
sys.path.insert(0, str(SCRIPTS))
|
|
28
28
|
import _contract # noqa: E402 (the contract is the only source of tool names, schemas and argument mapping)
|
|
29
|
+
import _common # noqa: E402 (run_tool: the outer wall-clock ceiling on a dispatched tool)
|
|
29
30
|
|
|
30
31
|
_SPECS: Dict[str, Dict[str, Any]] = {}
|
|
31
32
|
|
|
@@ -91,7 +92,14 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
91
92
|
if name not in specs() or not script.exists():
|
|
92
93
|
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
93
94
|
argv = build_argv(name, args or {})
|
|
94
|
-
|
|
95
|
+
# The child enforces --timeout on each ffmpeg call; this outer ceiling (4x that plus 60 s)
|
|
96
|
+
# is the only thing that ends a child hung for any other reason. The caller's own `timeout`
|
|
97
|
+
# argument sets both; otherwise FFMPEG_SKILL_TIMEOUT / the 1800 s default.
|
|
98
|
+
try:
|
|
99
|
+
per_call = float((args or {}).get("timeout", _common._env_timeout()))
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
per_call = _common._env_timeout()
|
|
102
|
+
proc = _common.run_tool([str(script)] + argv, per_call=per_call)
|
|
95
103
|
stdout = proc.stdout.strip()
|
|
96
104
|
text = stdout
|
|
97
105
|
structured = None
|
|
@@ -102,7 +110,13 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
102
110
|
if proc.returncode != 0:
|
|
103
111
|
err = proc.stderr.strip().splitlines()
|
|
104
112
|
tail = "\n".join(err[-12:])
|
|
105
|
-
|
|
113
|
+
failed: Dict[str, Any] = {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
|
|
114
|
+
# The child's own failure document (status, error.kind/code/hint, commands) is the
|
|
115
|
+
# machine-readable half of the contract; dropping it here left an MCP caller regex-
|
|
116
|
+
# parsing prose to tell a timeout from a missing binary.
|
|
117
|
+
if isinstance(structured, dict):
|
|
118
|
+
failed["structuredContent"] = structured
|
|
119
|
+
return failed
|
|
106
120
|
if structured is None:
|
|
107
121
|
text = stdout or "\n".join(proc.stderr.strip().splitlines()[-5:])
|
|
108
122
|
result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.6",
|
|
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",
|
|
@@ -30,7 +30,9 @@
|
|
|
30
30
|
"bin/",
|
|
31
31
|
"scripts/",
|
|
32
32
|
"mcp/",
|
|
33
|
-
"references/",
|
|
33
|
+
"references/scripts.md",
|
|
34
|
+
"references/devices.md",
|
|
35
|
+
"references/ci-platform-pitfalls.md",
|
|
34
36
|
"SKILL.md",
|
|
35
37
|
"README.md",
|
|
36
38
|
"LICENSE"
|
|
@@ -165,3 +165,18 @@ these had ever shown up before.
|
|
|
165
165
|
the RGB stages (exposure/colortemperature/colorbalance) make swscale go yuv→rgb with the
|
|
166
166
|
frame's bt709 matrix and back with its bt601 default. The identity test only ever used an
|
|
167
167
|
untagged source, where both legs pick bt601 and cancel out. Tracked separately.
|
|
168
|
+
|
|
169
|
+
### Windows: drawtext crashes when it resolves a font by family name (#100)
|
|
170
|
+
|
|
171
|
+
On certain Windows ffmpeg
|
|
172
|
+
builds (e.g. winget's gyan.dev), `drawtext` crashes with an access violation
|
|
173
|
+
whenever it resolves a font by family name through fontconfig, even with a
|
|
174
|
+
valid `fonts.conf` (#100). `look.py`, `scenes.py --sheet`, `overlay.py --text`
|
|
175
|
+
and `graphics.py` all resolve a concrete `--font-file` by default when one is
|
|
176
|
+
available (`fontfile=` skips fontconfig entirely and is the form confirmed
|
|
177
|
+
not to crash), so this should already be handled automatically. If a
|
|
178
|
+
drawtext tool still crashes, pass `--font-file` explicitly rather than
|
|
179
|
+
relying on `--font`/`font=` resolution; `doctor` also runs a real one-frame
|
|
180
|
+
drawtext probe and reports `filter:drawtext` missing (with the crash detail
|
|
181
|
+
in `errors[]`) rather than a false "available" from the `-filters` listing
|
|
182
|
+
alone.
|
package/scripts/_common.py
CHANGED
|
@@ -159,7 +159,8 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
|
|
|
159
159
|
reporting that detail while the top-level status says failed. Before 1.4.3 those four printed
|
|
160
160
|
`status: "completed"` next to a non-zero exit code, so a caller keying on the status alone
|
|
161
161
|
read a failed delivery as a success."""
|
|
162
|
-
|
|
162
|
+
hint = extra.pop("hint", None)
|
|
163
|
+
sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
|
|
163
164
|
if STATE.json:
|
|
164
165
|
doc: Dict[str, Any] = {
|
|
165
166
|
"status": "failed", "exit_code": code,
|
|
@@ -170,6 +171,8 @@ def die(msg: str, code: int = 1, kind: str = "input", **extra: Any) -> "None":
|
|
|
170
171
|
},
|
|
171
172
|
"commands": list(STATE.commands),
|
|
172
173
|
}
|
|
174
|
+
if hint:
|
|
175
|
+
doc["error"]["hint"] = hint
|
|
173
176
|
doc.update(extra)
|
|
174
177
|
print_json(doc)
|
|
175
178
|
sys.exit(code)
|
|
@@ -204,12 +207,11 @@ class Context:
|
|
|
204
207
|
"""Per-process settings that the shared flags (--dry-run, --json, --progress, --fast) set once.
|
|
205
208
|
|
|
206
209
|
Scripts read it as attributes (``STATE.dry_run``) or, for older call sites, like a dict
|
|
207
|
-
(``STATE
|
|
210
|
+
(``STATE.dry_run``). Keeping it a single explicit object rather than module globals makes
|
|
208
211
|
it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
209
212
|
"""
|
|
210
213
|
|
|
211
214
|
__slots__ = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
212
|
-
_KEYS = ("dry_run", "json", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting")
|
|
213
215
|
|
|
214
216
|
def __init__(self) -> None:
|
|
215
217
|
self.reset()
|
|
@@ -226,19 +228,6 @@ class Context:
|
|
|
226
228
|
self.written: set = set() # output paths this process has written itself
|
|
227
229
|
self.preexisting: dict = {} # output path -> (size, mtime_ns) of a file that was there before we ran
|
|
228
230
|
|
|
229
|
-
# mapping-style access kept for backwards compatibility
|
|
230
|
-
def __getitem__(self, key: str) -> Any:
|
|
231
|
-
if key not in self._KEYS:
|
|
232
|
-
raise KeyError(key)
|
|
233
|
-
return getattr(self, key)
|
|
234
|
-
|
|
235
|
-
def __setitem__(self, key: str, value: Any) -> None:
|
|
236
|
-
if key not in self._KEYS:
|
|
237
|
-
raise KeyError(key)
|
|
238
|
-
setattr(self, key, value)
|
|
239
|
-
|
|
240
|
-
def get(self, key: str, default: Any = None) -> Any:
|
|
241
|
-
return getattr(self, key, default) if key in self._KEYS else default
|
|
242
231
|
|
|
243
232
|
|
|
244
233
|
STATE = Context()
|
|
@@ -268,6 +257,9 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
268
257
|
STATE.timeout = max(0.0, float(args.timeout))
|
|
269
258
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
270
259
|
args.preset = "veryfast"
|
|
260
|
+
crf = getattr(args, "crf", None)
|
|
261
|
+
if crf is not None and not 0 <= int(crf) <= 51:
|
|
262
|
+
die(f"--crf must be between 0 and 51 (x264/x265 scale; 18 is visually lossless, 23 the encoder default), got {crf}")
|
|
271
263
|
|
|
272
264
|
|
|
273
265
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
@@ -487,6 +479,75 @@ def run_analysis(cmd: Sequence[str], *, check: bool = True, text: bool = True) -
|
|
|
487
479
|
return proc
|
|
488
480
|
|
|
489
481
|
|
|
482
|
+
def child_limit(per_call: Optional[float] = None) -> Optional[float]:
|
|
483
|
+
"""Wall-clock ceiling for running one sibling script as a subprocess (render/batch/report
|
|
484
|
+
stages, the MCP server's dispatch). A tool runs a handful of ffmpeg/ffprobe calls, each
|
|
485
|
+
under its own --timeout, so the outer ceiling is a multiple of that plus a margin: it never
|
|
486
|
+
fires first on a healthy run, and it is the only thing that ends a child hung for a reason
|
|
487
|
+
that is not ffmpeg (a stuck import, a wedged pipe). None when the per-call limit is 0."""
|
|
488
|
+
limit = STATE.timeout if per_call is None else per_call
|
|
489
|
+
return (limit * 4 + 60) if limit else None
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def run_tool(argv: Sequence[str], *, per_call: Optional[float] = None) -> subprocess.CompletedProcess:
|
|
493
|
+
"""Run a sibling script (`argv[0]` is the script path) under child_limit(). On overrun the
|
|
494
|
+
child is killed and a CompletedProcess is returned whose stdout is this skill's own failure
|
|
495
|
+
document (kind timeout, exit 124), so callers that parse the child's --json see a timeout
|
|
496
|
+
exactly as they would from the child itself."""
|
|
497
|
+
limit = child_limit(per_call)
|
|
498
|
+
try:
|
|
499
|
+
return subprocess.run([sys.executable] + list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=limit)
|
|
500
|
+
except subprocess.TimeoutExpired as e:
|
|
501
|
+
name = os.path.basename(str(argv[0]))
|
|
502
|
+
msg = f"{name} did not finish within {limit:.0f} s (4x the per-ffmpeg --timeout plus 60 s) and was killed"
|
|
503
|
+
doc = {"status": "failed", "exit_code": 124,
|
|
504
|
+
"error": {"kind": "timeout", "message": msg, "code": ERROR_CODE["timeout"], "retryable": ERROR_RETRYABLE},
|
|
505
|
+
"commands": []}
|
|
506
|
+
partial = e.stderr.decode(errors="replace") if isinstance(e.stderr, bytes) else (e.stderr or "")
|
|
507
|
+
return subprocess.CompletedProcess(list(argv), 124, json.dumps(doc), partial + f"\nerror: {msg}\n")
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def decode_pcm_mono(path: str, sample_rate: int, seconds: Optional[float] = None, start: float = 0.0,
|
|
511
|
+
*, check: bool = True) -> List[float]:
|
|
512
|
+
"""Decode (part of) a file's audio to mono float samples in [-1, 1) at `sample_rate` via a
|
|
513
|
+
single ffmpeg pass under --timeout. Shared by scenes.py (audio envelope for cut scoring) and
|
|
514
|
+
sync.py (cross-correlation); an undecodable input is kind ffmpeg when check=True, else []."""
|
|
515
|
+
ffmpeg = require_tool("ffmpeg")
|
|
516
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin"]
|
|
517
|
+
if start:
|
|
518
|
+
cmd += ["-ss", f"{start:.3f}"]
|
|
519
|
+
cmd += ["-i", path]
|
|
520
|
+
if seconds is not None:
|
|
521
|
+
cmd += ["-t", f"{seconds:.3f}"]
|
|
522
|
+
cmd += ["-vn", "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"]
|
|
523
|
+
proc = run_analysis(cmd, check=False, text=False)
|
|
524
|
+
if proc.returncode != 0 or not proc.stdout:
|
|
525
|
+
if check:
|
|
526
|
+
die(f"could not decode audio from {path}:\n{proc.stderr.decode(errors='replace').strip()}", kind="ffmpeg")
|
|
527
|
+
return []
|
|
528
|
+
n = len(proc.stdout) // 2
|
|
529
|
+
import struct
|
|
530
|
+
return [v / 32768.0 for v in struct.unpack(f"<{n}h", proc.stdout[: n * 2])]
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
def rms_envelope(samples: Sequence[float], step: int, *, full_blocks_only: bool = False, remove_mean: bool = False) -> List[float]:
|
|
534
|
+
"""RMS per block of `step` samples. full_blocks_only drops a short tail block (sync.py: every
|
|
535
|
+
block must be the same length for the correlation); remove_mean subtracts the envelope's mean
|
|
536
|
+
(sync.py: so silence does not correlate). scenes.py keeps the tail and the absolute level."""
|
|
537
|
+
import math
|
|
538
|
+
step = max(1, int(step))
|
|
539
|
+
n = len(samples)
|
|
540
|
+
stop = n - step + 1 if full_blocks_only else n
|
|
541
|
+
env: List[float] = []
|
|
542
|
+
for i in range(0, max(0, stop), step):
|
|
543
|
+
block = samples[i:i + step]
|
|
544
|
+
env.append(math.sqrt(sum(x * x for x in block) / len(block)))
|
|
545
|
+
if remove_mean and env:
|
|
546
|
+
mean = sum(env) / len(env)
|
|
547
|
+
env = [e - mean for e in env]
|
|
548
|
+
return env
|
|
549
|
+
|
|
550
|
+
|
|
490
551
|
def child_args() -> List[str]:
|
|
491
552
|
"""The shared flags a tool that runs sibling scripts (render.py, batch.py) forwards to them,
|
|
492
553
|
so one `--timeout`/`--overwrite`/`--fast`/`--dry-run` on the outer command governs every
|
|
@@ -683,9 +744,9 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
683
744
|
role="output" marks a file this tool just wrote: a read failure is then reported as an
|
|
684
745
|
output-verification failure (kind "output") instead of an input problem."""
|
|
685
746
|
if not os.path.exists(path):
|
|
686
|
-
if role == "output" and not STATE
|
|
747
|
+
if role == "output" and not STATE.dry_run:
|
|
687
748
|
_output_failed(path, "not written")
|
|
688
|
-
if STATE
|
|
749
|
+
if STATE.dry_run:
|
|
689
750
|
# width/height/fps are honestly 0/0/0.0 -- "not measured", matching duration/size_bytes
|
|
690
751
|
# below -- because this is a dry run: the file doesn't exist yet, so there is nothing to
|
|
691
752
|
# probe. Earlier this stub used plausible-looking placeholders (1920x1080x30.0) instead,
|
|
@@ -722,8 +783,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
|
|
|
722
783
|
duration = _to_float(video.get("duration"))
|
|
723
784
|
if duration is None and audio:
|
|
724
785
|
duration = _to_float(audio.get("duration"))
|
|
725
|
-
if duration and STATE.
|
|
726
|
-
STATE
|
|
786
|
+
if duration and STATE.duration_hint is None:
|
|
787
|
+
STATE.duration_hint = duration
|
|
727
788
|
|
|
728
789
|
out: Dict[str, Any] = {
|
|
729
790
|
"file": path,
|
|
@@ -1078,6 +1139,55 @@ def db_to_linear(db: float) -> float:
|
|
|
1078
1139
|
return 10 ** (db / 20.0)
|
|
1079
1140
|
|
|
1080
1141
|
|
|
1142
|
+
def read_text_or_die(path: str, flag: str) -> str:
|
|
1143
|
+
"""Read a caller-supplied UTF-8 text file (a cue list, chapters, notes) or fail as kind input
|
|
1144
|
+
with the flag named, instead of a FileNotFoundError / UnicodeDecodeError traceback."""
|
|
1145
|
+
try:
|
|
1146
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
1147
|
+
return fh.read()
|
|
1148
|
+
except FileNotFoundError:
|
|
1149
|
+
die(f"{flag}: {path} does not exist")
|
|
1150
|
+
except IsADirectoryError:
|
|
1151
|
+
die(f"{flag}: {path} is a directory, not a text file")
|
|
1152
|
+
except UnicodeDecodeError as e:
|
|
1153
|
+
die(f"{flag}: {path} is not UTF-8 text ({e.reason} at byte {e.start}); save it as UTF-8")
|
|
1154
|
+
except OSError as e:
|
|
1155
|
+
die(f"{flag}: cannot read {path}: {e.strerror}")
|
|
1156
|
+
return "" # unreachable
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def keyframes_near(path: str, t: float, window: float = 5.0) -> List[float]:
|
|
1160
|
+
"""Video keyframe timestamps within +-window seconds of t, ascending. Read with
|
|
1161
|
+
-read_intervals so a long file is not scanned end to end; empty when ffprobe cannot say."""
|
|
1162
|
+
ffprobe = require_tool("ffprobe")
|
|
1163
|
+
lo = max(0.0, t - window)
|
|
1164
|
+
proc = run([ffprobe, "-v", "error", "-select_streams", "v:0", "-skip_frame", "nokey",
|
|
1165
|
+
"-read_intervals", f"{lo:.3f}%{t + window:.3f}", "-show_entries", "frame=pts_time",
|
|
1166
|
+
"-of", "csv=p=0", path], quiet=True, check=False)
|
|
1167
|
+
if proc.returncode != 0:
|
|
1168
|
+
return []
|
|
1169
|
+
out: List[float] = []
|
|
1170
|
+
for line in proc.stdout.splitlines():
|
|
1171
|
+
try:
|
|
1172
|
+
out.append(round(float(line.strip().rstrip(",")), 3))
|
|
1173
|
+
except ValueError:
|
|
1174
|
+
continue
|
|
1175
|
+
return sorted(set(out))
|
|
1176
|
+
|
|
1177
|
+
|
|
1178
|
+
def measured_level_dbfs(path: str, seconds: float = 120.0) -> Optional[Dict[str, float]]:
|
|
1179
|
+
"""Mean and peak level of the first `seconds` of audio (volumedetect), in dBFS; None if unmeasurable.
|
|
1180
|
+
Cheap enough to run once as a hint when a threshold-based tool found nothing."""
|
|
1181
|
+
ffmpeg = require_tool("ffmpeg")
|
|
1182
|
+
proc = run_analysis([ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.0f}", "-i", path, "-vn",
|
|
1183
|
+
"-af", "volumedetect", "-f", "null", "-"], check=False)
|
|
1184
|
+
m_mean = re.search(r"mean_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
1185
|
+
m_max = re.search(r"max_volume:\s*(-?[0-9.]+) dB", proc.stderr)
|
|
1186
|
+
if not (m_mean and m_max):
|
|
1187
|
+
return None
|
|
1188
|
+
return {"mean_dbfs": float(m_mean.group(1)), "peak_dbfs": float(m_max.group(1))}
|
|
1189
|
+
|
|
1190
|
+
|
|
1081
1191
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
1082
1192
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
1083
1193
|
|
package/scripts/audio.py
CHANGED
|
@@ -131,9 +131,9 @@ def main() -> int:
|
|
|
131
131
|
output = args.output or default_output(args.input, "audio")
|
|
132
132
|
audio_out = is_audio_output(output)
|
|
133
133
|
streams = meta.get("audio_streams") or []
|
|
134
|
-
if streams and not (0 <= args.audio_stream < len(streams)) and not STATE
|
|
134
|
+
if streams and not (0 <= args.audio_stream < len(streams)) and not STATE.dry_run:
|
|
135
135
|
die(f"--audio-stream {args.audio_stream}: input has {len(streams)} audio stream(s), 0..{len(streams) - 1}")
|
|
136
|
-
if args.audio_stream and not streams and not STATE
|
|
136
|
+
if args.audio_stream and not streams and not STATE.dry_run:
|
|
137
137
|
die("--audio-stream needs an input with audio streams")
|
|
138
138
|
|
|
139
139
|
inputs: List[str] = ["-i", args.input]
|
|
@@ -222,7 +222,7 @@ def main() -> int:
|
|
|
222
222
|
run(cmd)
|
|
223
223
|
r = probe(output, role="output")
|
|
224
224
|
a = r["audio"]
|
|
225
|
-
if r.get("video") and audio_out and not STATE
|
|
225
|
+
if r.get("video") and audio_out and not STATE.dry_run:
|
|
226
226
|
die(f"{output} unexpectedly contains a video stream")
|
|
227
227
|
info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz"
|
|
228
228
|
+ (", video stream-copied" if has_video and not audio_out else ", video dropped" if has_video else "") + ")")
|
package/scripts/background.py
CHANGED
|
@@ -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
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def main() -> int:
|
|
@@ -29,7 +29,7 @@ def main() -> int:
|
|
|
29
29
|
src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
|
|
30
30
|
ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
|
|
31
31
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
32
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
32
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
33
33
|
add_common(ap)
|
|
34
34
|
args = ap.parse_args()
|
|
35
35
|
apply_common(args)
|
package/scripts/batch.py
CHANGED
|
@@ -26,13 +26,12 @@ import argparse
|
|
|
26
26
|
import hashlib
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
|
-
import subprocess
|
|
30
29
|
import sys
|
|
31
30
|
import time
|
|
32
31
|
from pathlib import Path
|
|
33
32
|
from typing import Any, Dict, List
|
|
34
33
|
|
|
35
|
-
from _common import STATE, add_common, apply_common, child_args, die, emit, info
|
|
34
|
+
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool
|
|
36
35
|
|
|
37
36
|
HERE = Path(__file__).resolve().parent
|
|
38
37
|
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
@@ -78,9 +77,9 @@ def run_step(argv: List[str]) -> bool:
|
|
|
78
77
|
if script not in ALLOWED_STEP_SCRIPTS:
|
|
79
78
|
die(f"recipe step names a script that isn't one of this skill's own tools: {script!r} "
|
|
80
79
|
f"(must be a bare filename like 'silence.py', found in scripts/)")
|
|
81
|
-
cmd = [
|
|
82
|
-
info(" → " + " ".join(os.path.basename(c) if i <
|
|
83
|
-
proc =
|
|
80
|
+
cmd = [str(HERE / script)] + argv[1:] + child_args()
|
|
81
|
+
info(" → " + " ".join(os.path.basename(c) if i < 1 else c for i, c in enumerate(cmd)))
|
|
82
|
+
proc = run_tool(cmd)
|
|
84
83
|
if proc.returncode != 0:
|
|
85
84
|
info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
86
85
|
return False
|
|
@@ -191,7 +190,7 @@ def main() -> int:
|
|
|
191
190
|
info(f"=== {src.name}")
|
|
192
191
|
r = process(src, recipe, outdir, work)
|
|
193
192
|
results.append(r)
|
|
194
|
-
if r["ok"] and not STATE
|
|
193
|
+
if r["ok"] and not STATE.dry_run:
|
|
195
194
|
cache[key] = r
|
|
196
195
|
# write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
|
|
197
196
|
# a concurrent manual run) could leave a truncated file that json.loads() above
|
package/scripts/broll.py
CHANGED
|
@@ -21,7 +21,7 @@ import argparse
|
|
|
21
21
|
import sys
|
|
22
22
|
from typing import Any, Dict, List
|
|
23
23
|
|
|
24
|
-
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
24
|
+
from _common import STATE, add_common, aac_args, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, validate_color, video_args, X264_PRESETS
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -36,10 +36,11 @@ def main() -> int:
|
|
|
36
36
|
ap.add_argument("--audio", choices=["a", "b", "mix"], default="a", help="under a cutaway: A's audio (default), B's audio, or both mixed")
|
|
37
37
|
ap.add_argument("--pad-color", default="black", help="pad colour when B's aspect differs from A's (default black)")
|
|
38
38
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
39
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
39
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
40
40
|
add_common(ap)
|
|
41
41
|
args = ap.parse_args()
|
|
42
42
|
apply_common(args)
|
|
43
|
+
validate_color(args.pad_color, "--pad-color")
|
|
43
44
|
|
|
44
45
|
n = len(args.insert)
|
|
45
46
|
if len(args.at) != n:
|
|
@@ -82,7 +83,7 @@ def main() -> int:
|
|
|
82
83
|
if dur_a and at + length > dur_a + 0.01:
|
|
83
84
|
die(f"cutaway {i + 1}: {at:g}s + {length:g}s runs past the end of the A-roll ({dur_a:.3f}s)")
|
|
84
85
|
dur_b = meta_b.get("duration") or 0.0
|
|
85
|
-
if dur_b and start_b + length > dur_b + 0.01 and not STATE
|
|
86
|
+
if dur_b and start_b + length > dur_b + 0.01 and not STATE.dry_run:
|
|
86
87
|
die(f"cutaway {i + 1}: {path} has only {dur_b - start_b:.3f}s from {start_b:g}s, {length:g}s asked for")
|
|
87
88
|
if cutaways and at < cutaways[-1]["at"] + cutaways[-1]["length"]:
|
|
88
89
|
die(f"cutaway {i + 1} at {at:g}s overlaps the previous one (ends {cutaways[-1]['at'] + cutaways[-1]['length']:g}s)")
|
|
@@ -141,7 +142,7 @@ def main() -> int:
|
|
|
141
142
|
run(cmd)
|
|
142
143
|
|
|
143
144
|
result = probe(output, role="output")
|
|
144
|
-
if not STATE
|
|
145
|
+
if not STATE.dry_run and dur_a and abs((result.get("duration") or 0.0) - dur_a) > max(0.1, 1.5 / fps):
|
|
145
146
|
die(f"output is {result.get('duration'):.3f}s but the A-roll is {dur_a:.3f}s -- a cutaway must not change the length", kind="output")
|
|
146
147
|
info(f"wrote {output} ({result.get('duration', 0):.3f}s, {len(cutaways)} cutaway(s), audio={args.audio})")
|
|
147
148
|
emit(output, cutaways=[{"insert": c["path"], "at": c["at"], "end": c["at"] + c["length"], "from": c["from"]} for c in cutaways], audio=args.audio)
|
package/scripts/caption.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
|
@@ -406,7 +406,7 @@ def main() -> int:
|
|
|
406
406
|
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
407
407
|
enc = ap.add_argument_group("encoding")
|
|
408
408
|
enc.add_argument("--crf", type=int, default=18)
|
|
409
|
-
enc.add_argument("--preset", default="medium")
|
|
409
|
+
enc.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
410
410
|
add_common(ap)
|
|
411
411
|
args = ap.parse_args()
|
|
412
412
|
apply_common(args)
|
package/scripts/color.py
CHANGED
|
@@ -22,7 +22,7 @@ import os
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import List
|
|
24
24
|
|
|
25
|
-
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args
|
|
25
|
+
from _common import add_common, analyze_levels, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, run_keeping_subtitles, x264_args, X264_PRESETS
|
|
26
26
|
|
|
27
27
|
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
28
28
|
|
|
@@ -204,7 +204,7 @@ def main() -> int:
|
|
|
204
204
|
"audio (--to-sdr, --lut, --correct, and --retag's re-encode fallback) -- --strip-dovi and "
|
|
205
205
|
"a successful --retag stream-copy all streams untouched, so the flag has nothing to select there.")
|
|
206
206
|
ap.add_argument("--crf", type=int, default=18)
|
|
207
|
-
ap.add_argument("--preset", default="medium")
|
|
207
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS)
|
|
208
208
|
add_common(ap)
|
|
209
209
|
args = ap.parse_args()
|
|
210
210
|
apply_common(args)
|
package/scripts/crop.py
CHANGED
|
@@ -21,7 +21,7 @@ Examples:
|
|
|
21
21
|
import argparse
|
|
22
22
|
import sys
|
|
23
23
|
|
|
24
|
-
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args
|
|
24
|
+
from _common import add_common, apply_common, aac_args, cfr_args, default_output, die, emit, ffmpeg_base, info, probe, run, video_args, X264_PRESETS
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
def main() -> int:
|
|
@@ -33,7 +33,7 @@ def main() -> int:
|
|
|
33
33
|
ap.add_argument("--width", type=int, required=True, help="crop width in px (must be even)")
|
|
34
34
|
ap.add_argument("--height", type=int, required=True, help="crop height in px (must be even)")
|
|
35
35
|
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
36
|
-
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
36
|
+
ap.add_argument("--preset", default="medium", choices=X264_PRESETS, help="x264 preset")
|
|
37
37
|
ap.add_argument("--fps", type=float, help="force a constant output frame rate (recommended for VFR sources)")
|
|
38
38
|
add_common(ap)
|
|
39
39
|
args = ap.parse_args()
|