ffmpeg-skill 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,12 @@ 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.
24
+ - **Brand kit** — one `brand.json` (fonts, colours, logo, safe margins, caption style) applied by captions, overlays, graphics and projects.
25
+ - **Motion graphics without assets** — lower-thirds, title cards, chapter chips, progress bars, countdowns and corner bugs drawn by FFmpeg.
26
+ - **HTML delivery report** — before/after contact sheets, media facts, loudness, compliance and the commands run, in one file.
21
27
  - **Scene detection and highlight picks** — find cuts and loud moments, get a 60-second digest proposal as a cut list.
22
28
  - **Delivery checks** — PASS/FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, broadcast and podcast specs, with the fix for each failure.
23
29
  - **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
@@ -96,6 +102,10 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
96
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 |
97
103
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
98
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) |
107
+ | `graphics.py` | Lower-third, title, chapter, progress, countdown, bug templates (brand colours) |
108
+ | `report.py` | Single-file HTML delivery report with sheets, facts, loudness, compliance, commands |
99
109
  | `scenes.py` | Scene changes, audio peaks, highlight proposals and per-scene sheet |
100
110
  | `check.py` | Pre-delivery compliance per platform (duration, aspect, codec, colour, loudness, size) |
101
111
  | `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
@@ -114,6 +124,14 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
114
124
 
115
125
  All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stderr message on failure.
116
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
+
117
135
  ## Requirements
118
136
 
119
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, 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
@@ -72,6 +72,11 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
72
72
  | "make a 60 s highlight from this hour", "find the good bits" | `scenes.py long.mp4 --highlights 6 --target 60 --edl picks.txt` → `cut.py --segments` |
73
73
  | "is this OK to upload?", "check it meets the Reels spec" | `check.py final.mp4 --platform reels` |
74
74
  | "set it up so I can tweak and re-render", "several changes to the same edit" | `render.py --init project.json`, edit, `render.py project.json` |
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
+ | "use our brand fonts/colours/logo" | pass `--brand brand.json` to caption/overlay/graphics, or `"brand"` in project.json |
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`) |
75
80
  | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
76
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) |
77
82
  | "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
@@ -149,7 +154,7 @@ render.py --init project.json # starter file
149
154
  render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
150
155
  ```
151
156
  Stages: clips (cut, optional speed) → join (transition) → silence → fit →
152
- captions → overlays → audio → loudness → export → check. Keys mirror the
157
+ captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
153
158
  CLI flags of each script (see the docstring). Use it whenever an edit has
154
159
  more than two steps or the user is likely to ask for changes: edit the JSON,
155
160
  re-render, and the result is reproducible. `--dry-run --json` prints the
@@ -173,6 +178,59 @@ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podca
173
178
  PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
174
179
  step before reporting a deliverable; fix FAILs, mention WARNs.
175
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
+
205
+ ### graphics.py — motion-graphics templates
206
+ ```
207
+ graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
208
+ [--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [-o OUT]
209
+ ```
210
+ Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
211
+ the frame's short side; colours, font and safe margin come from `--brand`.
212
+ Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
213
+
214
+ ### brand.json — one file for fonts, colours, logo, margins
215
+ ```json
216
+ {"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
217
+ "colors": {"primary": "FF6A00", "text": "FFFFFF", "outline": "000000", "background": "0B1D2A"},
218
+ "logo": "logo.png", "logo_position": "top-right", "logo_scale": 160, "logo_opacity": 0.9,
219
+ "safe_margin": 48, "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
220
+ ```
221
+ `caption.py --brand`, `overlay.py --brand --logo`, `graphics.py --brand`, and
222
+ `"brand": "brand.json"` in a render project. Explicit flags still win. When a
223
+ user mentions brand guidelines, colours, "our font" or a logo, ask for or
224
+ write a brand.json once and reuse it across every output.
225
+
226
+ ### report.py — HTML delivery report
227
+ ```
228
+ report.py --after FINAL [--before SOURCE] [--platform youtube] [--commands cmds.txt] [--notes notes.md] [--title T] [--no-sheets] [-o report.html]
229
+ ```
230
+ One self-contained HTML: before/after facts and contact sheets, loudness,
231
+ compliance table with fixes, commands. Produce it for any multi-step job and
232
+ hand the path to the user together with the numbers.
233
+
176
234
  ### multicam.py — align several cameras and switch between them
177
235
  ```
178
236
  multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
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);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.5.0",
4
- "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: declarative project rendering, 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.0",
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",
@@ -417,6 +417,55 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
417
417
  }
418
418
 
419
419
 
420
+ BRAND_DEFAULTS: Dict[str, Any] = {
421
+ "font": "DejaVu Sans",
422
+ "font_file": None,
423
+ "colors": {"primary": "FFD200", "text": "FFFFFF", "outline": "000000", "background": "101418", "accent": "1E6F8E"},
424
+ "logo": None,
425
+ "logo_position": "top-right",
426
+ "logo_scale": 160,
427
+ "logo_opacity": 0.9,
428
+ "safe_margin": 48,
429
+ "caption": {"size": 26, "position": "bottom", "animate": "pop", "karaoke": False, "bold": True, "outline": 2},
430
+ "loudness": {"lufs": -14, "tp": -1},
431
+ }
432
+
433
+
434
+ def load_brand(path: Optional[str]) -> Dict[str, Any]:
435
+ """Load brand.json (fonts, colours, logo, safe margins, caption defaults); missing keys fall back to defaults."""
436
+ import copy
437
+ brand = copy.deepcopy(BRAND_DEFAULTS)
438
+ if not path:
439
+ return brand
440
+ if not os.path.exists(path):
441
+ die(f"brand file not found: {path}")
442
+ try:
443
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
444
+ except ValueError as exc:
445
+ die(f"brand file is not valid JSON: {exc}")
446
+ base = Path(path).resolve().parent
447
+ for k, v in data.items():
448
+ if isinstance(v, dict) and isinstance(brand.get(k), dict):
449
+ brand[k].update(v)
450
+ else:
451
+ brand[k] = v
452
+ for key in ("logo", "font_file"):
453
+ if brand.get(key) and not os.path.isabs(brand[key]):
454
+ brand[key] = str(base / brand[key])
455
+ brand["_path"] = str(path)
456
+ return brand
457
+
458
+
459
+ def color_hex(value: str) -> str:
460
+ """Normalise '#ffd200' / 'ffd200' / '0xFFD200' to 'FFD200'."""
461
+ v = str(value).strip().lstrip("#")
462
+ if v.lower().startswith("0x"):
463
+ v = v[2:]
464
+ if len(v) != 6:
465
+ die(f"colour must be RRGGBB, got '{value}'")
466
+ return v.upper()
467
+
468
+
420
469
  def print_json(obj: Any) -> None:
421
470
  sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
422
471
 
@@ -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())
@@ -20,9 +20,10 @@ import argparse
20
20
  import os
21
21
  import re
22
22
  import sys
23
- from typing import List, Tuple
23
+ from pathlib import Path
24
+ from typing import List, Optional, Tuple
24
25
 
25
- from _common import 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
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
26
27
 
27
28
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
28
29
 
@@ -59,6 +60,71 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
59
60
  return cues
60
61
 
61
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
+
62
128
  def parse_srt(path: str) -> List[Tuple[float, float, str]]:
63
129
  cues: List[Tuple[float, float, str]] = []
64
130
  block: List[str] = []
@@ -216,25 +282,29 @@ def main() -> int:
216
282
  src.add_argument("--srt", help="SRT file to burn")
217
283
  src.add_argument("--ass", help="ASS file to burn (styles inside the file are used)")
218
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)")
219
288
  src.add_argument("--write-srt", help="where to save the generated SRT (default: <text>.srt)")
220
289
  src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
221
290
  src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
222
291
  sty = ap.add_argument_group("style (SRT only)")
223
- sty.add_argument("--font", default="DejaVu Sans", help="font family, e.g. 'Noto Sans CJK JP' for Japanese")
292
+ sty.add_argument("--brand", help="brand.json: font, colours, caption size/position/animation defaults")
293
+ sty.add_argument("--font", default=None, help="font family, e.g. 'Noto Sans CJK JP' for Japanese (default DejaVu Sans or brand font)")
224
294
  sty.add_argument("--fonts-dir", help="directory with extra .ttf/.otf files")
225
- sty.add_argument("--size", type=int, default=24, help="font size in ASS points (relative to a 288p script height, scales automatically)")
226
- sty.add_argument("--color", default="FFFFFF", help="text colour RRGGBB (default FFFFFF)")
227
- sty.add_argument("--outline-color", default="000000", help="outline colour RRGGBB")
228
- sty.add_argument("--outline", type=float, default=2.0, help="outline width (default 2)")
295
+ sty.add_argument("--size", type=int, default=None, help="font size in ASS points (relative to a 288p script height, scales automatically)")
296
+ sty.add_argument("--color", default=None, help="text colour RRGGBB (default FFFFFF or brand text colour)")
297
+ sty.add_argument("--outline-color", default=None, help="outline colour RRGGBB")
298
+ sty.add_argument("--outline", type=float, default=None, help="outline width (default 2)")
229
299
  sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
230
300
  sty.add_argument("--bold", action="store_true")
231
- sty.add_argument("--position", choices=sorted(ALIGN), default="bottom", help="on-screen placement (default bottom)")
301
+ sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
232
302
  sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
233
303
  sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
234
304
  anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
235
- anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default="none", help="per-cue entrance animation")
305
+ anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default=None, help="per-cue entrance animation (default none, or brand caption.animate)")
236
306
  anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
237
- anim.add_argument("--highlight-color", default="FFD200", help="karaoke fill colour RRGGBB (default FFD200)")
307
+ anim.add_argument("--highlight-color", default=None, help="karaoke fill colour RRGGBB (default FFD200 or brand primary)")
238
308
  anim.add_argument("--karaoke-timing", choices=["even", "energy"], default="energy",
239
309
  help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
240
310
  anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
@@ -245,10 +315,33 @@ def main() -> int:
245
315
  args = ap.parse_args()
246
316
  apply_common(args)
247
317
 
248
- if not (args.srt or args.ass or args.text):
249
- die("give one of --srt, --ass or --text")
318
+ brand = load_brand(args.brand)
319
+ bc, bcap = brand["colors"], brand["caption"]
320
+ args.font = args.font or brand.get("font") or "DejaVu Sans"
321
+ args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
322
+ args.color = color_hex(args.color or bc.get("text", "FFFFFF"))
323
+ args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
324
+ args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
325
+ args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
326
+ args.animate = args.animate or (bcap.get("animate", "none") if args.brand else "none")
327
+ args.highlight_color = color_hex(args.highlight_color or bc.get("primary", "FFD200"))
328
+ if args.brand and bcap.get("bold") and not args.bold:
329
+ args.bold = True
330
+ if args.brand and bcap.get("karaoke") and not args.karaoke:
331
+ args.karaoke = True
332
+ if args.brand and brand.get("font_file") and not args.fonts_dir:
333
+ args.fonts_dir = str(Path(brand["font_file"]).parent)
334
+ if not (args.srt or args.ass or args.text or args.transcribe):
335
+ die("give one of --srt, --ass, --text or --transcribe")
250
336
 
251
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
252
345
  if args.text:
253
346
  cues = parse_text_cues(args.text, args.auto_seconds, args.gap)
254
347
  srt_path = args.write_srt or os.path.splitext(args.text)[0] + ".srt"
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env python3
2
+ """Motion-graphics templates rendered with drawbox/drawtext expressions —
3
+ no After Effects, no image assets, brand colours from brand.json.
4
+
5
+ Templates:
6
+ lower-third name + title bar sliding in from the left (--name, --title)
7
+ title centred title card with optional subtitle, fade in/out (--title, --subtitle)
8
+ chapter small chip in a corner (--title), e.g. "Part 2 — Setup"
9
+ progress thin progress bar along the bottom that fills over the clip (or --start/--end)
10
+ countdown big numbers counting down from --from to 0 (--start/--end define the window)
11
+ bug persistent text bug (--title) in a corner, e.g. "@handle" or "LIVE"
12
+
13
+ Examples:
14
+ python3 graphics.py talk.mp4 --template lower-third --name "Ada Lovelace" --title "Analyst" --start 2 --end 8
15
+ python3 graphics.py talk.mp4 --template title --title "Episode 12" --subtitle "The math of video" --start 0 --end 4
16
+ python3 graphics.py talk.mp4 --template progress --brand brand.json
17
+ python3 graphics.py intro.mp4 --template countdown --from 5 --start 1 --end 6
18
+ python3 graphics.py clip.mp4 --template chapter --title "Part 2 — Setup" --position top-left --start 0 --end 5
19
+ """
20
+ import argparse
21
+ import sys
22
+ from typing import List, Optional
23
+
24
+ from _common import aac_args, add_common, apply_common, cfr_args, color_hex, default_output, die, emit, escape_drawtext, escape_filter_path, ffmpeg_base, info, load_brand, parse_time, probe, run, video_args
25
+
26
+ TEMPLATES = ["lower-third", "title", "chapter", "progress", "countdown", "bug"]
27
+
28
+
29
+ def ff_color(hex_rgb: str, alpha: float = 1.0) -> str:
30
+ return f"0x{color_hex(hex_rgb)}@{alpha:g}"
31
+
32
+
33
+ def font_opts(brand: dict, font: Optional[str], font_file: Optional[str]) -> str:
34
+ if font_file or brand.get("font_file"):
35
+ return f"fontfile={escape_filter_path(font_file or brand['font_file'])}"
36
+ return f"font='{font or brand.get('font', 'DejaVu Sans')}'"
37
+
38
+
39
+ def main() -> int:
40
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
41
+ ap.add_argument("input")
42
+ ap.add_argument("-o", "--output", help="output file (default: <name>_gfx.<ext>)")
43
+ ap.add_argument("--template", choices=TEMPLATES, required=True)
44
+ ap.add_argument("--brand", help="brand.json for colours, font, safe margin")
45
+ ap.add_argument("--name", help="lower-third: name line")
46
+ ap.add_argument("--title", help="title / chapter / bug text, or lower-third second line")
47
+ ap.add_argument("--subtitle", help="title: smaller second line")
48
+ ap.add_argument("--from", dest="count_from", type=int, default=5, help="countdown start number (default 5)")
49
+ ap.add_argument("--start", help="show from (default 0)")
50
+ ap.add_argument("--end", help="hide after (default end of clip)")
51
+ ap.add_argument("--position", choices=["top-left", "top-right", "bottom-left", "bottom-right"], default=None, help="corner for chapter/bug (default bottom-left / top-right)")
52
+ ap.add_argument("--primary", help="override brand primary colour RRGGBB")
53
+ ap.add_argument("--text-color", help="override text colour RRGGBB")
54
+ ap.add_argument("--font")
55
+ ap.add_argument("--font-file")
56
+ ap.add_argument("--scale", type=float, default=1.0, help="size multiplier (default 1)")
57
+ ap.add_argument("--crf", type=int, default=18)
58
+ ap.add_argument("--preset", default="medium")
59
+ add_common(ap)
60
+ args = ap.parse_args()
61
+ apply_common(args)
62
+
63
+ brand = load_brand(args.brand)
64
+ primary = color_hex(args.primary or brand["colors"]["primary"])
65
+ text_c = color_hex(args.text_color or brand["colors"]["text"])
66
+ bg = color_hex(brand["colors"].get("background", "101418"))
67
+ margin = int(brand.get("safe_margin", 48))
68
+ fo = font_opts(brand, args.font, args.font_file)
69
+
70
+ meta = probe(args.input)
71
+ if not meta.get("video"):
72
+ die("input has no video stream")
73
+ W, H = meta["video"]["width"], meta["video"]["height"]
74
+ if meta["video"].get("rotation") in (90, -90, 270, -270):
75
+ W, H = H, W
76
+ dur = meta.get("duration") or 0.0
77
+ s = parse_time(args.start) if args.start else 0.0
78
+ e = parse_time(args.end) if args.end else dur
79
+ if e <= s:
80
+ die("--end must be after --start")
81
+ en = f"enable='between(t,{s:.3f},{e:.3f})'"
82
+ base = min(W, H) * args.scale # scale everything from the short side
83
+ filters: List[str] = []
84
+ fade_a = f"if(lt(t,{s:.3f}+0.3),(t-{s:.3f})/0.3,if(gt(t,{e:.3f}-0.3),({e:.3f}-t)/0.3,1))"
85
+
86
+ extra_inputs: List[str] = []
87
+ fc: List[str] = [] # filter_complex chains (used by templates that need animated boxes)
88
+ if args.template == "lower-third":
89
+ if not args.name:
90
+ die("lower-third needs --name")
91
+ h1 = int(base * 0.055)
92
+ h2 = int(base * 0.038)
93
+ pad = int(base * 0.02)
94
+ bar_h = h1 + (h2 + pad if args.title else 0) + pad * 2
95
+ bar_w = int(base * 0.62)
96
+ y0 = H - margin - bar_h
97
+ # slide in from the left over 0.4 s, slide out over 0.3 s (overlay evaluates x per frame)
98
+ x_expr = f"if(lt(t,{s:.3f}+0.4),-{bar_w}+({bar_w}+{margin})*((t-{s:.3f})/0.4),if(gt(t,{e:.3f}-0.3),{margin}-({bar_w}+{margin})*(1-({e:.3f}-t)/0.3),{margin}))"
99
+ fc.append(f"color=c=0x{bg}@0.85:s={bar_w}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[bar]")
100
+ fc.append(f"color=c=0x{primary}:s={int(base * 0.012)}x{bar_h}:r={meta['video'].get('fps') or 30:g},format=rgba[acc]")
101
+ fc.append(f"[0:v][bar]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v1]")
102
+ fc.append(f"[v1][acc]overlay=x='{x_expr}':y={y0}:{en}:eof_action=pass[v2]")
103
+ tx = f"({x_expr})+{int(base * 0.035)}"
104
+ chain = f"drawtext=text='{escape_drawtext(args.name)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x='{tx}':y={y0 + pad}:{en}"
105
+ if args.title:
106
+ chain += f",drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x='{tx}':y={y0 + pad + h1 + pad // 2}:{en}"
107
+ fc.append(f"[v2]{chain}[vout]")
108
+
109
+ elif args.template == "title":
110
+ if not args.title:
111
+ die("title needs --title")
112
+ h1 = int(base * 0.11)
113
+ h2 = int(base * 0.045)
114
+ filters.append(f"drawbox=x=0:y=0:w=iw:h=ih:color={ff_color(bg, 0.55)}:t=fill:{en}")
115
+ filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={h1}:fontcolor={ff_color(text_c)}:x=(w-text_w)/2:y=(h-text_h)/2-{h2 if args.subtitle else 0}:alpha='{fade_a}':{en}")
116
+ filters.append(f"drawbox=x=(iw-{int(base * 0.12)})/2:y=(ih)/2+{h1 // 2 + (0 if args.subtitle else 0)}:w={int(base * 0.12)}:h={max(2, int(base * 0.006))}:color={ff_color(primary)}:t=fill:{en}")
117
+ if args.subtitle:
118
+ filters.append(f"drawtext=text='{escape_drawtext(args.subtitle)}':{fo}:fontsize={h2}:fontcolor={ff_color(primary)}:x=(w-text_w)/2:y=(h-text_h)/2+{h1 // 2 + int(base * 0.03)}:alpha='{fade_a}':{en}")
119
+
120
+ elif args.template in ("chapter", "bug"):
121
+ if not args.title:
122
+ die(f"{args.template} needs --title")
123
+ pos = args.position or ("bottom-left" if args.template == "chapter" else "top-right")
124
+ fs = int(base * (0.04 if args.template == "chapter" else 0.032))
125
+ padx, pady = int(fs * 0.6), int(fs * 0.35)
126
+ xe = f"{margin}" if "left" in pos else f"w-text_w-{margin}"
127
+ ye = f"{margin}" if "top" in pos else f"h-text_h-{margin}"
128
+ box_color = ff_color(primary if args.template == "chapter" else bg, 0.9 if args.template == "chapter" else 0.7)
129
+ txt_color = ff_color(bg if args.template == "chapter" else text_c)
130
+ filters.append(f"drawtext=text='{escape_drawtext(args.title)}':{fo}:fontsize={fs}:fontcolor={txt_color}:x={xe}:y={ye}:box=1:boxcolor={box_color}:boxborderw={pady}|{padx}:alpha='{fade_a}':{en}")
131
+
132
+ elif args.template == "progress":
133
+ h = max(3, int(base * 0.008))
134
+ fps = meta['video'].get('fps') or 30
135
+ fc.append(f"color=c=0x{primary}:s={W}x{h}:r={fps:g},format=rgba[pb]")
136
+ fc.append(f"[0:v]drawbox=x=0:y=ih-{h}:w=iw:h={h}:color={ff_color(bg, 0.5)}:t=fill:{en}[v1]")
137
+ fc.append(f"[v1][pb]overlay=x='-w+w*min(1,max(0,(t-{s:.3f})/{e - s:.3f}))':y={H - h}:{en}:eof_action=pass[vout]")
138
+
139
+ elif args.template == "countdown":
140
+ n = args.count_from
141
+ seg = (e - s) / (n + 1)
142
+ fs = int(base * 0.32)
143
+ for k in range(n, -1, -1):
144
+ ks = s + (n - k) * seg
145
+ ke = ks + seg
146
+ pulse = f"1-0.15*min(1,(t-{ks:.3f})/{seg * 0.5:.3f})"
147
+ filters.append(f"drawtext=text='{k}':{fo}:fontsize={fs}:fontcolor={ff_color(primary)}:borderw={max(2, fs // 40)}:bordercolor={ff_color(bg)}:x=(w-text_w)/2:y=(h-text_h)/2:alpha='{pulse}':enable='between(t,{ks:.3f},{ke:.3f})'")
148
+
149
+ output = args.output or default_output(args.input, "gfx")
150
+ cmd = ffmpeg_base() + ["-i", args.input]
151
+ if fc:
152
+ cmd += ["-filter_complex", ";".join(fc), "-map", "[vout]", "-map", "0:a:0?"]
153
+ else:
154
+ cmd += ["-vf", ",".join(filters), "-map", "0:v:0", "-map", "0:a:0?"]
155
+ cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
156
+ cmd += aac_args() if meta.get("audio") else ["-an"]
157
+ cmd.append(output)
158
+ run(cmd)
159
+ r = probe(output)
160
+ info(f"wrote {output} ({r['duration']:.3f}s, {args.template})")
161
+ emit(output, template=args.template)
162
+ return 0
163
+
164
+
165
+ if __name__ == "__main__":
166
+ sys.exit(main())
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List, Optional
17
17
 
18
- from _common import video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
18
+ from _common import load_brand, video_args, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
19
19
 
20
20
  POS = {
21
21
  "top-left": ("{m}", "{m}"),
@@ -76,9 +76,11 @@ def main() -> int:
76
76
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
77
77
  ap.add_argument("input")
78
78
  ap.add_argument("-o", "--output", help="output file (default: <name>_overlay.<ext>)")
79
- src = ap.add_mutually_exclusive_group(required=True)
79
+ src = ap.add_mutually_exclusive_group()
80
80
  src.add_argument("--image", help="PNG/JPG (alpha respected) to composite")
81
81
  src.add_argument("--text", help="text to draw (drawtext)")
82
+ src.add_argument("--logo", action="store_true", help="composite the brand logo from --brand (position/scale/opacity from brand.json)")
83
+ ap.add_argument("--brand", help="brand.json (logo, font, colours, safe margin)")
82
84
  ap.add_argument("--position", default="top-right", help="named position or X,Y (default top-right)")
83
85
  ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
84
86
  ap.add_argument("--start", help="show from this time (default: whole video)")
@@ -104,6 +106,26 @@ def main() -> int:
104
106
  args = ap.parse_args()
105
107
  apply_common(args)
106
108
 
109
+ brand = load_brand(args.brand)
110
+ if args.logo:
111
+ if not brand.get("logo"):
112
+ die("--logo needs a brand.json with a 'logo' entry")
113
+ args.image = brand["logo"]
114
+ if args.position == ap.get_default("position"):
115
+ args.position = brand.get("logo_position", "top-right")
116
+ if not args.scale and not args.scale_percent:
117
+ args.scale = int(brand.get("logo_scale", 160))
118
+ if args.opacity == 1.0:
119
+ args.opacity = float(brand.get("logo_opacity", 1.0))
120
+ if not (args.image or args.text):
121
+ die("give --image, --text or --logo")
122
+ if args.brand:
123
+ if args.margin == ap.get_default("margin"):
124
+ args.margin = int(brand.get("safe_margin", args.margin))
125
+ if args.font == ap.get_default("font"):
126
+ args.font = brand.get("font", args.font)
127
+ if not args.font_file and brand.get("font_file"):
128
+ args.font_file = brand["font_file"]
107
129
  meta = probe(args.input)
108
130
  if not meta.get("video"):
109
131
  die("input has no video stream")
package/scripts/render.py CHANGED
@@ -15,8 +15,13 @@ Project format (all keys optional except clips):
15
15
  "transition": {"type": "fade", "duration": 0.5},
16
16
  "silence": {"threshold": -38, "min_silence": 0.8},
17
17
  "captions": {"text": "cues.txt", "srt": null, "animate": "pop", "karaoke": true, "font": "Noto Sans CJK JP", "size": 28, "position": "bottom"},
18
+ "brand": "brand.json",
19
+ "graphics": [
20
+ {"template": "title", "title": "Episode 12", "subtitle": "The math of video", "start": 0, "end": 4},
21
+ {"template": "lower-third", "name": "Ada Lovelace", "title": "Analyst", "start": 5, "end": 11}
22
+ ],
18
23
  "overlays": [
19
- {"image": "logo.png", "position": "top-right", "scale": 160, "opacity": 0.9},
24
+ {"logo": true},
20
25
  {"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
21
26
  ],
22
27
  "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "fade_out": 2},
@@ -27,7 +32,9 @@ Project format (all keys optional except clips):
27
32
  }
28
33
 
29
34
  Stages run in this order: clips (cut) → join → silence → fit → captions →
30
- overlays → audio → loudness → export → check. Missing stages are skipped.
35
+ graphics → overlays → audio → loudness → export → check. Missing stages are
36
+ skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
37
+ colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
31
38
 
32
39
  Examples:
33
40
  python3 render.py --init project.json # write a commented starter project
@@ -53,7 +60,9 @@ TEMPLATE = {
53
60
  "clips": [{"src": "REPLACE_ME.mp4", "in": "0:00", "out": "0:30"}],
54
61
  "transition": {"type": "fade", "duration": 0.5},
55
62
  "silence": None,
63
+ "brand": None,
56
64
  "captions": None,
65
+ "graphics": [],
57
66
  "overlays": [],
58
67
  "audio": None,
59
68
  "loudness": {"lufs": -14, "tp": -1},
@@ -89,7 +98,7 @@ def main() -> int:
89
98
  ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
90
99
  ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
91
100
  ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
92
- ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
101
+ ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "graphics", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
93
102
  add_common(ap)
94
103
  args = ap.parse_args()
95
104
  apply_common(args)
@@ -119,6 +128,7 @@ def main() -> int:
119
128
  work.mkdir(parents=True, exist_ok=True)
120
129
  frame = proj.get("frame") or {}
121
130
  trans = proj.get("transition") or {}
131
+ brand_args: List[str] = ["--brand", rel(proj["brand"])] if proj.get("brand") else []
122
132
  stages_done: List[str] = []
123
133
 
124
134
  # ---- clips
@@ -222,18 +232,37 @@ def main() -> int:
222
232
  for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
223
233
  if cap.get(k):
224
234
  argv.append(flag)
225
- sh("caption.py", *argv)
235
+ sh("caption.py", *(argv + brand_args))
226
236
  current = nxt
227
237
  stages_done.append("captions")
228
238
  if args.stop_after == "captions":
229
239
  emit(current, stages=stages_done)
230
240
  return 0
231
241
 
242
+ # ---- graphics
243
+ for i, g in enumerate(proj.get("graphics") or []):
244
+ nxt = str(work / f"graphics{i:02d}.mp4")
245
+ if not g.get("template"):
246
+ die(f"graphics[{i}] needs a template")
247
+ argv = [current, "-o", nxt, "--template", g["template"]]
248
+ for k, flag in (("name", "--name"), ("title", "--title"), ("subtitle", "--subtitle"), ("start", "--start"), ("end", "--end"), ("position", "--position"), ("from", "--from"), ("scale", "--scale"), ("primary", "--primary"), ("text_color", "--text-color")):
249
+ if g.get(k) is not None:
250
+ argv += [flag, str(g[k])]
251
+ sh("graphics.py", *(argv + brand_args))
252
+ current = nxt
253
+ if "graphics" not in stages_done:
254
+ stages_done.append("graphics")
255
+ if args.stop_after == "graphics":
256
+ emit(current, stages=stages_done)
257
+ return 0
258
+
232
259
  # ---- overlays
233
260
  for i, ov in enumerate(proj.get("overlays") or []):
234
261
  nxt = str(work / f"overlay{i:02d}.mp4")
235
262
  argv = [current, "-o", nxt]
236
- if ov.get("image"):
263
+ if ov.get("logo"):
264
+ argv.append("--logo")
265
+ elif ov.get("image"):
237
266
  argv += ["--image", rel(ov["image"])]
238
267
  elif ov.get("text"):
239
268
  argv += ["--text", ov["text"]]
@@ -244,7 +273,7 @@ def main() -> int:
244
273
  argv += [flag, str(ov[k])]
245
274
  if ov.get("box"):
246
275
  argv.append("--box")
247
- sh("overlay.py", *argv)
276
+ sh("overlay.py", *(argv + brand_args))
248
277
  current = nxt
249
278
  if "overlays" not in stages_done:
250
279
  stages_done.append("overlays")
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env python3
2
+ """Build a single-file HTML delivery report: what went in, what came out,
3
+ before/after contact sheets, loudness, compliance and the exact commands.
4
+ The agent hands this to the user instead of a wall of text.
5
+
6
+ Examples:
7
+ python3 report.py --before raw.mov --after final.mp4 -o report.html
8
+ python3 report.py --after final.mp4 --platform reels --title "Episode 12 — Reels cut" -o report.html
9
+ python3 report.py --before raw.mov --after final.mp4 --commands commands.txt --notes notes.md
10
+ """
11
+ import argparse
12
+ import base64
13
+ import html
14
+ import json
15
+ import os
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from _common import add_common, apply_common, die, emit, info, probe
23
+
24
+ HERE = Path(__file__).resolve().parent
25
+
26
+
27
+ def sheet_b64(path: str, tiles: str = "4x2", width: int = 1200) -> Optional[str]:
28
+ with tempfile.TemporaryDirectory(prefix="ffskill_report_") as tmp:
29
+ png = os.path.join(tmp, "sheet.png")
30
+ proc = subprocess.run([sys.executable, str(HERE / "look.py"), path, "--tiles", tiles, "--width", str(width), "-o", png],
31
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
32
+ if proc.returncode != 0 or not os.path.exists(png):
33
+ return None
34
+ return base64.b64encode(Path(png).read_bytes()).decode("ascii")
35
+
36
+
37
+ def loudness(path: str) -> Dict[str, Any]:
38
+ proc = subprocess.run([sys.executable, str(HERE / "loudness.py"), path, "--measure-only"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
39
+ try:
40
+ d = json.loads(proc.stdout)
41
+ return {"lufs": round(float(d["input_i"]), 1), "tp": round(float(d["input_tp"]), 1), "lra": round(float(d["input_lra"]), 1)}
42
+ except (ValueError, KeyError):
43
+ return {}
44
+
45
+
46
+ def check(path: str, platform: str) -> Optional[Dict[str, Any]]:
47
+ proc = subprocess.run([sys.executable, str(HERE / "check.py"), path, "--platform", platform, "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
48
+ try:
49
+ return json.loads(proc.stdout)
50
+ except ValueError:
51
+ return None
52
+
53
+
54
+ def fmt_dur(sec: Optional[float]) -> str:
55
+ if not sec:
56
+ return "?"
57
+ m, s = divmod(sec, 60)
58
+ h, m = divmod(int(m), 60)
59
+ return f"{h}:{m:02d}:{s:05.2f}" if h else f"{m}:{s:05.2f}"
60
+
61
+
62
+ def media_rows(meta: Dict[str, Any], ld: Dict[str, Any]) -> List[List[str]]:
63
+ v, a = meta.get("video") or {}, meta.get("audio") or {}
64
+ rows = [
65
+ ["Duration", fmt_dur(meta.get("duration"))],
66
+ ["Size", f"{(meta.get('size_bytes') or 0) / 1024 / 1024:.1f} MB"],
67
+ ["Video", f"{v.get('codec')} {v.get('width')}×{v.get('height')} @ {v.get('fps')} fps, {v.get('pix_fmt')}" if v else "none"],
68
+ ["Colour", (f"{v.get('color_primaries')}/{v.get('color_transfer')}" + (f" — {v.get('hdr_format')}" if v.get("hdr") else " (SDR)")) if v else "—"],
69
+ ["Frame rate", ("variable (suspected)" if v.get("variable_frame_rate_suspected") else "constant") if v else "—"],
70
+ ["Audio", f"{a.get('codec')} {a.get('channels')} ch {a.get('sample_rate')} Hz" if a else "none"],
71
+ ]
72
+ if ld:
73
+ rows.append(["Loudness", f"{ld['lufs']} LUFS, TP {ld['tp']} dBTP, LRA {ld['lra']} LU"])
74
+ return rows
75
+
76
+
77
+ def main() -> int:
78
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
79
+ ap.add_argument("--after", required=True, help="the deliverable")
80
+ ap.add_argument("--before", help="the source (optional)")
81
+ ap.add_argument("-o", "--output", help="report path (default: <after>_report.html)")
82
+ ap.add_argument("--title", help="report title")
83
+ ap.add_argument("--platform", help="run check.py for this platform and include the table")
84
+ ap.add_argument("--commands", help="text file with the commands that were run (one per line)")
85
+ ap.add_argument("--notes", help="text/markdown file with notes to include verbatim")
86
+ ap.add_argument("--no-sheets", action="store_true", help="skip contact sheets (faster, smaller)")
87
+ add_common(ap)
88
+ args = ap.parse_args()
89
+ apply_common(args)
90
+
91
+ after = probe(args.after)
92
+ before = probe(args.before) if args.before else None
93
+ ld_after = loudness(args.after) if after.get("audio") else {}
94
+ ld_before = loudness(args.before) if before and before.get("audio") else {}
95
+ chk = check(args.after, args.platform) if args.platform else None
96
+ sheets = {}
97
+ if not args.no_sheets:
98
+ if before and before.get("video"):
99
+ sheets["before"] = sheet_b64(args.before)
100
+ if after.get("video"):
101
+ sheets["after"] = sheet_b64(args.after)
102
+ commands = Path(args.commands).read_text(encoding="utf-8").splitlines() if args.commands else []
103
+ notes = Path(args.notes).read_text(encoding="utf-8") if args.notes else ""
104
+ title = args.title or f"Delivery report — {Path(args.after).name}"
105
+ output = args.output or str(Path(args.after).with_name(Path(args.after).stem + "_report.html"))
106
+
107
+ def table(rows: List[List[str]]) -> str:
108
+ return "<table>" + "".join(f"<tr><th>{html.escape(k)}</th><td>{html.escape(str(v))}</td></tr>" for k, v in rows) + "</table>"
109
+
110
+ parts: List[str] = []
111
+ parts.append(f"<h1>{html.escape(title)}</h1>")
112
+ parts.append(f"<p class='meta'>{html.escape(os.path.abspath(args.after))}</p>")
113
+ cols = []
114
+ if before:
115
+ cols.append(f"<div class='col'><h2>Before</h2><p class='file'>{html.escape(Path(args.before).name)}</p>{table(media_rows(before, ld_before))}"
116
+ + (f"<img src='data:image/png;base64,{sheets['before']}' alt='before contact sheet'>" if sheets.get("before") else "") + "</div>")
117
+ cols.append(f"<div class='col'><h2>After</h2><p class='file'>{html.escape(Path(args.after).name)}</p>{table(media_rows(after, ld_after))}"
118
+ + (f"<img src='data:image/png;base64,{sheets['after']}' alt='after contact sheet'>" if sheets.get("after") else "") + "</div>")
119
+ parts.append("<div class='cols'>" + "".join(cols) + "</div>")
120
+ if chk:
121
+ rows = "".join(
122
+ f"<tr class='{r['status'].lower()}'><td class='st'>{r['status']}</td><td>{html.escape(r['check'])}</td><td>{html.escape(str(r['value']))}</td><td>{html.escape(str(r['expected']))}</td><td>{html.escape(r.get('fix') or '') if r['status'] != 'PASS' else ''}</td></tr>"
123
+ for r in chk["checks"])
124
+ verdict = "READY" if chk.get("ok") else f"{chk.get('failed')} FAIL"
125
+ parts.append(f"<h2>Compliance — {html.escape(args.platform)} <span class='verdict {'ok' if chk.get('ok') else 'bad'}'>{verdict}</span></h2>"
126
+ f"<table class='checks'><tr><th></th><th>check</th><th>value</th><th>expected</th><th>fix</th></tr>{rows}</table>")
127
+ if notes:
128
+ parts.append("<h2>Notes</h2><pre class='notes'>" + html.escape(notes) + "</pre>")
129
+ if commands:
130
+ parts.append("<h2>Commands</h2><pre class='cmd'>" + html.escape("\n".join(commands)) + "</pre>")
131
+ parts.append("<p class='foot'>Generated by ffmpeg-skill · local FFmpeg, no cloud.</p>")
132
+
133
+ css = """
134
+ :root{--bg:#F4F6F8;--paper:#fff;--ink:#161B21;--ink2:#4B5661;--line:#D8DEE4;--ok:#2C8A5B;--warn:#C48519;--bad:#B4362F;--accent:#1E6F8E}
135
+ @media (prefers-color-scheme:dark){:root{--bg:#111518;--paper:#191E23;--ink:#E8ECEF;--ink2:#AEB6BE;--line:#2A3138;--ok:#5CC38C;--warn:#E3A63C;--bad:#F07A73;--accent:#5FB2D4}}
136
+ body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.6 system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans JP",sans-serif}
137
+ .wrap{max-width:1100px;margin:0 auto;padding:32px 20px 60px}
138
+ h1{font-size:26px;margin:0 0 4px}h2{font-size:18px;margin:28px 0 10px}
139
+ .meta{color:var(--ink2);font-size:13px;margin:0 0 20px;word-break:break-all}
140
+ .cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(320px,1fr));gap:16px}
141
+ .col{background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:14px 16px}
142
+ .col h2{margin-top:0}.file{color:var(--ink2);font-size:13px;margin:0 0 8px}
143
+ table{border-collapse:collapse;width:100%;font-size:14px}th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);vertical-align:top}
144
+ th{color:var(--ink2);font-weight:500;width:34%}
145
+ img{max-width:100%;border-radius:4px;margin-top:12px;border:1px solid var(--line)}
146
+ .checks{background:var(--paper);border:1px solid var(--line);border-radius:6px;overflow:hidden}.checks th{width:auto}
147
+ .st{font-weight:700;font-family:ui-monospace,Menlo,monospace}tr.pass .st{color:var(--ok)}tr.warn .st{color:var(--warn)}tr.fail .st{color:var(--bad)}
148
+ .verdict{font-size:13px;padding:2px 8px;border-radius:3px;margin-left:8px;vertical-align:middle}.verdict.ok{background:var(--ok);color:#fff}.verdict.bad{background:var(--bad);color:#fff}
149
+ pre{background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:12px 14px;overflow-x:auto;font-size:12.5px;line-height:1.5}
150
+ .foot{color:var(--ink2);font-size:12px;margin-top:36px;border-top:1px solid var(--line);padding-top:10px}
151
+ """
152
+ doc = f"<!doctype html><html><head><meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'><title>{html.escape(title)}</title><style>{css}</style></head><body><div class='wrap'>{''.join(parts)}</div></body></html>"
153
+ Path(output).write_text(doc, encoding="utf-8")
154
+ info(f"wrote {output} ({os.path.getsize(output) / 1024:.0f} KB)")
155
+ emit(None, report=output, check=chk)
156
+ if not args.json:
157
+ print(output)
158
+ return 0
159
+
160
+
161
+ if __name__ == "__main__":
162
+ sys.exit(main())