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.
Files changed (44) hide show
  1. package/README.md +296 -118
  2. package/SKILL.md +39 -7
  3. package/bin/install.js +10 -2
  4. package/mcp/server.py +40 -44
  5. package/package.json +19 -5
  6. package/scripts/_common.py +55 -7
  7. package/scripts/_contract.py +757 -0
  8. package/scripts/audio.py +100 -7
  9. package/scripts/caption.py +10 -3
  10. package/scripts/check.py +21 -7
  11. package/scripts/color.py +68 -4
  12. package/scripts/cut.py +83 -9
  13. package/scripts/export.py +15 -6
  14. package/scripts/fit.py +17 -3
  15. package/scripts/join.py +74 -3
  16. package/scripts/multicam.py +10 -0
  17. package/scripts/overlay.py +5 -0
  18. package/scripts/render.py +13 -2
  19. package/scripts/report.py +6 -3
  20. package/scripts/scenes.py +15 -3
  21. package/scripts/sync.py +8 -0
  22. package/mcp/__pycache__/server.cpython-311.pyc +0 -0
  23. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  24. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  25. package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
  26. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  27. package/scripts/__pycache__/check.cpython-311.pyc +0 -0
  28. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  29. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  30. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  31. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  32. package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
  33. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  34. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  35. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  36. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  37. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  38. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  39. package/scripts/__pycache__/render.cpython-311.pyc +0 -0
  40. package/scripts/__pycache__/report.cpython-311.pyc +0 -0
  41. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  42. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  43. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  44. 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; arguments are passed as an argv list
5
- or as a flat object of flags. Results are the script's --json output.
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
- 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"]}
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 ("look", "probe") and "--json" not in argv and "--help" not in argv:
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
- pos = POSITIONAL.get(name, ["input"])
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 ("look", "probe") and "--json" not in argv and "--help" not in argv:
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
- 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
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(' Pass either')[0]}")
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.8.5",
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
- "keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
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"
@@ -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 inside an ffmpeg filter graph option value."""
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(":", "\\:").replace("'", "\\'").replace(",", "\\,").replace("[", "\\[").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