ffmpeg-skill 0.8.5 → 0.10.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 +296 -118
- package/SKILL.md +39 -7
- package/bin/install.js +10 -2
- package/mcp/server.py +40 -44
- package/package.json +19 -5
- package/scripts/_common.py +55 -7
- package/scripts/_contract.py +757 -0
- package/scripts/audio.py +100 -7
- package/scripts/caption.py +10 -3
- package/scripts/check.py +21 -7
- package/scripts/color.py +68 -4
- package/scripts/cut.py +83 -9
- package/scripts/export.py +15 -6
- package/scripts/fit.py +17 -3
- package/scripts/join.py +74 -3
- package/scripts/multicam.py +10 -0
- package/scripts/overlay.py +5 -0
- package/scripts/render.py +13 -2
- package/scripts/report.py +6 -3
- package/scripts/scenes.py +15 -3
- package/scripts/sync.py +8 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.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__/check.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__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.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__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
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') };
|
|
@@ -69,7 +77,7 @@ function checkFfmpeg() {
|
|
|
69
77
|
const r = spawnSync('ffmpeg', ['-version'], { encoding: 'utf8' });
|
|
70
78
|
if (r.error || r.status !== 0) {
|
|
71
79
|
console.warn('\n warning: ffmpeg was not found on PATH. The skill needs FFmpeg to run:');
|
|
72
|
-
console.warn(' macOS: brew install ffmpeg');
|
|
80
|
+
console.warn(' macOS: brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)');
|
|
73
81
|
console.warn(' Ubuntu: sudo apt install ffmpeg');
|
|
74
82
|
console.warn(' Windows: winget install Gyan.FFmpeg\n');
|
|
75
83
|
return false;
|
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,8 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agent Skill that
|
|
5
|
-
"keywords": [
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 21 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
|
+
"keywords": [
|
|
6
|
+
"ffmpeg",
|
|
7
|
+
"video",
|
|
8
|
+
"video-editing",
|
|
9
|
+
"video-processing",
|
|
10
|
+
"audio",
|
|
11
|
+
"agent-skill",
|
|
12
|
+
"claude-code",
|
|
13
|
+
"cursor",
|
|
14
|
+
"codex",
|
|
15
|
+
"mcp",
|
|
16
|
+
"skill"
|
|
17
|
+
],
|
|
6
18
|
"license": "MIT",
|
|
7
19
|
"author": "kajisho5",
|
|
8
20
|
"repository": {
|
|
@@ -24,9 +36,11 @@
|
|
|
24
36
|
"LICENSE"
|
|
25
37
|
],
|
|
26
38
|
"scripts": {
|
|
27
|
-
"test": "python3 tests/test_all.py",
|
|
39
|
+
"test": "python3 tests/test_all.py && python3 tests/test_contract.py",
|
|
28
40
|
"release-check": "bash tests/release_check.sh",
|
|
29
|
-
"demo": "bash examples/make_demo.sh"
|
|
41
|
+
"demo": "bash examples/make_demo.sh",
|
|
42
|
+
"contract": "python3 scripts/_contract.py --json",
|
|
43
|
+
"doctor": "python3 scripts/_contract.py doctor"
|
|
30
44
|
},
|
|
31
45
|
"engines": {
|
|
32
46
|
"node": ">=16"
|
package/scripts/_common.py
CHANGED
|
@@ -16,8 +16,18 @@ from fractions import Fraction
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Any, Dict, List, Optional, Sequence
|
|
18
18
|
|
|
19
|
+
# Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
|
|
20
|
+
# arrows). On Windows the console streams default to a legacy code page and raise
|
|
21
|
+
# UnicodeEncodeError; make them UTF-8 with replacement so a --help never crashes on encoding.
|
|
22
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
23
|
+
try:
|
|
24
|
+
if getattr(_stream, "encoding", "").lower().replace("-", "") != "utf8":
|
|
25
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except (AttributeError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
19
29
|
INSTALL_HINTS = {
|
|
20
|
-
"Darwin": " brew install ffmpeg",
|
|
30
|
+
"Darwin": " brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)",
|
|
21
31
|
"Linux": (
|
|
22
32
|
" Debian/Ubuntu: sudo apt install ffmpeg\n"
|
|
23
33
|
" Fedora: sudo dnf install ffmpeg\n"
|
|
@@ -31,8 +41,12 @@ INSTALL_HINTS = {
|
|
|
31
41
|
}
|
|
32
42
|
|
|
33
43
|
|
|
34
|
-
def die(msg: str, code: int = 1) -> "None":
|
|
44
|
+
def die(msg: str, code: int = 1, kind: str = "input") -> "None":
|
|
45
|
+
"""Exit with a message. Under --json also print a machine-readable failure document
|
|
46
|
+
(status: failed) on stdout so callers get the same shape as a success; exit codes are unchanged."""
|
|
35
47
|
sys.stderr.write(f"error: {msg}\n")
|
|
48
|
+
if STATE.json:
|
|
49
|
+
print_json({"status": "failed", "error": {"kind": kind, "message": msg}})
|
|
36
50
|
sys.exit(code)
|
|
37
51
|
|
|
38
52
|
|
|
@@ -53,7 +67,7 @@ def require_tool(name: str) -> str:
|
|
|
53
67
|
die(
|
|
54
68
|
f"'{name}' was not found on PATH.\n"
|
|
55
69
|
f"Install FFmpeg (which includes ffprobe) for {system}:\n{hint}",
|
|
56
|
-
code=127,
|
|
70
|
+
code=127, kind="missing_tool",
|
|
57
71
|
)
|
|
58
72
|
return "" # unreachable
|
|
59
73
|
|
|
@@ -122,7 +136,7 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
122
136
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
123
137
|
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
124
138
|
if STATE.json:
|
|
125
|
-
doc: Dict[str, Any] = {"output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
139
|
+
doc: Dict[str, Any] = {"status": "completed", "output": output, "dry_run": STATE.dry_run, "commands": list(STATE.commands)}
|
|
126
140
|
if output and not STATE.dry_run and os.path.exists(output):
|
|
127
141
|
doc["probe"] = probe(output)
|
|
128
142
|
doc.update(extra)
|
|
@@ -141,7 +155,7 @@ def _is_ffmpeg(cmd: Sequence[str]) -> bool:
|
|
|
141
155
|
|
|
142
156
|
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
143
157
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
144
|
-
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1)
|
|
158
|
+
die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=returncode or 1, kind="ffmpeg")
|
|
145
159
|
|
|
146
160
|
|
|
147
161
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
@@ -318,6 +332,16 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
318
332
|
"sample_rate": _to_int(audio.get("sample_rate")),
|
|
319
333
|
"bitrate": _to_int(audio.get("bit_rate")),
|
|
320
334
|
}
|
|
335
|
+
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
336
|
+
out["audio_streams"] = [{
|
|
337
|
+
"index": n,
|
|
338
|
+
"codec": a.get("codec_name"),
|
|
339
|
+
"channels": _to_int(a.get("channels")),
|
|
340
|
+
"channel_layout": a.get("channel_layout"),
|
|
341
|
+
"sample_rate": _to_int(a.get("sample_rate")),
|
|
342
|
+
"language": (a.get("tags") or {}).get("language"),
|
|
343
|
+
"title": (a.get("tags") or {}).get("title"),
|
|
344
|
+
} for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
|
|
321
345
|
return out
|
|
322
346
|
|
|
323
347
|
|
|
@@ -352,10 +376,21 @@ def fmt_srt_time(seconds: float) -> str:
|
|
|
352
376
|
|
|
353
377
|
|
|
354
378
|
def escape_filter_path(path: str) -> str:
|
|
355
|
-
"""Escape a path for use
|
|
379
|
+
"""Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
|
|
380
|
+
|
|
381
|
+
A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
|
|
382
|
+
on `:`, then the filter's own option parser splits key=value pairs on `:` again. A character that
|
|
383
|
+
must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
|
|
384
|
+
written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
|
|
385
|
+
ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
|
|
386
|
+
Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
|
|
387
|
+
never has to be escaped itself; `'`, `,`, `;`, `[` and `]` are graph-level characters.
|
|
388
|
+
"""
|
|
356
389
|
p = str(Path(path))
|
|
357
390
|
p = p.replace("\\", "/")
|
|
358
|
-
p = p.replace(":", "
|
|
391
|
+
p = p.replace(":", "\\\\:")
|
|
392
|
+
for ch in ("'", ",", ";", "[", "]"):
|
|
393
|
+
p = p.replace(ch, "\\" + ch)
|
|
359
394
|
return p
|
|
360
395
|
|
|
361
396
|
|
|
@@ -432,6 +467,19 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
|
|
|
432
467
|
return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
|
|
433
468
|
|
|
434
469
|
|
|
470
|
+
def is_audio_output(output_path: str) -> bool:
|
|
471
|
+
"""True when the output extension is an audio-only container (.wav, .flac, .mp3, .m4a, .aac, .ogg, .opus).
|
|
472
|
+
|
|
473
|
+
Such a file cannot hold a video stream and, for .wav, cannot hold compressed audio: scripts use
|
|
474
|
+
this to drop the picture (-vn) and to pick the codec from the extension instead of AAC.
|
|
475
|
+
"""
|
|
476
|
+
return os.path.splitext(output_path)[1].lower() in AUDIO_CODECS
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def db_to_linear(db: float) -> float:
|
|
480
|
+
return 10 ** (db / 20.0)
|
|
481
|
+
|
|
482
|
+
|
|
435
483
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
436
484
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
437
485
|
|