ffmpeg-skill 0.8.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -2
- package/bin/install.js +9 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +40 -44
- package/package.json +5 -3
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/_common.py +8 -4
- package/scripts/_contract.py +577 -0
- package/scripts/caption.py +4 -3
- package/scripts/report.py +6 -3
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ npx ffmpeg-skill
|
|
|
18
18
|
- **Lossless when possible** — cuts and joins use stream copy by default; re-encoding only happens when it must (frame-accurate cuts, filters, format changes).
|
|
19
19
|
- **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
|
|
20
20
|
- **Declarative edits** — describe the whole edit in a `project.json` (clips, transitions, captions, overlays, music, loudness, export, check) and re-render after every tweak.
|
|
21
|
-
- **MCP server** — `mcp/server.py` exposes every script as an MCP tool over stdio (stdlib only) for Claude Desktop, Cursor or any MCP client.
|
|
21
|
+
- **MCP server** — `mcp/server.py` exposes every script as an MCP tool over stdio (stdlib only) for Claude Desktop, Cursor or any MCP client; tool names, order and `inputSchema` are derived from the contract, so the MCP surface follows the scripts.
|
|
22
22
|
- **Batch / watch folder** — one recipe over a whole shoot with a content-hash cache; re-runs only touch what changed.
|
|
23
23
|
- **Optional local transcription** — `caption.py --transcribe` uses whisper.cpp / faster-whisper / openai-whisper when present; never required.
|
|
24
24
|
- **Brand kit** — one `brand.json` (fonts, colours, logo, safe margins, caption style) applied by captions, overlays, graphics and projects.
|
|
@@ -140,13 +140,22 @@ python3 tests/corpus.py --fetch --verify # ~1.4 GB download, then verify (sl
|
|
|
140
140
|
python3 tests/bench_sync.py --cases 100
|
|
141
141
|
```
|
|
142
142
|
|
|
143
|
+
## Machine-readable contract
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
npx ffmpeg-skill contract --json # every tool as a ToolSpec: schema from argparse, role, capabilities, verification, dry-run
|
|
147
|
+
npx ffmpeg-skill doctor # which required ffmpeg components this machine has
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
For agent frameworks that treat ffmpeg-skill as an execution skill: `contract --json` lists the 21 tools as `ffmpeg-skill/<name>` with input/output schemas, `analysis` / `execution` / `verification` roles, the ffmpeg encoders and filters each one needs, whether the result must be probed, checked or looked at, and that no tool modifies its input or runs a shell. `contract_version` (1.0) is separate from the skill version. Details in [docs/contract.md](docs/contract.md).
|
|
151
|
+
|
|
143
152
|
## MCP
|
|
144
153
|
|
|
145
154
|
```json
|
|
146
155
|
{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["/Users/you/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}
|
|
147
156
|
```
|
|
148
157
|
|
|
149
|
-
`python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
|
|
158
|
+
`python3 mcp/server.py --list` prints the tools (the same set, order and schemas as `contract --json`: the contract is the source of truth, MCP is the transport); `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
|
|
150
159
|
|
|
151
160
|
## Requirements
|
|
152
161
|
|
package/bin/install.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* npx ffmpeg-skill --dir ./skills # custom parent directory
|
|
13
13
|
* npx ffmpeg-skill --project # ./.claude/skills/ffmpeg-skill in the current project
|
|
14
14
|
* npx ffmpeg-skill --uninstall # remove from the selected targets
|
|
15
|
+
* npx ffmpeg-skill contract --json # machine-readable execution contract (see docs/contract.md)
|
|
16
|
+
* npx ffmpeg-skill doctor [--json] # which required ffmpeg capabilities this machine has
|
|
15
17
|
*/
|
|
16
18
|
'use strict';
|
|
17
19
|
|
|
@@ -22,7 +24,7 @@ const { spawnSync } = require('child_process');
|
|
|
22
24
|
|
|
23
25
|
const SKILL_NAME = 'ffmpeg-skill';
|
|
24
26
|
const ROOT = path.resolve(__dirname, '..');
|
|
25
|
-
const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'mcp'];
|
|
27
|
+
const PAYLOAD = ['SKILL.md', 'scripts', 'references', 'mcp', 'package.json'];
|
|
26
28
|
|
|
27
29
|
const args = process.argv.slice(2);
|
|
28
30
|
const has = (flag) => args.includes(flag);
|
|
@@ -36,6 +38,12 @@ if (has('--help') || has('-h')) {
|
|
|
36
38
|
process.exit(0);
|
|
37
39
|
}
|
|
38
40
|
|
|
41
|
+
// `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
|
|
42
|
+
if (args[0] === 'contract' || args[0] === 'doctor') {
|
|
43
|
+
const py = spawnSync('python3', [path.join(ROOT, 'scripts', '_contract.py'), ...args], { stdio: 'inherit' });
|
|
44
|
+
process.exit(py.error ? 127 : py.status);
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
const home = os.homedir();
|
|
40
48
|
const targets = [];
|
|
41
49
|
const want = { claude: has('--claude'), cursor: has('--cursor'), codex: has('--codex') };
|
|
Binary file
|
package/mcp/server.py
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""ffmpeg-skill as an MCP server (stdio, JSON-RPC 2.0) — standard library only.
|
|
3
3
|
|
|
4
|
-
Every script in ../scripts becomes a tool
|
|
5
|
-
|
|
4
|
+
Every public script in ../scripts becomes a tool. Tool names, order, inputSchema and the
|
|
5
|
+
structured-argument mapping all come from the contract (scripts/_contract.py); this file
|
|
6
|
+
is only the transport. Arguments are passed as a flat object (keys = argparse dests) or,
|
|
7
|
+
for CLI compatibility, as an argv list. Results are the script's --json output.
|
|
6
8
|
|
|
7
9
|
Run:
|
|
8
10
|
python3 mcp/server.py # stdio transport
|
|
@@ -20,41 +22,45 @@ HERE = Path(__file__).resolve().parent
|
|
|
20
22
|
SCRIPTS = HERE.parent / "scripts"
|
|
21
23
|
PROTOCOL_VERSION = "2024-11-05"
|
|
22
24
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
"
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
25
|
+
sys.path.insert(0, str(SCRIPTS))
|
|
26
|
+
import _contract # noqa: E402 (the contract is the only source of tool names, schemas and argument mapping)
|
|
27
|
+
|
|
28
|
+
_SPECS: Dict[str, Dict[str, Any]] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def specs() -> Dict[str, Dict[str, Any]]:
|
|
32
|
+
"""ToolSpecs from the contract, generated once per process (argparse -> ToolSpec -> here)."""
|
|
33
|
+
if not _SPECS:
|
|
34
|
+
for spec in _contract.build(detect=False)["tools"]:
|
|
35
|
+
_SPECS[spec["name"]] = spec
|
|
36
|
+
return _SPECS
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class _Positional(dict):
|
|
40
|
+
"""Positional argument names per tool, read from the contract (kept as a mapping for compatibility)."""
|
|
41
|
+
|
|
42
|
+
def __missing__(self, name: str) -> List[str]:
|
|
43
|
+
return list(specs()[name]["mcp"]["positional"]) if name in specs() else ["input"]
|
|
44
|
+
|
|
45
|
+
def get(self, name: str, default: Any = None) -> Any: # type: ignore[override]
|
|
46
|
+
return self[name] if name in specs() else default
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
POSITIONAL: Dict[str, List[str]] = _Positional()
|
|
46
50
|
|
|
47
51
|
|
|
48
52
|
def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
53
|
+
"""Structured arguments -> argv, using the mapping the contract states (invocation.structured)."""
|
|
49
54
|
if isinstance(args.get("argv"), list):
|
|
50
55
|
argv = [str(a) for a in args["argv"]]
|
|
51
|
-
if name not in
|
|
56
|
+
if name not in _contract.MCP_JSON_EXEMPT and "--json" not in argv and "--help" not in argv:
|
|
52
57
|
argv.append("--json")
|
|
53
58
|
return argv
|
|
59
|
+
spec = specs().get(name)
|
|
60
|
+
exceptions = spec["mcp"]["argument_exceptions"] if spec else {}
|
|
54
61
|
argv: List[str] = []
|
|
55
62
|
args = dict(args)
|
|
56
|
-
|
|
57
|
-
for key in pos:
|
|
63
|
+
for key in POSITIONAL[name]:
|
|
58
64
|
val = args.pop(key, None)
|
|
59
65
|
if val is None:
|
|
60
66
|
continue
|
|
@@ -65,11 +71,7 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
|
65
71
|
for key, val in args.items():
|
|
66
72
|
if val is None or val is False:
|
|
67
73
|
continue
|
|
68
|
-
flag = "--" + key.replace("_", "-")
|
|
69
|
-
if key == "output":
|
|
70
|
-
flag = "-o"
|
|
71
|
-
if key == "lufs" and name == "loudness":
|
|
72
|
-
flag = "-I"
|
|
74
|
+
flag = exceptions.get(key, "--" + key.replace("_", "-"))
|
|
73
75
|
if val is True:
|
|
74
76
|
argv.append(flag)
|
|
75
77
|
elif isinstance(val, list):
|
|
@@ -77,14 +79,14 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
|
77
79
|
argv += [flag, str(v)]
|
|
78
80
|
else:
|
|
79
81
|
argv += [flag, str(val)]
|
|
80
|
-
if name not in
|
|
82
|
+
if name not in _contract.MCP_JSON_EXEMPT and "--json" not in argv and "--help" not in argv:
|
|
81
83
|
argv.append("--json")
|
|
82
84
|
return argv
|
|
83
85
|
|
|
84
86
|
|
|
85
87
|
def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
86
88
|
script = SCRIPTS / f"{name}.py"
|
|
87
|
-
if not script.exists():
|
|
89
|
+
if name not in specs() or not script.exists():
|
|
88
90
|
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
89
91
|
argv = build_argv(name, args or {})
|
|
90
92
|
proc = subprocess.run([sys.executable, str(script)] + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
@@ -108,14 +110,8 @@ def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
108
110
|
|
|
109
111
|
|
|
110
112
|
def tool_list() -> List[Dict[str, Any]]:
|
|
111
|
-
|
|
112
|
-
for
|
|
113
|
-
out.append({
|
|
114
|
-
"name": name,
|
|
115
|
-
"description": desc + " Pass either named args (flags without dashes, underscores for hyphens) or argv (raw CLI list). Media paths must be absolute.",
|
|
116
|
-
"inputSchema": {"type": "object", "properties": {"argv": {"type": "array", "items": {"type": "string"}, "description": "raw CLI arguments"}}, "additionalProperties": True},
|
|
117
|
-
})
|
|
118
|
-
return out
|
|
113
|
+
"""tools/list: one entry per ToolSpec, in the contract's order, inputSchema derived from ToolSpec.input_schema."""
|
|
114
|
+
return [_contract.mcp_tool(spec) for spec in specs().values()]
|
|
119
115
|
|
|
120
116
|
|
|
121
117
|
def handle(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
@@ -142,7 +138,7 @@ def version() -> str:
|
|
|
142
138
|
def main() -> int:
|
|
143
139
|
if "--list" in sys.argv:
|
|
144
140
|
for t in tool_list():
|
|
145
|
-
print(f"{t['name']:10s} {t['description'].split('
|
|
141
|
+
print(f"{t['name']:10s} {t['description'].split(' Structured arguments:')[0]}")
|
|
146
142
|
return 0
|
|
147
143
|
if "--call" in sys.argv: # debugging helper: --call NAME '{"input": "..."}'
|
|
148
144
|
i = sys.argv.index("--call")
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: MCP server, batch processing, declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
5
5
|
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
6
|
"license": "MIT",
|
|
@@ -24,9 +24,11 @@
|
|
|
24
24
|
"LICENSE"
|
|
25
25
|
],
|
|
26
26
|
"scripts": {
|
|
27
|
-
"test": "python3 tests/test_all.py",
|
|
27
|
+
"test": "python3 tests/test_all.py && python3 tests/test_contract.py",
|
|
28
28
|
"release-check": "bash tests/release_check.sh",
|
|
29
|
-
"demo": "bash examples/make_demo.sh"
|
|
29
|
+
"demo": "bash examples/make_demo.sh",
|
|
30
|
+
"contract": "python3 scripts/_contract.py --json",
|
|
31
|
+
"doctor": "python3 scripts/_contract.py doctor"
|
|
30
32
|
},
|
|
31
33
|
"engines": {
|
|
32
34
|
"node": ">=16"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -31,8 +31,12 @@ INSTALL_HINTS = {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def die(msg: str, code: int = 1) -> "None":
|
|
34
|
+
def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
35
|
+
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
36
|
+
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
|
|
35
37
|
sys.stderr.write(f"error: {msg}\n")
|
|
38
|
+
if STATE.json:
|
|
39
|
+
print_json({"status": "failed", "error": {"kind": kind, "message": msg}})
|
|
36
40
|
sys.exit(code)
|
|
37
41
|
|
|
38
42
|
|
|
@@ -53,7 +57,7 @@ def require_tool(name: str) -> str:
|
|
|
53
57
|
die(
|
|
54
58
|
f"'{name}' was not found on PATH.\n"
|
|
55
59
|
f"Install FFmpeg (which includes ffprobe) for {system}:\n{hint}",
|
|
56
|
-
code=127,
|
|
60
|
+
code=127, kind="missing_tool",
|
|
57
61
|
)
|
|
58
62
|
return "" # unreachable
|
|
59
63
|
|
|
@@ -122,7 +126,7 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
122
126
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
123
127
|
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
124
128
|
if STATE.json:
|
|
125
|
-
doc: Dict[str, Any] = {"output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
129
|
+
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
126
130
|
if output and not STATE.dry_run and os.path.exists(output):
|
|
127
131
|
doc["probe"] = probe(output)
|
|
128
132
|
doc.update(extra)
|
|
@@ -141,7 +145,7 @@ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
|
|
|
141
145
|
|
|
142
146
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
143
147
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
144
|
-
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1)
|
|
148
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
|
|
145
149
|
|
|
146
150
|
|
|
147
151
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
@@ -0,0 +1,577 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Machine-readable execution contract for ffmpeg-skill (internal module, not a tool).
|
|
3
|
+
|
|
4
|
+
python3 scripts/_contract.py --json # the contract, with detected capabilities
|
|
5
|
+
python3 scripts/_contract.py --json --static # same, without environment detection
|
|
6
|
+
python3 scripts/_contract.py doctor [--json] # which required capabilities this machine has
|
|
7
|
+
ffmpeg-skill contract --json # the same through the npm entry point
|
|
8
|
+
|
|
9
|
+
The contract describes every public tool in scripts/ (one ToolSpec per script that does
|
|
10
|
+
not start with "_"): what it needs, what it takes, what it writes, how to verify the
|
|
11
|
+
result, and whether an agent can plan it with --dry-run. Input schemas are generated
|
|
12
|
+
from each script's argparse parser, so the CLI stays the single source of truth; the
|
|
13
|
+
per-tool facts that cannot be read from a parser (role, verification policy, required
|
|
14
|
+
ffmpeg components) live in TOOL_META below and are checked against the scripts by
|
|
15
|
+
tests/test_contract.py.
|
|
16
|
+
|
|
17
|
+
The contract has its own version (CONTRACT_VERSION) that only changes when the shape of
|
|
18
|
+
this document changes; the skill version comes from package.json.
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import importlib.util
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import shutil
|
|
26
|
+
import subprocess
|
|
27
|
+
import sys
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Dict, List, Optional
|
|
30
|
+
|
|
31
|
+
HERE = Path(__file__).resolve().parent
|
|
32
|
+
ROOT = HERE.parent
|
|
33
|
+
SKILL_ID = "ffmpeg-skill"
|
|
34
|
+
CONTRACT_VERSION = "1.0"
|
|
35
|
+
|
|
36
|
+
ROLES = {
|
|
37
|
+
"analysis": "reads media and reports measurements; writes no media",
|
|
38
|
+
"analysis_and_execution": "measures by default or with a flag, and can also write a transformed artifact",
|
|
39
|
+
"execution": "writes a new media artifact from the input(s); the input is never modified",
|
|
40
|
+
"verification": "checks or shows an artifact (probe numbers, compliance rows, contact sheets); writes no media",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
# Facts that are not derivable from the argparse parsers. Capability names:
|
|
44
|
+
# ffmpeg / ffprobe the binaries on PATH
|
|
45
|
+
# encoder:<name> `ffmpeg -encoders`
|
|
46
|
+
# filter:<name> `ffmpeg -filters`
|
|
47
|
+
# bsf:<name> `ffmpeg -bsfs`
|
|
48
|
+
# external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
|
|
49
|
+
# "optional" entries name the flag or condition under which the capability is needed.
|
|
50
|
+
FF = ["ffmpeg", "ffprobe"]
|
|
51
|
+
X264 = "encoder:libx264"
|
|
52
|
+
X265 = "encoder:libx265"
|
|
53
|
+
AAC = "encoder:aac"
|
|
54
|
+
HDR_X265 = {"capability": X265, "when": "the source is HDR (kept as HEVC Main10)"}
|
|
55
|
+
AUDIO_OUT = [
|
|
56
|
+
{"capability": "encoder:libmp3lame", "when": "output extension is .mp3"},
|
|
57
|
+
{"capability": "encoder:libopus", "when": "output extension is .opus"},
|
|
58
|
+
{"capability": "encoder:libvorbis", "when": "output extension is .ogg"},
|
|
59
|
+
{"capability": "encoder:flac", "when": "output extension is .flac"},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
63
|
+
"probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
|
|
64
|
+
required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
|
|
65
|
+
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
|
|
66
|
+
"cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family)"],
|
|
67
|
+
required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}],
|
|
68
|
+
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
69
|
+
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
70
|
+
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
|
|
71
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
72
|
+
"caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions", "generated .srt / .ass sidecar"],
|
|
73
|
+
required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"}],
|
|
74
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
75
|
+
"overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo) or text (--text)"], outputs=["video artifact with the overlay composited"],
|
|
76
|
+
required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, HDR_X265],
|
|
77
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
78
|
+
"graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
|
|
79
|
+
required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
|
|
80
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
81
|
+
"sync": dict(role="analysis_and_execution", inputs=["reference recording (video or audio)", "second recording (video or audio)"], outputs=["offset / drift JSON on stdout", "aligned artifact with --replace-audio / --trim-second / --fix-drift -o"],
|
|
82
|
+
required=FF, optional=[{"capability": AAC, "when": "writing a video container"}] + AUDIO_OUT,
|
|
83
|
+
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
84
|
+
"multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
|
|
85
|
+
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
86
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
87
|
+
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied)"],
|
|
88
|
+
required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"}] + AUDIO_OUT,
|
|
89
|
+
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
90
|
+
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
91
|
+
required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
|
|
92
|
+
video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
93
|
+
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
94
|
+
required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
|
|
95
|
+
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
96
|
+
"join": dict(role="execution", inputs=["two or more video assets"], outputs=["concatenated video artifact"],
|
|
97
|
+
required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265],
|
|
98
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
99
|
+
"color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
|
|
100
|
+
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
101
|
+
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"}],
|
|
102
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
103
|
+
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
104
|
+
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
|
|
105
|
+
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
|
|
106
|
+
{"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}],
|
|
107
|
+
video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
108
|
+
"check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
|
|
109
|
+
required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
|
|
110
|
+
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
|
|
111
|
+
"scenes": dict(role="analysis", inputs=["video asset"], outputs=["scene / audio-peak / highlight JSON on stdout", "EDL text (--edl)", "per-scene contact sheet PNG (--sheet)"],
|
|
112
|
+
required=FF + ["filter:scdet"], optional=[{"capability": "filter:drawtext", "when": "--sheet"}, {"capability": "filter:tile", "when": "--sheet"}],
|
|
113
|
+
video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
114
|
+
"look": dict(role="verification", inputs=["video artifact"], outputs=["PNG contact sheet / frames / side-by-side"],
|
|
115
|
+
required=FF + ["filter:tile"], optional=[{"capability": "filter:drawtext", "when": "timecode stamps (default; --no-timecode to skip)"}, {"capability": "filter:zscale", "when": "HDR source"}, {"capability": "filter:tonemap", "when": "HDR source"}],
|
|
116
|
+
video_required=True, audio_only=False, visual=False, verify=[], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
117
|
+
"render": dict(role="execution", inputs=["project.json (clips, transitions, captions, overlays, audio, loudness, export, check)"], outputs=["final video artifact", "work directory of stage outputs (--keep / --work)"],
|
|
118
|
+
required=FF, optional=[{"capability": "delegated", "when": "each stage runs cut / join / fit / caption / overlay / audio / loudness / export / check with their capabilities"}],
|
|
119
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "check", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
120
|
+
"batch": dict(role="execution", inputs=["folder of media", "batch.json recipe (steps or a render project)"], outputs=["one artifact per input file in the recipe's output_dir", "content-hash cache"],
|
|
121
|
+
required=FF, optional=[{"capability": "delegated", "when": "each recipe step runs the named script with its capabilities"}],
|
|
122
|
+
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="cached", deterministic=True),
|
|
123
|
+
"verify": dict(role="verification", inputs=["media files and/or folders"], outputs=["PASS/FAIL JSON per step", "Markdown report (--report)", "step outputs (--out / --keep)"],
|
|
124
|
+
required=FF, optional=[{"capability": "delegated", "when": "runs cut / fit / caption / export / loudness / color on each file"}],
|
|
125
|
+
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="environment_dependent", deterministic=False),
|
|
126
|
+
"report": dict(role="verification", inputs=["deliverable (--after)", "source (--before)", "commands / notes text"], outputs=["single-file HTML delivery report"],
|
|
127
|
+
required=FF + ["filter:loudnorm"], optional=[{"capability": "delegated", "when": "runs look (sheets) and check (--platform)"}],
|
|
128
|
+
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# Dry-run behaviour a parser cannot express (measured in tests/test_contract.py with a fake ffmpeg
|
|
132
|
+
# on PATH). "analysis_only": ffmpeg still decodes/measures the input under --dry-run, but nothing
|
|
133
|
+
# is encoded and no file is written. Every other tool with the flag runs no ffmpeg at all.
|
|
134
|
+
DRY_RUN_ANALYSIS = {
|
|
135
|
+
"sync": "audio is decoded to find the offset; the aligned output is not written",
|
|
136
|
+
"multicam": "audio is decoded to align the cameras; the switched output is not written",
|
|
137
|
+
"scenes": "scene and audio-peak measurement runs; --sheet and --edl are not written",
|
|
138
|
+
"report": "probe, loudness and contact-sheet measurements run; the HTML is not written",
|
|
139
|
+
}
|
|
140
|
+
DRY_RUN_NOTES = {
|
|
141
|
+
"probe": "read-only tool; --dry-run changes nothing (ffprobe still runs)",
|
|
142
|
+
"check": "read-only tool; --dry-run skips the ffmpeg loudness measurement, so loudness rows are absent",
|
|
143
|
+
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
IDEMPOTENCY = {
|
|
147
|
+
"bit_exact": "same inputs and flags give byte-identical output",
|
|
148
|
+
"content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
|
|
149
|
+
"cached": "re-runs skip inputs whose content hash and recipe are unchanged",
|
|
150
|
+
"environment_dependent": "output includes timings or machine state and differs between runs",
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ----------------------------------------------------------------------------- parsers
|
|
155
|
+
class _Captured(Exception):
|
|
156
|
+
def __init__(self, parser: argparse.ArgumentParser) -> None:
|
|
157
|
+
self.parser = parser
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _capture_parser(script: Path) -> argparse.ArgumentParser:
|
|
161
|
+
"""Import the script and run main() until parse_args() to get its live parser."""
|
|
162
|
+
original = argparse.ArgumentParser.parse_args
|
|
163
|
+
|
|
164
|
+
def fake_parse(self: argparse.ArgumentParser, *a: Any, **k: Any) -> Any:
|
|
165
|
+
raise _Captured(self)
|
|
166
|
+
|
|
167
|
+
argparse.ArgumentParser.parse_args = fake_parse # type: ignore[assignment]
|
|
168
|
+
sys_argv = sys.argv
|
|
169
|
+
try:
|
|
170
|
+
sys.argv = [str(script)]
|
|
171
|
+
spec = importlib.util.spec_from_file_location("ffskill_tool_" + script.stem, script)
|
|
172
|
+
module = importlib.util.module_from_spec(spec)
|
|
173
|
+
assert spec.loader is not None
|
|
174
|
+
spec.loader.exec_module(module)
|
|
175
|
+
module.main()
|
|
176
|
+
except _Captured as cap:
|
|
177
|
+
return cap.parser
|
|
178
|
+
finally:
|
|
179
|
+
argparse.ArgumentParser.parse_args = original # type: ignore[assignment]
|
|
180
|
+
sys.argv = sys_argv
|
|
181
|
+
raise RuntimeError(f"{script.name}: main() returned before parse_args()")
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _json_type(action: argparse.Action) -> Dict[str, Any]:
|
|
185
|
+
if isinstance(action, argparse._StoreTrueAction):
|
|
186
|
+
return {"type": "boolean"}
|
|
187
|
+
if isinstance(action, argparse._AppendAction) or action.nargs in ("+", "*"):
|
|
188
|
+
return {"type": "array", "items": {"type": "string"}}
|
|
189
|
+
if action.type is int:
|
|
190
|
+
return {"type": "integer"}
|
|
191
|
+
if action.type is float:
|
|
192
|
+
return {"type": "number"}
|
|
193
|
+
return {"type": "string"}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
197
|
+
props: Dict[str, Any] = {}
|
|
198
|
+
required: List[str] = []
|
|
199
|
+
positional: List[str] = []
|
|
200
|
+
common = {"dry_run", "json", "progress", "fast"}
|
|
201
|
+
for action in parser._actions:
|
|
202
|
+
if isinstance(action, argparse._HelpAction):
|
|
203
|
+
continue
|
|
204
|
+
prop: Dict[str, Any] = _json_type(action)
|
|
205
|
+
if action.help and action.help != argparse.SUPPRESS:
|
|
206
|
+
prop["description"] = action.help % {"default": action.default} if "%(default)" in action.help else action.help
|
|
207
|
+
if action.choices:
|
|
208
|
+
prop["enum"] = list(action.choices)
|
|
209
|
+
if action.default not in (None, False, argparse.SUPPRESS):
|
|
210
|
+
prop["default"] = action.default
|
|
211
|
+
if action.option_strings:
|
|
212
|
+
prop["cli"] = list(action.option_strings)
|
|
213
|
+
if action.required:
|
|
214
|
+
required.append(action.dest)
|
|
215
|
+
else:
|
|
216
|
+
prop["cli"] = "positional"
|
|
217
|
+
positional.append(action.dest)
|
|
218
|
+
if action.nargs not in ("?", "*"):
|
|
219
|
+
required.append(action.dest)
|
|
220
|
+
if action.dest in common:
|
|
221
|
+
prop["common"] = True
|
|
222
|
+
props[action.dest] = prop
|
|
223
|
+
groups = [g for g in getattr(parser, "_mutually_exclusive_groups", []) if g._group_actions]
|
|
224
|
+
schema: Dict[str, Any] = {"type": "object", "properties": props, "required": required, "positional": positional, "additionalProperties": False}
|
|
225
|
+
if groups:
|
|
226
|
+
schema["mutually_exclusive"] = [[a.dest for a in g._group_actions] for g in groups]
|
|
227
|
+
schema["one_of_required"] = [[a.dest for a in g._group_actions] for g in groups if g.required]
|
|
228
|
+
return schema
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
232
|
+
"""What the tool prints on stdout with --json (keys observed in the implementation)."""
|
|
233
|
+
if name == "probe":
|
|
234
|
+
return {"type": "object", "description": "one probe document, or an array of them for several inputs",
|
|
235
|
+
"properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
|
|
236
|
+
"video": {"type": ["object", "null"]}, "audio": {"type": ["object", "null"]}}, "additionalProperties": True}
|
|
237
|
+
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
238
|
+
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
239
|
+
"probe": {"type": "object", "description": "probe of the output when a file was written"}}
|
|
240
|
+
extra: Dict[str, Any] = {}
|
|
241
|
+
if name == "check":
|
|
242
|
+
extra = {"platform": {"type": "string"}, "ok": {"type": "boolean"}, "failed": {"type": "integer"}, "warnings": {"type": "integer"},
|
|
243
|
+
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
244
|
+
elif name == "scenes":
|
|
245
|
+
extra = {"file": {"type": "string"}, "duration": {"type": "number"}, "scene_count": {"type": "integer"}, "scenes": {"type": "array"}, "audio_peaks": {"type": "array"}}
|
|
246
|
+
elif name == "silence":
|
|
247
|
+
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"}}
|
|
248
|
+
elif name == "sync":
|
|
249
|
+
extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"}}
|
|
250
|
+
elif name == "look":
|
|
251
|
+
extra = {"outputs": {"type": "array", "items": {"type": "string"}}}
|
|
252
|
+
elif name == "render":
|
|
253
|
+
extra = {"stages": {"type": "array", "items": {"type": "string"}}, "check": {"type": ["object", "null"]}}
|
|
254
|
+
elif name == "verify":
|
|
255
|
+
extra = {"report": {"type": ["string", "null"]}, "files": {"type": "array"}, "failed": {"type": "integer"}, "total": {"type": "integer"}}
|
|
256
|
+
elif name == "batch":
|
|
257
|
+
extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
|
|
258
|
+
elif name == "report":
|
|
259
|
+
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
|
|
260
|
+
elif name == "loudness":
|
|
261
|
+
extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
|
|
262
|
+
props = dict(base)
|
|
263
|
+
props.update(extra)
|
|
264
|
+
required = ["status", "output", "dry_run", "commands"]
|
|
265
|
+
return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
# ----------------------------------------------------------------------------- environment
|
|
269
|
+
def skill_version() -> str:
|
|
270
|
+
for candidate in (ROOT / "package.json",):
|
|
271
|
+
try:
|
|
272
|
+
return str(json.loads(candidate.read_text(encoding="utf-8"))["version"])
|
|
273
|
+
except (OSError, ValueError, KeyError):
|
|
274
|
+
continue
|
|
275
|
+
return "unknown"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def skill_description() -> str:
|
|
279
|
+
try:
|
|
280
|
+
text = (ROOT / "SKILL.md").read_text(encoding="utf-8")
|
|
281
|
+
m = re.search(r"^description:\s*(.+)$", text, re.M)
|
|
282
|
+
return m.group(1).strip() if m else ""
|
|
283
|
+
except OSError:
|
|
284
|
+
return ""
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def public_tools() -> List[str]:
|
|
288
|
+
return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _ff_list(binary: str, flag: str) -> List[str]:
|
|
292
|
+
"""Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing)."""
|
|
293
|
+
exe = shutil.which(binary)
|
|
294
|
+
if not exe:
|
|
295
|
+
return []
|
|
296
|
+
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
297
|
+
names: List[str] = []
|
|
298
|
+
flags = {"-encoders": r"[VASFXBD.]{6}", "-filters": r"[TSC.]{3}"}.get(flag)
|
|
299
|
+
for line in proc.stdout.splitlines():
|
|
300
|
+
parts = line.split()
|
|
301
|
+
if not parts:
|
|
302
|
+
continue
|
|
303
|
+
if flag == "-bsfs":
|
|
304
|
+
if len(parts) == 1 and not parts[0].endswith(":"):
|
|
305
|
+
names.append(parts[0])
|
|
306
|
+
elif flags and len(parts) >= 2 and re.fullmatch(flags, parts[0]):
|
|
307
|
+
names.append(parts[1])
|
|
308
|
+
return names
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def _version_line(binary: str) -> Optional[str]:
|
|
312
|
+
exe = shutil.which(binary)
|
|
313
|
+
if not exe:
|
|
314
|
+
return None
|
|
315
|
+
proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
316
|
+
first = (proc.stdout or proc.stderr).splitlines()[:1]
|
|
317
|
+
m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
|
|
318
|
+
return m.group(1) if m else (first[0] if first else "unknown")
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _whisper_available() -> bool:
|
|
322
|
+
if shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("whisper"):
|
|
323
|
+
return True
|
|
324
|
+
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def required_capabilities() -> Dict[str, List[str]]:
|
|
328
|
+
req: set = set()
|
|
329
|
+
opt: set = set()
|
|
330
|
+
for meta in TOOL_META.values():
|
|
331
|
+
req.update(meta["required"])
|
|
332
|
+
opt.update(o["capability"] for o in meta["optional"] if o["capability"] != "delegated")
|
|
333
|
+
opt -= req
|
|
334
|
+
return {"required": sorted(req), "optional": sorted(opt)}
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def doctor() -> Dict[str, Any]:
|
|
338
|
+
"""Detect which declared capabilities this machine has. No secrets, no environment variables."""
|
|
339
|
+
encoders = set(_ff_list("ffmpeg", "-encoders"))
|
|
340
|
+
filters = set(_ff_list("ffmpeg", "-filters"))
|
|
341
|
+
bsfs = set(_ff_list("ffmpeg", "-bsfs"))
|
|
342
|
+
have: Dict[str, bool] = {}
|
|
343
|
+
wanted = required_capabilities()
|
|
344
|
+
for cap in wanted["required"] + wanted["optional"]:
|
|
345
|
+
if cap == "ffmpeg":
|
|
346
|
+
have[cap] = shutil.which("ffmpeg") is not None
|
|
347
|
+
elif cap == "ffprobe":
|
|
348
|
+
have[cap] = shutil.which("ffprobe") is not None
|
|
349
|
+
elif cap.startswith("encoder:"):
|
|
350
|
+
have[cap] = cap[8:] in encoders
|
|
351
|
+
elif cap.startswith("filter:"):
|
|
352
|
+
have[cap] = cap[7:] in filters
|
|
353
|
+
elif cap.startswith("bsf:"):
|
|
354
|
+
have[cap] = cap[4:] in bsfs
|
|
355
|
+
elif cap == "external:whisper":
|
|
356
|
+
have[cap] = _whisper_available()
|
|
357
|
+
else:
|
|
358
|
+
have[cap] = False
|
|
359
|
+
available = sorted(c for c, ok in have.items() if ok)
|
|
360
|
+
missing_required = sorted(c for c in wanted["required"] if not have.get(c))
|
|
361
|
+
missing_optional = sorted(c for c in wanted["optional"] if not have.get(c))
|
|
362
|
+
return {
|
|
363
|
+
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
364
|
+
"ffmpeg": _version_line("ffmpeg"),
|
|
365
|
+
"ffprobe": _version_line("ffprobe"),
|
|
366
|
+
"available": available,
|
|
367
|
+
"missing": missing_required,
|
|
368
|
+
"missing_optional": missing_optional,
|
|
369
|
+
"ok": not missing_required,
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
# ----------------------------------------------------------------------------- contract
|
|
374
|
+
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
375
|
+
if name not in TOOL_META:
|
|
376
|
+
# a public script without metadata is drift: fail loudly instead of guessing its role or capabilities
|
|
377
|
+
raise RuntimeError(f"scripts/{name}.py is public but has no TOOL_META entry in scripts/_contract.py; add one (or prefix the file with '_')")
|
|
378
|
+
meta = TOOL_META[name]
|
|
379
|
+
parser = _capture_parser(HERE / f"{name}.py")
|
|
380
|
+
schema = input_schema(parser)
|
|
381
|
+
# structured key -> CLI flag where key.replace("_", "-") is not the long option (MCP uses the same table)
|
|
382
|
+
exceptions: Dict[str, str] = {}
|
|
383
|
+
for dest, prop in schema["properties"].items():
|
|
384
|
+
if prop["cli"] == "positional":
|
|
385
|
+
continue
|
|
386
|
+
longs = [f for f in prop["cli"] if f.startswith("--")]
|
|
387
|
+
if dest == "output":
|
|
388
|
+
exceptions[dest] = "-o"
|
|
389
|
+
elif name == "loudness" and dest == "lufs":
|
|
390
|
+
exceptions[dest] = "-I"
|
|
391
|
+
elif "--" + dest.replace("_", "-") not in longs:
|
|
392
|
+
exceptions[dest] = longs[0] if longs else prop["cli"][0]
|
|
393
|
+
supports_dry_run = "dry_run" in schema["properties"] and name != "verify"
|
|
394
|
+
return {
|
|
395
|
+
"id": f"{SKILL_ID}/{name}",
|
|
396
|
+
"name": name,
|
|
397
|
+
"version": version,
|
|
398
|
+
"description": (parser.description or "").strip().splitlines()[0] if parser.description else "",
|
|
399
|
+
"executable": f"scripts/{name}.py",
|
|
400
|
+
"role": meta["role"],
|
|
401
|
+
"capabilities": {"required": list(meta["required"]), "optional": list(meta["optional"])},
|
|
402
|
+
"inputs": list(meta["inputs"]),
|
|
403
|
+
"outputs": list(meta["outputs"]),
|
|
404
|
+
"input_schema": schema,
|
|
405
|
+
"output_schema": output_schema(name, meta),
|
|
406
|
+
"supports_dry_run": supports_dry_run,
|
|
407
|
+
"dry_run": {"supported": supports_dry_run,
|
|
408
|
+
"ffmpeg_execution": "full" if not supports_dry_run else "analysis_only" if name in DRY_RUN_ANALYSIS else "none",
|
|
409
|
+
"semantics": "prints the ffmpeg command lines that would run; no output file is written",
|
|
410
|
+
**({"note": DRY_RUN_ANALYSIS.get(name) or DRY_RUN_NOTES[name]} if name in DRY_RUN_ANALYSIS or name in DRY_RUN_NOTES else {})},
|
|
411
|
+
"supports_json": "json" in schema["properties"],
|
|
412
|
+
"mutates_input": False,
|
|
413
|
+
"produces_artifact": meta["produces_artifact"],
|
|
414
|
+
"verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
|
|
415
|
+
"requires_visual_verification": meta["visual"],
|
|
416
|
+
"audio_only": meta["audio_only"],
|
|
417
|
+
"video_required": meta["video_required"],
|
|
418
|
+
"deterministic_inputs": meta["deterministic"],
|
|
419
|
+
"idempotency_hint": meta["idempotency"],
|
|
420
|
+
"mcp": {"tool": name, "positional": schema["positional"], "argument_exceptions": exceptions},
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ----------------------------------------------------------------------------- MCP derivation
|
|
425
|
+
# tools that print JSON without --json (probe) or whose primary output is a file path (look): the transport
|
|
426
|
+
# does not append --json for them (stated in invocation.structured.argument_mapping.json)
|
|
427
|
+
MCP_JSON_EXEMPT = ("look", "probe")
|
|
428
|
+
MCP_STRUCTURED_NOTE = ("Structured arguments: keys are the input_schema property names (argparse dests), positionals "
|
|
429
|
+
"are passed by name, output -> -o. Or argv: the raw CLI list (non-canonical; all other keys are then ignored). "
|
|
430
|
+
"Media paths must be absolute.")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def mcp_input_schema(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
434
|
+
"""Translate a ToolSpec.input_schema into the JSON Schema an MCP tools/list entry carries.
|
|
435
|
+
|
|
436
|
+
Deterministic and lossless where JSON Schema can express argparse semantics:
|
|
437
|
+
- properties keep type / enum / default / description / items; the ffmpeg-skill-only keys
|
|
438
|
+
(`cli`, `common`) are dropped, positionals get a "(positional N)" prefix in the description;
|
|
439
|
+
- required fields, mutually exclusive groups (`not required [a, b]` per pair) and required
|
|
440
|
+
groups (`anyOf required`) apply to the structured branch;
|
|
441
|
+
- the raw-argv compatibility branch (`argv` present) lifts those constraints, which JSON Schema
|
|
442
|
+
expresses as a top-level anyOf of the two branches.
|
|
443
|
+
Not expressible and therefore documented rather than encoded: which keys the tool ignores when
|
|
444
|
+
`argv` is given (all of them), and argparse's `%(default)s` help interpolation (already applied).
|
|
445
|
+
"""
|
|
446
|
+
src = spec["input_schema"]
|
|
447
|
+
props: Dict[str, Any] = {}
|
|
448
|
+
positional = list(src.get("positional", []))
|
|
449
|
+
for dest in sorted(src["properties"]):
|
|
450
|
+
p = src["properties"][dest]
|
|
451
|
+
out: Dict[str, Any] = {"type": p["type"]}
|
|
452
|
+
if p["type"] == "array":
|
|
453
|
+
out["items"] = dict(p.get("items", {"type": "string"}))
|
|
454
|
+
desc = p.get("description", "")
|
|
455
|
+
if dest in positional:
|
|
456
|
+
desc = f"(positional {positional.index(dest) + 1}) {desc}".strip()
|
|
457
|
+
if desc:
|
|
458
|
+
out["description"] = desc
|
|
459
|
+
for key in ("enum", "default"):
|
|
460
|
+
if key in p:
|
|
461
|
+
out[key] = p[key]
|
|
462
|
+
props[dest] = out
|
|
463
|
+
props["argv"] = {"type": "array", "items": {"type": "string"}, "description": "raw CLI arguments (non-canonical compatibility path; when present every other key is ignored)"}
|
|
464
|
+
structured: Dict[str, Any] = {}
|
|
465
|
+
if src.get("required"):
|
|
466
|
+
structured["required"] = list(src["required"])
|
|
467
|
+
all_of: List[Dict[str, Any]] = []
|
|
468
|
+
for group in src.get("mutually_exclusive", []):
|
|
469
|
+
for i, a in enumerate(group):
|
|
470
|
+
for b in group[i + 1:]:
|
|
471
|
+
all_of.append({"not": {"required": [a, b]}})
|
|
472
|
+
if all_of:
|
|
473
|
+
structured["allOf"] = all_of
|
|
474
|
+
one_of = [[{"required": [d]} for d in group] for group in src.get("one_of_required", [])]
|
|
475
|
+
if one_of:
|
|
476
|
+
structured["anyOf"] = one_of[0] if len(one_of) == 1 else [{"allOf": [{"anyOf": g} for g in one_of]}]
|
|
477
|
+
schema: Dict[str, Any] = {"type": "object", "properties": props, "additionalProperties": False}
|
|
478
|
+
if structured:
|
|
479
|
+
schema["anyOf"] = [{"required": ["argv"]}, structured]
|
|
480
|
+
return schema
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def mcp_tool(spec: Dict[str, Any]) -> Dict[str, Any]:
|
|
484
|
+
"""The MCP tools/list entry for a ToolSpec: name, description and the derived inputSchema."""
|
|
485
|
+
return {"name": spec["name"], "description": f"{spec['description']} {MCP_STRUCTURED_NOTE}".strip(), "inputSchema": mcp_input_schema(spec)}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def mcp_tools(detect: bool = False) -> List[Dict[str, Any]]:
|
|
489
|
+
return [mcp_tool(spec) for spec in build(detect=detect)["tools"]]
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def build(detect: bool = True) -> Dict[str, Any]:
|
|
493
|
+
version = skill_version()
|
|
494
|
+
tools = [tool_spec(n, version) for n in public_tools()]
|
|
495
|
+
wanted = required_capabilities()
|
|
496
|
+
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
|
|
497
|
+
if detect:
|
|
498
|
+
d = doctor()
|
|
499
|
+
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"], "detected_by": "doctor"})
|
|
500
|
+
return {
|
|
501
|
+
"contract_version": CONTRACT_VERSION,
|
|
502
|
+
"skill": {
|
|
503
|
+
"id": SKILL_ID,
|
|
504
|
+
"version": version,
|
|
505
|
+
"description": skill_description(),
|
|
506
|
+
"execution_mode": "local",
|
|
507
|
+
"kind": "execution",
|
|
508
|
+
"entrypoints": {
|
|
509
|
+
"cli": "python3 scripts/<tool>.py [args] [--json] [--dry-run]",
|
|
510
|
+
"mcp": "python3 mcp/server.py (stdio JSON-RPC; tools/list == this tool list)",
|
|
511
|
+
"contract": "python3 scripts/_contract.py --json | ffmpeg-skill contract --json",
|
|
512
|
+
"doctor": "python3 scripts/_contract.py doctor --json | ffmpeg-skill doctor --json",
|
|
513
|
+
},
|
|
514
|
+
"not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"],
|
|
515
|
+
},
|
|
516
|
+
"requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0", "node": ">=16 (npx installer only)"},
|
|
517
|
+
"execution": {
|
|
518
|
+
"shell": False,
|
|
519
|
+
"arbitrary_executables": False,
|
|
520
|
+
"subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH",
|
|
521
|
+
"network": False,
|
|
522
|
+
"input_mutation": False,
|
|
523
|
+
},
|
|
524
|
+
"invocation": {
|
|
525
|
+
"structured": {
|
|
526
|
+
"canonical": True,
|
|
527
|
+
"transports": ["cli", "mcp"],
|
|
528
|
+
"argument_mapping": {
|
|
529
|
+
"positional": "listed in input_schema.positional, in order; array values expand to several arguments",
|
|
530
|
+
"options": "key -> --key with '_' replaced by '-'; booleans are flags; arrays repeat the flag; input_schema.properties[key].cli lists the accepted spellings",
|
|
531
|
+
"exceptions": "per tool in mcp.argument_exceptions (key -> flag), e.g. output -> -o, loudness.lufs -> -I, graphics.count_from -> --from",
|
|
532
|
+
"json": "--json is appended for every tool except look and probe (probe prints JSON by default)",
|
|
533
|
+
},
|
|
534
|
+
},
|
|
535
|
+
"raw_argv": {"canonical": False, "transports": ["mcp"], "note": "MCP tools also accept {\"argv\": [...]} for CLI compatibility; it is still bound to the named script, never a shell"},
|
|
536
|
+
},
|
|
537
|
+
"roles": ROLES,
|
|
538
|
+
"idempotency_hints": IDEMPOTENCY,
|
|
539
|
+
"verification_policy": {
|
|
540
|
+
"probe_first": "run ffmpeg-skill/probe on every input before planning",
|
|
541
|
+
"verify_last": "run the tools named in each ToolSpec.verification after it wrote an artifact",
|
|
542
|
+
"visual": "when requires_visual_verification is true, run ffmpeg-skill/look on the output and inspect the PNG",
|
|
543
|
+
"audio_only": "audio-only inputs and audio-only tools never need ffmpeg-skill/look",
|
|
544
|
+
"check_rows": "ffmpeg-skill/check rows carry kind=format (fix) or kind=judgement (decide with the user)",
|
|
545
|
+
},
|
|
546
|
+
"json_output": {
|
|
547
|
+
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
548
|
+
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"error\": {\"kind\": ..., \"message\": ...}} when --json was given", "stderr": "human-readable message"},
|
|
549
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error", "missing_tool": "ffmpeg or ffprobe not on PATH"},
|
|
550
|
+
},
|
|
551
|
+
"capabilities": caps,
|
|
552
|
+
"tools": tools,
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def main() -> int:
|
|
557
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
558
|
+
ap.add_argument("command", nargs="?", choices=["contract", "doctor"], default="contract")
|
|
559
|
+
ap.add_argument("--json", action="store_true", help="JSON on stdout (the contract is always JSON)")
|
|
560
|
+
ap.add_argument("--static", action="store_true", help="omit environment detection (available / missing capabilities)")
|
|
561
|
+
args = ap.parse_args()
|
|
562
|
+
if args.command == "doctor":
|
|
563
|
+
d = doctor()
|
|
564
|
+
if args.json:
|
|
565
|
+
print(json.dumps(d, indent=2, sort_keys=True))
|
|
566
|
+
else:
|
|
567
|
+
print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
|
|
568
|
+
print(f"available: {', '.join(d['available'])}")
|
|
569
|
+
print(f"missing required: {', '.join(d['missing']) or 'none'}")
|
|
570
|
+
print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
|
|
571
|
+
return 0 if d["ok"] else 1
|
|
572
|
+
print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
|
|
573
|
+
return 0
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
if __name__ == "__main__":
|
|
577
|
+
sys.exit(main())
|
package/scripts/caption.py
CHANGED
|
@@ -23,7 +23,7 @@ import sys
|
|
|
23
23
|
from pathlib import Path
|
|
24
24
|
from typing import List, Optional, Tuple
|
|
25
25
|
|
|
26
|
-
from _common import 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, info, parse_time, probe, run, x264_args
|
|
26
|
+
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, info, parse_time, probe, run, x264_args
|
|
27
27
|
|
|
28
28
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
29
29
|
|
|
@@ -352,7 +352,8 @@ def main() -> int:
|
|
|
352
352
|
srt_path = os.path.splitext(out_guess)[0] + ".srt"
|
|
353
353
|
else:
|
|
354
354
|
srt_path = os.path.splitext(args.text)[0] + ".srt"
|
|
355
|
-
|
|
355
|
+
if not STATE.dry_run:
|
|
356
|
+
write_srt(cues, srt_path)
|
|
356
357
|
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
357
358
|
if not args.input:
|
|
358
359
|
print(srt_path)
|
|
@@ -382,7 +383,7 @@ def main() -> int:
|
|
|
382
383
|
if args.fonts_dir:
|
|
383
384
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
384
385
|
else:
|
|
385
|
-
if not srt_path or not os.path.exists(srt_path):
|
|
386
|
+
if not srt_path or (not os.path.exists(srt_path) and not (STATE.dry_run and args.text)):
|
|
386
387
|
die(f"SRT file not found: {srt_path}")
|
|
387
388
|
style = [
|
|
388
389
|
f"FontName={args.font}",
|
package/scripts/report.py
CHANGED
|
@@ -19,7 +19,7 @@ import tempfile
|
|
|
19
19
|
from pathlib import Path
|
|
20
20
|
from typing import Any, Dict, List, Optional
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, die, emit, info, probe
|
|
22
|
+
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
23
23
|
|
|
24
24
|
HERE = Path(__file__).resolve().parent
|
|
25
25
|
|
|
@@ -150,8 +150,11 @@ def main() -> int:
|
|
|
150
150
|
.foot{color:var(--ink2);font-size:12px;margin-top:36px;border-top:1px solid var(--line);padding-top:10px}
|
|
151
151
|
"""
|
|
152
152
|
doc = f"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{html.escape(title)}</title><style>{css}</style></head><body><div class='wrap'>{''.join(parts)}</div></body></html>"
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
if STATE.dry_run:
|
|
154
|
+
info(f"wrote {output}") # printed as "[dry-run] would write"; nothing is written
|
|
155
|
+
else:
|
|
156
|
+
Path(output).write_text(doc, encoding="utf-8")
|
|
157
|
+
info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
|
|
155
158
|
emit(None, report=output, check=chk)
|
|
156
159
|
if not args.json:
|
|
157
160
|
print(output)
|