ffmpeg-skill 0.6.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/README.md +13 -0
- package/SKILL.md +27 -1
- package/bin/install.js +6 -2
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +178 -0
- package/package.json +3 -2
- package/scripts/__pycache__/_common.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/batch.py +177 -0
- package/scripts/caption.py +78 -3
package/README.md
CHANGED
|
@@ -18,6 +18,9 @@ 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.
|
|
22
|
+
- **Batch / watch folder** — one recipe over a whole shoot with a content-hash cache; re-runs only touch what changed.
|
|
23
|
+
- **Optional local transcription** — `caption.py --transcribe` uses whisper.cpp / faster-whisper / openai-whisper when present; never required.
|
|
21
24
|
- **Brand kit** — one `brand.json` (fonts, colours, logo, safe margins, caption style) applied by captions, overlays, graphics and projects.
|
|
22
25
|
- **Motion graphics without assets** — lower-thirds, title cards, chapter chips, progress bars, countdowns and corner bugs drawn by FFmpeg.
|
|
23
26
|
- **HTML delivery report** — before/after contact sheets, media facts, loudness, compliance and the commands run, in one file.
|
|
@@ -99,6 +102,8 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
99
102
|
| `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision, colour space, rotation, audio channels as JSON; `--analyze` flags Log footage |
|
|
100
103
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
|
|
101
104
|
| `render.py` | Render a whole edit from `project.json`; `--init`, `--dry-run`, `--stop-after` |
|
|
105
|
+
| `batch.py` | Apply a step recipe or render project to a folder, cached, optional watch |
|
|
106
|
+
| `mcp/server.py` | MCP server exposing all scripts as tools (stdio JSON-RPC) |
|
|
102
107
|
| `graphics.py` | Lower-third, title, chapter, progress, countdown, bug templates (brand colours) |
|
|
103
108
|
| `report.py` | Single-file HTML delivery report with sheets, facts, loudness, compliance, commands |
|
|
104
109
|
| `scenes.py` | Scene changes, audio peaks, highlight proposals and per-scene sheet |
|
|
@@ -119,6 +124,14 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
119
124
|
|
|
120
125
|
All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stderr message on failure.
|
|
121
126
|
|
|
127
|
+
## MCP
|
|
128
|
+
|
|
129
|
+
```json
|
|
130
|
+
{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["/Users/you/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
`python3 mcp/server.py --list` prints the tools; `--call probe '{"inputs": ["a.mp4"]}'` runs one from the shell.
|
|
134
|
+
|
|
122
135
|
## Requirements
|
|
123
136
|
|
|
124
137
|
- FFmpeg 5.0+ with `libx264`, `libx265`, `libass`, `prores_ks` and `libzimg` (for `color.py --to-sdr`); the default builds from Homebrew, apt and gyan.dev include all of them
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ffmpeg-skill
|
|
3
|
-
description: Professional video editing with local FFmpeg — declarative project rendering, brand kits, motion-graphics templates (lower-thirds, titles, countdowns), HTML delivery reports, scene detection and highlight picks, delivery compliance checks, cut, silence removal, transitions, multicam, captions (animated/karaoke timed to speech), fit to duration/aspect, audio sync with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs, audio clean-up and ducking, loudness, overlays, platform exports, frame inspection and a real-footage verification kit; Python stdlib scripts, no cloud or API keys.
|
|
3
|
+
description: Professional video editing with local FFmpeg — MCP server, batch folders, declarative project rendering, brand kits, motion-graphics templates (lower-thirds, titles, countdowns), HTML delivery reports, scene detection and highlight picks, delivery compliance checks, cut, silence removal, transitions, multicam, captions (animated/karaoke timed to speech), fit to duration/aspect, audio sync with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs, audio clean-up and ducking, loudness, overlays, platform exports, frame inspection and a real-footage verification kit; Python stdlib scripts, no cloud or API keys.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
@@ -75,6 +75,8 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
75
75
|
| "add a lower third with my name", "title card", "countdown intro", "progress bar" | `graphics.py input.mp4 --template lower-third --name "..." --title "..." --start 2 --end 8` |
|
|
76
76
|
| "use our brand fonts/colours/logo" | pass `--brand brand.json` to caption/overlay/graphics, or `"brand"` in project.json |
|
|
77
77
|
| "send me a summary of what you did" | `report.py --before raw.mov --after final.mp4 --platform youtube -o report.html` |
|
|
78
|
+
| "do this to every file in the folder", "process the whole shoot" | `batch.py FOLDER --recipe batch.json` (steps or a render project; cached) |
|
|
79
|
+
| "transcribe it and caption it" | `caption.py input.mp4 --transcribe --animate pop --karaoke` (needs a local whisper; otherwise `--text`) |
|
|
78
80
|
| "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
|
|
79
81
|
| "it's an iPhone Dolby Vision clip and players show it wrong" | `color.py clip.mov --to-sdr` or `color.py clip.mov --strip-dovi` (keep HDR, drop the DV layer) |
|
|
80
82
|
| "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
|
|
@@ -176,6 +178,30 @@ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podca
|
|
|
176
178
|
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
177
179
|
step before reporting a deliverable; fix FAILs, mention WARNs.
|
|
178
180
|
|
|
181
|
+
### batch.py — same recipe over a folder, cached
|
|
182
|
+
```
|
|
183
|
+
batch.py FOLDER --recipe batch.json [--force] [--watch SECONDS] [--json]
|
|
184
|
+
```
|
|
185
|
+
`batch.json` holds either `steps` (a list of script argv with `{in}`/`{out}`
|
|
186
|
+
placeholders, chained) or `project` (a render project applied per file).
|
|
187
|
+
Outputs land in `output_dir` with `suffix`; a content-hash cache skips files
|
|
188
|
+
already done with the same recipe. Use `--dry-run` to preview the plan.
|
|
189
|
+
|
|
190
|
+
### caption.py --transcribe — optional local speech-to-text
|
|
191
|
+
If `whisper-cli` (whisper.cpp), `faster-whisper` or `whisper` is installed,
|
|
192
|
+
`caption.py input.mp4 --transcribe [--language ja] [--model base]` writes the
|
|
193
|
+
SRT from the audio and burns it (combine with `--animate pop --karaoke`).
|
|
194
|
+
Nothing is downloaded and nothing is required: without an engine it prints
|
|
195
|
+
install hints and the user can supply `--text` cues instead. Always tell the
|
|
196
|
+
user which engine was used, and treat the transcript as a draft to review.
|
|
197
|
+
|
|
198
|
+
### MCP server — the toolkit for any MCP client
|
|
199
|
+
`python3 mcp/server.py` speaks MCP over stdio; each script is a tool taking
|
|
200
|
+
named args (flags without dashes, underscores for hyphens) or `argv`. Config
|
|
201
|
+
for Claude Desktop / Claude Code:
|
|
202
|
+
`{"mcpServers": {"ffmpeg-skill": {"command": "python3", "args": ["~/.claude/skills/ffmpeg-skill/mcp/server.py"]}}}`.
|
|
203
|
+
Inside this skill, call the scripts directly; the server is for other hosts.
|
|
204
|
+
|
|
179
205
|
### graphics.py — motion-graphics templates
|
|
180
206
|
```
|
|
181
207
|
graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
|
package/bin/install.js
CHANGED
|
@@ -22,7 +22,7 @@ const { spawnSync } = require('child_process');
|
|
|
22
22
|
|
|
23
23
|
const SKILL_NAME = 'ffmpeg-skill';
|
|
24
24
|
const ROOT = path.resolve(__dirname, '..');
|
|
25
|
-
const PAYLOAD = ['SKILL.md', 'scripts'];
|
|
25
|
+
const PAYLOAD = ['SKILL.md', 'scripts', 'mcp'];
|
|
26
26
|
|
|
27
27
|
const args = process.argv.slice(2);
|
|
28
28
|
const has = (flag) => args.includes(flag);
|
|
@@ -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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: 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.",
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
7
7
|
"author": "kajisho5",
|
|
@@ -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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/batch.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Apply the same recipe to every file in a folder, with a content-hash cache
|
|
3
|
+
so re-runs only process what changed. A recipe is a list of script steps;
|
|
4
|
+
{in} and {out} are substituted, and the output of one step feeds the next.
|
|
5
|
+
|
|
6
|
+
Recipe (batch.json):
|
|
7
|
+
{
|
|
8
|
+
"glob": "*.mp4",
|
|
9
|
+
"output_dir": "out",
|
|
10
|
+
"suffix": "_final",
|
|
11
|
+
"steps": [
|
|
12
|
+
["silence.py", "{in}", "--threshold", "-38", "-o", "{out}"],
|
|
13
|
+
["loudness.py", "{in}", "-o", "{out}"],
|
|
14
|
+
["export.py", "{in}", "--preset", "youtube", "-o", "{out}"]
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
or use a render project for every file: {"project": "project.json", "clip_key": 0}
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
python3 batch.py ~/Footage --recipe batch.json
|
|
21
|
+
python3 batch.py ~/Footage --recipe batch.json --dry-run
|
|
22
|
+
python3 batch.py ~/Footage --recipe batch.json --force # ignore the cache
|
|
23
|
+
python3 batch.py ~/Footage --recipe batch.json --watch 30 # poll the folder every 30 s
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Dict, List
|
|
34
|
+
|
|
35
|
+
from _common import STATE, add_common, apply_common, die, emit, info
|
|
36
|
+
|
|
37
|
+
HERE = Path(__file__).resolve().parent
|
|
38
|
+
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def file_key(path: Path) -> str:
|
|
42
|
+
st = path.stat()
|
|
43
|
+
h = hashlib.sha1()
|
|
44
|
+
h.update(f"{path.name}|{st.st_size}|{int(st.st_mtime)}".encode())
|
|
45
|
+
with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
|
|
46
|
+
h.update(fh.read(1 << 20))
|
|
47
|
+
if st.st_size > 2 << 20:
|
|
48
|
+
fh.seek(-(1 << 20), os.SEEK_END)
|
|
49
|
+
h.update(fh.read(1 << 20))
|
|
50
|
+
return h.hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
54
|
+
return hashlib.sha1(json.dumps(recipe, sort_keys=True).encode()).hexdigest()[:12]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_step(argv: List[str]) -> bool:
|
|
58
|
+
cmd = [sys.executable, str(HERE / argv[0])] + argv[1:]
|
|
59
|
+
if STATE["fast"]:
|
|
60
|
+
cmd.append("--fast")
|
|
61
|
+
if STATE["dry_run"]:
|
|
62
|
+
cmd.append("--dry-run")
|
|
63
|
+
info(" → " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
64
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
|
|
72
|
+
suffix = recipe.get("suffix", "_out")
|
|
73
|
+
final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
|
|
74
|
+
final = outdir / f"{src.stem}{suffix}.{final_ext}"
|
|
75
|
+
t0 = time.time()
|
|
76
|
+
if recipe.get("project"):
|
|
77
|
+
proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
|
|
78
|
+
idx = int(recipe.get("clip_key", 0))
|
|
79
|
+
proj.setdefault("clips", [{}])
|
|
80
|
+
while len(proj["clips"]) <= idx:
|
|
81
|
+
proj["clips"].append({})
|
|
82
|
+
proj["clips"][idx]["src"] = str(src.resolve())
|
|
83
|
+
proj["output"] = str(final.resolve())
|
|
84
|
+
pj = work / f"{src.stem}_project.json"
|
|
85
|
+
pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
|
|
86
|
+
ok = run_step(["render.py", str(pj)])
|
|
87
|
+
else:
|
|
88
|
+
steps = recipe.get("steps") or []
|
|
89
|
+
if not steps:
|
|
90
|
+
die("recipe needs steps or project")
|
|
91
|
+
cur = str(src)
|
|
92
|
+
ok = True
|
|
93
|
+
for i, step in enumerate(steps):
|
|
94
|
+
last = i == len(steps) - 1
|
|
95
|
+
out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
|
|
96
|
+
argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
|
|
97
|
+
if not run_step(argv):
|
|
98
|
+
ok = False
|
|
99
|
+
break
|
|
100
|
+
cur = out
|
|
101
|
+
return {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
106
|
+
ap.add_argument("folder")
|
|
107
|
+
ap.add_argument("--recipe", required=True, help="batch.json")
|
|
108
|
+
ap.add_argument("--force", action="store_true", help="ignore the cache and redo everything")
|
|
109
|
+
ap.add_argument("--watch", type=float, help="keep polling the folder every N seconds")
|
|
110
|
+
ap.add_argument("--work", help="work directory for intermediates (default: <output_dir>/.work)")
|
|
111
|
+
add_common(ap)
|
|
112
|
+
args = ap.parse_args()
|
|
113
|
+
apply_common(args)
|
|
114
|
+
|
|
115
|
+
folder = Path(args.folder)
|
|
116
|
+
if not folder.is_dir():
|
|
117
|
+
die(f"not a folder: {folder}")
|
|
118
|
+
try:
|
|
119
|
+
recipe = json.loads(Path(args.recipe).read_text(encoding="utf-8"))
|
|
120
|
+
except (OSError, ValueError) as exc:
|
|
121
|
+
die(f"cannot read recipe: {exc}")
|
|
122
|
+
if recipe.get("project") and not os.path.isabs(recipe["project"]):
|
|
123
|
+
recipe["project"] = str((Path(args.recipe).resolve().parent / recipe["project"]))
|
|
124
|
+
outdir = Path(recipe.get("output_dir") or (folder / "out"))
|
|
125
|
+
if not outdir.is_absolute():
|
|
126
|
+
outdir = folder / outdir
|
|
127
|
+
work = Path(args.work) if args.work else outdir / ".work"
|
|
128
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
cache_path = outdir / ".ffskill_cache.json"
|
|
131
|
+
cache: Dict[str, Any] = {}
|
|
132
|
+
if cache_path.exists() and not args.force:
|
|
133
|
+
try:
|
|
134
|
+
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
135
|
+
except ValueError:
|
|
136
|
+
cache = {}
|
|
137
|
+
rkey = recipe_key(recipe)
|
|
138
|
+
glob = recipe.get("glob") or "*"
|
|
139
|
+
|
|
140
|
+
def one_pass() -> List[Dict[str, Any]]:
|
|
141
|
+
results = []
|
|
142
|
+
files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
143
|
+
for src in files:
|
|
144
|
+
key = f"{file_key(src)}:{rkey}"
|
|
145
|
+
hit = cache.get(key)
|
|
146
|
+
if hit and Path(hit.get("output", "")).exists() and not args.force:
|
|
147
|
+
info(f"skip (cached) {src.name}")
|
|
148
|
+
results.append({**hit, "cached": True})
|
|
149
|
+
continue
|
|
150
|
+
info(f"=== {src.name}")
|
|
151
|
+
r = process(src, recipe, outdir, work)
|
|
152
|
+
results.append(r)
|
|
153
|
+
if r["ok"] and not STATE["dry_run"]:
|
|
154
|
+
cache[key] = r
|
|
155
|
+
cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
results = one_pass()
|
|
159
|
+
if args.watch:
|
|
160
|
+
info(f"watching {folder} every {args.watch:g}s (Ctrl-C to stop)")
|
|
161
|
+
try:
|
|
162
|
+
while True:
|
|
163
|
+
time.sleep(args.watch)
|
|
164
|
+
results = one_pass()
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
pass
|
|
167
|
+
done = sum(1 for r in results if r["ok"])
|
|
168
|
+
info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
|
|
169
|
+
emit(None, results=results, processed=done, total=len(results))
|
|
170
|
+
if not args.json:
|
|
171
|
+
for r in results:
|
|
172
|
+
print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
|
|
173
|
+
return 0 if done == len(results) else 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
sys.exit(main())
|
package/scripts/caption.py
CHANGED
|
@@ -21,7 +21,7 @@ import os
|
|
|
21
21
|
import re
|
|
22
22
|
import sys
|
|
23
23
|
from pathlib import Path
|
|
24
|
-
from typing import List, Tuple
|
|
24
|
+
from typing import List, Optional, Tuple
|
|
25
25
|
|
|
26
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
|
|
27
27
|
|
|
@@ -60,6 +60,71 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
60
60
|
return cues
|
|
61
61
|
|
|
62
62
|
|
|
63
|
+
def transcribe(video: str, out_srt: str, language: Optional[str], model: str) -> List[Tuple[float, float, str]]:
|
|
64
|
+
"""Optional local ASR bridge. Tries, in order: whisper-cli / main (whisper.cpp), faster-whisper (python),
|
|
65
|
+
whisper (openai-whisper CLI). Produces an SRT with word timings where the engine supports it.
|
|
66
|
+
No engine installed -> clear error with install hints; the skill never depends on one."""
|
|
67
|
+
import shutil
|
|
68
|
+
import subprocess
|
|
69
|
+
import tempfile
|
|
70
|
+
from _common import require_tool
|
|
71
|
+
ffmpeg = require_tool("ffmpeg")
|
|
72
|
+
tmpdir = tempfile.mkdtemp(prefix="ffskill_asr_")
|
|
73
|
+
wav = os.path.join(tmpdir, "audio.wav")
|
|
74
|
+
subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-y", "-i", video, "-vn", "-ac", "1", "-ar", "16000", "-c:a", "pcm_s16le", wav], check=True)
|
|
75
|
+
# 1. whisper.cpp
|
|
76
|
+
cli = shutil.which("whisper-cli") or shutil.which("whisper-cpp") or shutil.which("main")
|
|
77
|
+
if cli and (shutil.which("whisper-cli") or shutil.which("whisper-cpp")):
|
|
78
|
+
model_path = model
|
|
79
|
+
if not os.path.exists(model_path):
|
|
80
|
+
for cand in (os.path.expanduser(f"~/.cache/whisper.cpp/ggml-{model}.bin"), f"models/ggml-{model}.bin", f"/usr/local/share/whisper/ggml-{model}.bin"):
|
|
81
|
+
if os.path.exists(cand):
|
|
82
|
+
model_path = cand
|
|
83
|
+
break
|
|
84
|
+
base = os.path.join(tmpdir, "out")
|
|
85
|
+
cmd = [cli, "-m", model_path, "-f", wav, "-osrt", "-of", base]
|
|
86
|
+
if language:
|
|
87
|
+
cmd += ["-l", language]
|
|
88
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
89
|
+
if proc.returncode == 0 and os.path.exists(base + ".srt"):
|
|
90
|
+
info(f"transcribed with whisper.cpp ({os.path.basename(cli)}, model {os.path.basename(model_path)})")
|
|
91
|
+
cues = parse_srt(base + ".srt")
|
|
92
|
+
write_srt(cues, out_srt)
|
|
93
|
+
return cues
|
|
94
|
+
info("whisper.cpp found but failed: " + (proc.stderr.strip().splitlines() or ["?"])[-1][:200])
|
|
95
|
+
# 2. faster-whisper (python package)
|
|
96
|
+
try:
|
|
97
|
+
from faster_whisper import WhisperModel # type: ignore
|
|
98
|
+
m = WhisperModel(model, device="cpu", compute_type="int8")
|
|
99
|
+
segments, _ = m.transcribe(wav, language=language, word_timestamps=False)
|
|
100
|
+
cues = [(seg.start, seg.end, seg.text.strip()) for seg in segments if seg.text.strip()]
|
|
101
|
+
if cues:
|
|
102
|
+
info("transcribed with faster-whisper")
|
|
103
|
+
write_srt(cues, out_srt)
|
|
104
|
+
return cues
|
|
105
|
+
except ImportError:
|
|
106
|
+
pass
|
|
107
|
+
# 3. openai-whisper CLI
|
|
108
|
+
if shutil.which("whisper"):
|
|
109
|
+
cmd = ["whisper", wav, "--model", model, "--output_format", "srt", "--output_dir", tmpdir]
|
|
110
|
+
if language:
|
|
111
|
+
cmd += ["--language", language]
|
|
112
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
113
|
+
srt = os.path.join(tmpdir, "audio.srt")
|
|
114
|
+
if proc.returncode == 0 and os.path.exists(srt):
|
|
115
|
+
info("transcribed with openai-whisper")
|
|
116
|
+
cues = parse_srt(srt)
|
|
117
|
+
write_srt(cues, out_srt)
|
|
118
|
+
return cues
|
|
119
|
+
die("no local speech-to-text engine found for --transcribe.\n"
|
|
120
|
+
"Install one (all run offline):\n"
|
|
121
|
+
" whisper.cpp: brew install whisper-cpp (then download a model: ggml-base.bin)\n"
|
|
122
|
+
" faster-whisper: pip install faster-whisper\n"
|
|
123
|
+
" openai-whisper: pip install openai-whisper\n"
|
|
124
|
+
"Or write the cues by hand with --text cues.txt (see format above).")
|
|
125
|
+
return []
|
|
126
|
+
|
|
127
|
+
|
|
63
128
|
def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
64
129
|
cues: List[Tuple[float, float, str]] = []
|
|
65
130
|
block: List[str] = []
|
|
@@ -217,6 +282,9 @@ def main() -> int:
|
|
|
217
282
|
src.add_argument("--srt", help="SRT file to burn")
|
|
218
283
|
src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
|
|
219
284
|
src.add_argument("--text", help="plain text cue file to convert into SRT (see format above)")
|
|
285
|
+
src.add_argument("--transcribe", action="store_true", help="generate the SRT from the audio with a local speech-to-text engine if one is installed (whisper-cli / whisper / faster-whisper); never required")
|
|
286
|
+
src.add_argument("--language", help="language code for --transcribe (e.g. en, ja); default auto")
|
|
287
|
+
src.add_argument("--model", default="base", help="whisper model name/path for --transcribe (default base)")
|
|
220
288
|
src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
|
|
221
289
|
src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
|
|
222
290
|
src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
|
|
@@ -263,10 +331,17 @@ def main() -> int:
|
|
|
263
331
|
args.karaoke = True
|
|
264
332
|
if args.brand and brand.get("font_file") and not args.fonts_dir:
|
|
265
333
|
args.fonts_dir = str(Path(brand["font_file"]).parent)
|
|
266
|
-
if not (args.srt or args.ass or args.text):
|
|
267
|
-
die("give one of --srt, --ass or --
|
|
334
|
+
if not (args.srt or args.ass or args.text or args.transcribe):
|
|
335
|
+
die("give one of --srt, --ass, --text or --transcribe")
|
|
268
336
|
|
|
269
337
|
srt_path = args.srt
|
|
338
|
+
if args.transcribe:
|
|
339
|
+
if not args.input:
|
|
340
|
+
die("--transcribe needs the input video")
|
|
341
|
+
srt_path = args.write_srt or os.path.splitext(args.input)[0] + ".srt"
|
|
342
|
+
cues = transcribe(args.input, srt_path, args.language, args.model)
|
|
343
|
+
info(f"wrote {srt_path} ({len(cues)} cues)")
|
|
344
|
+
args.text = None
|
|
270
345
|
if args.text:
|
|
271
346
|
cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
|
|
272
347
|
srt_path = args.write_srt or os.path.splitext(args.text)[0] + ".srt"
|