ffmpeg-skill 0.7.0 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/install.js +5 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +178 -0
- package/package.json +2 -1
package/bin/install.js
CHANGED
|
@@ -88,7 +88,11 @@ for (const t of targets) {
|
|
|
88
88
|
}
|
|
89
89
|
fs.rmSync(t.dir, { recursive: true, force: true });
|
|
90
90
|
fs.mkdirSync(t.dir, { recursive: true });
|
|
91
|
-
for (const item of PAYLOAD)
|
|
91
|
+
for (const item of PAYLOAD) {
|
|
92
|
+
const src = path.join(ROOT, item);
|
|
93
|
+
if (!fs.existsSync(src)) { if (item !== 'SKILL.md' && item !== 'scripts') continue; throw new Error(`missing ${item} in package`); }
|
|
94
|
+
copyRecursive(src, path.join(t.dir, item));
|
|
95
|
+
}
|
|
92
96
|
console.log(`installed ${t.label}: ${t.dir}`);
|
|
93
97
|
} catch (err) {
|
|
94
98
|
failed = true;
|
|
Binary file
|
package/mcp/server.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""ffmpeg-skill as an MCP server (stdio, JSON-RPC 2.0) — standard library only.
|
|
3
|
+
|
|
4
|
+
Every script in ../scripts becomes a tool; arguments are passed as an argv list
|
|
5
|
+
or as a flat object of flags. Results are the script's --json output.
|
|
6
|
+
|
|
7
|
+
Run:
|
|
8
|
+
python3 mcp/server.py # stdio transport
|
|
9
|
+
Claude Desktop / Claude Code config example:
|
|
10
|
+
{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["/path/to/ffmpeg-skill/mcp/server.py"]}}}
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any, Dict, List
|
|
18
|
+
|
|
19
|
+
HERE = Path(__file__).resolve().parent
|
|
20
|
+
SCRIPTS = HERE.parent / "scripts"
|
|
21
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
22
|
+
|
|
23
|
+
TOOLS: Dict[str, str] = {
|
|
24
|
+
"probe": "Inspect a media file: duration, fps/VFR, resolution, codecs, HDR, rotation, audio. Args: inputs (list), analyze (bool), compact (bool).",
|
|
25
|
+
"cut": "Cut a range or several segments, lossless when possible. Args: input, start, end, duration, segments, accurate, output.",
|
|
26
|
+
"fit": "Fit to a duration (speed/trim) and/or aspect (pad/crop). Args: input, duration, method, aspect, fit, width, fps, smooth, output.",
|
|
27
|
+
"caption": "Burn SRT/ASS or timed text; animated/karaoke styles; brand. Args: input, srt, ass, text, animate, karaoke, font, size, position, brand, output.",
|
|
28
|
+
"overlay": "Composite a logo/image/text with timing and fade. Args: input, image, text, logo, position, start, end, fade, opacity, scale, brand, output.",
|
|
29
|
+
"graphics": "Motion-graphics templates: lower-third, title, chapter, progress, countdown, bug. Args: input, template, name, title, subtitle, start, end, brand, output.",
|
|
30
|
+
"sync": "Detect offset between two recordings by audio, optional drift fix. Args: reference, second, fix_drift, replace_audio, trim_second, output.",
|
|
31
|
+
"multicam": "Align N cameras/recorders and switch between them. Args: inputs (list), switch, auto, audio, fix_drift, output.",
|
|
32
|
+
"audio": "Denoise/voice chain, music bed with ducking, fades, downmix, replace. Args: input, voice, denoise, music, duck, music_volume, fade_in, fade_out, downmix, replace, output.",
|
|
33
|
+
"loudness": "Two-pass EBU R128 normalisation. Args: input, lufs, tp, measure_only, output.",
|
|
34
|
+
"silence": "Remove dead air / list silences. Args: input, threshold, min_silence, margin, list, edl, output.",
|
|
35
|
+
"join": "Concatenate clips with transitions, normalising size/fps/audio. Args: inputs (list), transition, duration, width, height, fps, output.",
|
|
36
|
+
"color": "HDR→SDR tone mapping, LUTs, retag, strip Dolby Vision. Args: input, to_sdr, lut, retag, strip_dovi, tonemap, output.",
|
|
37
|
+
"export": "Platform presets: youtube, youtube4k, reels, x, prores, h265, gif. Args: input, preset, fit, output.",
|
|
38
|
+
"check": "Pre-delivery compliance per platform. Args: input, platform, no_loudness.",
|
|
39
|
+
"scenes": "Scene changes, audio peaks, highlight proposals. Args: input, highlights, target, edl, sheet.",
|
|
40
|
+
"look": "Contact sheet / frames / before-after PNG for visual checks. Args: input, at (list), tiles, compare, output.",
|
|
41
|
+
"render": "Render a whole edit from project.json. Args: project, fast, stop_after, init.",
|
|
42
|
+
"verify": "Run the toolchain on real files and report PASS/FAIL. Args: paths (list), quick, report.",
|
|
43
|
+
"report": "HTML delivery report. Args: after, before, platform, commands, notes, title, output.",
|
|
44
|
+
}
|
|
45
|
+
POSITIONAL = {"probe": ["inputs"], "sync": ["reference", "second"], "multicam": ["inputs"], "join": ["inputs"], "verify": ["paths"], "render": ["project"]}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
49
|
+
if isinstance(args.get("argv"), list):
|
|
50
|
+
argv = [str(a) for a in args["argv"]]
|
|
51
|
+
if name not in ("look", "probe") and "--json" not in argv and "--help" not in argv:
|
|
52
|
+
argv.append("--json")
|
|
53
|
+
return argv
|
|
54
|
+
argv: List[str] = []
|
|
55
|
+
args = dict(args)
|
|
56
|
+
pos = POSITIONAL.get(name, ["input"])
|
|
57
|
+
for key in pos:
|
|
58
|
+
val = args.pop(key, None)
|
|
59
|
+
if val is None:
|
|
60
|
+
continue
|
|
61
|
+
if isinstance(val, list):
|
|
62
|
+
argv += [str(v) for v in val]
|
|
63
|
+
else:
|
|
64
|
+
argv.append(str(val))
|
|
65
|
+
for key, val in args.items():
|
|
66
|
+
if val is None or val is False:
|
|
67
|
+
continue
|
|
68
|
+
flag = "--" + key.replace("_", "-")
|
|
69
|
+
if key == "output":
|
|
70
|
+
flag = "-o"
|
|
71
|
+
if key == "lufs" and name == "loudness":
|
|
72
|
+
flag = "-I"
|
|
73
|
+
if val is True:
|
|
74
|
+
argv.append(flag)
|
|
75
|
+
elif isinstance(val, list):
|
|
76
|
+
for v in val:
|
|
77
|
+
argv += [flag, str(v)]
|
|
78
|
+
else:
|
|
79
|
+
argv += [flag, str(val)]
|
|
80
|
+
if name not in ("look", "probe") and "--json" not in argv and "--help" not in argv:
|
|
81
|
+
argv.append("--json")
|
|
82
|
+
return argv
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
86
|
+
script = SCRIPTS / f"{name}.py"
|
|
87
|
+
if not script.exists():
|
|
88
|
+
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
89
|
+
argv = build_argv(name, args or {})
|
|
90
|
+
proc = subprocess.run([sys.executable, str(script)] + argv, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
91
|
+
stdout = proc.stdout.strip()
|
|
92
|
+
text = stdout
|
|
93
|
+
structured = None
|
|
94
|
+
try:
|
|
95
|
+
structured = json.loads(stdout) if stdout.startswith("{") or stdout.startswith("[") else None
|
|
96
|
+
except ValueError:
|
|
97
|
+
structured = None
|
|
98
|
+
if proc.returncode != 0:
|
|
99
|
+
err = proc.stderr.strip().splitlines()
|
|
100
|
+
tail = "\n".join(err[-12:])
|
|
101
|
+
return {"isError": True, "content": [{"type": "text", "text": f"{name} failed (exit {proc.returncode})\n{tail}"}]}
|
|
102
|
+
if structured is None:
|
|
103
|
+
text = stdout or "\n".join(proc.stderr.strip().splitlines()[-5:])
|
|
104
|
+
result: Dict[str, Any] = {"content": [{"type": "text", "text": text}]}
|
|
105
|
+
if structured is not None:
|
|
106
|
+
result["structuredContent"] = structured if isinstance(structured, dict) else {"result": structured}
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def tool_list() -> List[Dict[str, Any]]:
|
|
111
|
+
out = []
|
|
112
|
+
for name, desc in TOOLS.items():
|
|
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
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def handle(req: Dict[str, Any]) -> Dict[str, Any]:
|
|
122
|
+
method = req.get("method")
|
|
123
|
+
params = req.get("params") or {}
|
|
124
|
+
if method == "initialize":
|
|
125
|
+
return {"protocolVersion": PROTOCOL_VERSION, "capabilities": {"tools": {}}, "serverInfo": {"name": "ffmpeg-skill", "version": version()}}
|
|
126
|
+
if method == "tools/list":
|
|
127
|
+
return {"tools": tool_list()}
|
|
128
|
+
if method == "tools/call":
|
|
129
|
+
return call_tool(params.get("name", ""), params.get("arguments") or {})
|
|
130
|
+
if method == "ping":
|
|
131
|
+
return {}
|
|
132
|
+
raise KeyError(method)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def version() -> str:
|
|
136
|
+
try:
|
|
137
|
+
return json.loads((HERE.parent / "package.json").read_text())["version"]
|
|
138
|
+
except Exception:
|
|
139
|
+
return "0"
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def main() -> int:
|
|
143
|
+
if "--list" in sys.argv:
|
|
144
|
+
for t in tool_list():
|
|
145
|
+
print(f"{t['name']:10s} {t['description'].split(' Pass either')[0]}")
|
|
146
|
+
return 0
|
|
147
|
+
if "--call" in sys.argv: # debugging helper: --call NAME '{"input": "..."}'
|
|
148
|
+
i = sys.argv.index("--call")
|
|
149
|
+
name = sys.argv[i + 1]
|
|
150
|
+
args = json.loads(sys.argv[i + 2]) if len(sys.argv) > i + 2 else {}
|
|
151
|
+
print(json.dumps(call_tool(name, args), indent=2))
|
|
152
|
+
return 0
|
|
153
|
+
stdin = sys.stdin.buffer
|
|
154
|
+
stdout = sys.stdout.buffer
|
|
155
|
+
for raw in stdin:
|
|
156
|
+
line = raw.decode("utf-8", errors="replace").strip()
|
|
157
|
+
if not line:
|
|
158
|
+
continue
|
|
159
|
+
try:
|
|
160
|
+
req = json.loads(line)
|
|
161
|
+
except ValueError:
|
|
162
|
+
continue
|
|
163
|
+
if "id" not in req: # notification
|
|
164
|
+
continue
|
|
165
|
+
try:
|
|
166
|
+
result = handle(req)
|
|
167
|
+
resp = {"jsonrpc": "2.0", "id": req["id"], "result": result}
|
|
168
|
+
except KeyError as exc:
|
|
169
|
+
resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32601, "message": f"method not found: {exc}"}}
|
|
170
|
+
except Exception as exc: # noqa: BLE001
|
|
171
|
+
resp = {"jsonrpc": "2.0", "id": req["id"], "error": {"code": -32000, "message": str(exc)}}
|
|
172
|
+
stdout.write((json.dumps(resp) + "\n").encode("utf-8"))
|
|
173
|
+
stdout.flush()
|
|
174
|
+
return 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
if __name__ == "__main__":
|
|
178
|
+
sys.exit(main())
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
"files": [
|
|
18
18
|
"bin/",
|
|
19
19
|
"scripts/",
|
|
20
|
+
"mcp/",
|
|
20
21
|
"SKILL.md",
|
|
21
22
|
"README.md",
|
|
22
23
|
"LICENSE"
|