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 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) copyRecursive(path.join(ROOT, item), path.join(t.dir, item));
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;
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.6.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"
@@ -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())
@@ -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 --text")
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"