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 +18 -0
- package/SKILL.md +60 -2
- package/bin/install.js +1 -1
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/_common.py +49 -0
- package/scripts/batch.py +177 -0
- package/scripts/caption.py +105 -12
- package/scripts/graphics.py +166 -0
- package/scripts/overlay.py +24 -2
- package/scripts/render.py +35 -6
- package/scripts/report.py +162 -0
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.
|
|
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",
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -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
|
|
package/scripts/batch.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Apply the same recipe to every file in a folder, with a content-hash cache
|
|
3
|
+
so re-runs only process what changed. A recipe is a list of script steps;
|
|
4
|
+
{in} and {out} are substituted, and the output of one step feeds the next.
|
|
5
|
+
|
|
6
|
+
Recipe (batch.json):
|
|
7
|
+
{
|
|
8
|
+
"glob": "*.mp4",
|
|
9
|
+
"output_dir": "out",
|
|
10
|
+
"suffix": "_final",
|
|
11
|
+
"steps": [
|
|
12
|
+
["silence.py", "{in}", "--threshold", "-38", "-o", "{out}"],
|
|
13
|
+
["loudness.py", "{in}", "-o", "{out}"],
|
|
14
|
+
["export.py", "{in}", "--preset", "youtube", "-o", "{out}"]
|
|
15
|
+
]
|
|
16
|
+
}
|
|
17
|
+
or use a render project for every file: {"project": "project.json", "clip_key": 0}
|
|
18
|
+
|
|
19
|
+
Examples:
|
|
20
|
+
python3 batch.py ~/Footage --recipe batch.json
|
|
21
|
+
python3 batch.py ~/Footage --recipe batch.json --dry-run
|
|
22
|
+
python3 batch.py ~/Footage --recipe batch.json --force # ignore the cache
|
|
23
|
+
python3 batch.py ~/Footage --recipe batch.json --watch 30 # poll the folder every 30 s
|
|
24
|
+
"""
|
|
25
|
+
import argparse
|
|
26
|
+
import hashlib
|
|
27
|
+
import json
|
|
28
|
+
import os
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import time
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any, Dict, List
|
|
34
|
+
|
|
35
|
+
from _common import STATE, add_common, apply_common, die, emit, info
|
|
36
|
+
|
|
37
|
+
HERE = Path(__file__).resolve().parent
|
|
38
|
+
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def file_key(path: Path) -> str:
|
|
42
|
+
st = path.stat()
|
|
43
|
+
h = hashlib.sha1()
|
|
44
|
+
h.update(f"{path.name}|{st.st_size}|{int(st.st_mtime)}".encode())
|
|
45
|
+
with open(path, "rb") as fh: # first and last MB: cheap and good enough to detect changes
|
|
46
|
+
h.update(fh.read(1 << 20))
|
|
47
|
+
if st.st_size > 2 << 20:
|
|
48
|
+
fh.seek(-(1 << 20), os.SEEK_END)
|
|
49
|
+
h.update(fh.read(1 << 20))
|
|
50
|
+
return h.hexdigest()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
54
|
+
return hashlib.sha1(json.dumps(recipe, sort_keys=True).encode()).hexdigest()[:12]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def run_step(argv: List[str]) -> bool:
|
|
58
|
+
cmd = [sys.executable, str(HERE / argv[0])] + argv[1:]
|
|
59
|
+
if STATE["fast"]:
|
|
60
|
+
cmd.append("--fast")
|
|
61
|
+
if STATE["dry_run"]:
|
|
62
|
+
cmd.append("--dry-run")
|
|
63
|
+
info(" → " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
64
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
65
|
+
if proc.returncode != 0:
|
|
66
|
+
info(" " + "\n ".join(proc.stderr.strip().splitlines()[-4:]))
|
|
67
|
+
return False
|
|
68
|
+
return True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path) -> Dict[str, Any]:
|
|
72
|
+
suffix = recipe.get("suffix", "_out")
|
|
73
|
+
final_ext = recipe.get("ext") or src.suffix.lstrip(".") or "mp4"
|
|
74
|
+
final = outdir / f"{src.stem}{suffix}.{final_ext}"
|
|
75
|
+
t0 = time.time()
|
|
76
|
+
if recipe.get("project"):
|
|
77
|
+
proj = json.loads(Path(recipe["project"]).read_text(encoding="utf-8"))
|
|
78
|
+
idx = int(recipe.get("clip_key", 0))
|
|
79
|
+
proj.setdefault("clips", [{}])
|
|
80
|
+
while len(proj["clips"]) <= idx:
|
|
81
|
+
proj["clips"].append({})
|
|
82
|
+
proj["clips"][idx]["src"] = str(src.resolve())
|
|
83
|
+
proj["output"] = str(final.resolve())
|
|
84
|
+
pj = work / f"{src.stem}_project.json"
|
|
85
|
+
pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
|
|
86
|
+
ok = run_step(["render.py", str(pj)])
|
|
87
|
+
else:
|
|
88
|
+
steps = recipe.get("steps") or []
|
|
89
|
+
if not steps:
|
|
90
|
+
die("recipe needs steps or project")
|
|
91
|
+
cur = str(src)
|
|
92
|
+
ok = True
|
|
93
|
+
for i, step in enumerate(steps):
|
|
94
|
+
last = i == len(steps) - 1
|
|
95
|
+
out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
|
|
96
|
+
argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
|
|
97
|
+
if not run_step(argv):
|
|
98
|
+
ok = False
|
|
99
|
+
break
|
|
100
|
+
cur = out
|
|
101
|
+
return {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main() -> int:
|
|
105
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
106
|
+
ap.add_argument("folder")
|
|
107
|
+
ap.add_argument("--recipe", required=True, help="batch.json")
|
|
108
|
+
ap.add_argument("--force", action="store_true", help="ignore the cache and redo everything")
|
|
109
|
+
ap.add_argument("--watch", type=float, help="keep polling the folder every N seconds")
|
|
110
|
+
ap.add_argument("--work", help="work directory for intermediates (default: <output_dir>/.work)")
|
|
111
|
+
add_common(ap)
|
|
112
|
+
args = ap.parse_args()
|
|
113
|
+
apply_common(args)
|
|
114
|
+
|
|
115
|
+
folder = Path(args.folder)
|
|
116
|
+
if not folder.is_dir():
|
|
117
|
+
die(f"not a folder: {folder}")
|
|
118
|
+
try:
|
|
119
|
+
recipe = json.loads(Path(args.recipe).read_text(encoding="utf-8"))
|
|
120
|
+
except (OSError, ValueError) as exc:
|
|
121
|
+
die(f"cannot read recipe: {exc}")
|
|
122
|
+
if recipe.get("project") and not os.path.isabs(recipe["project"]):
|
|
123
|
+
recipe["project"] = str((Path(args.recipe).resolve().parent / recipe["project"]))
|
|
124
|
+
outdir = Path(recipe.get("output_dir") or (folder / "out"))
|
|
125
|
+
if not outdir.is_absolute():
|
|
126
|
+
outdir = folder / outdir
|
|
127
|
+
work = Path(args.work) if args.work else outdir / ".work"
|
|
128
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
cache_path = outdir / ".ffskill_cache.json"
|
|
131
|
+
cache: Dict[str, Any] = {}
|
|
132
|
+
if cache_path.exists() and not args.force:
|
|
133
|
+
try:
|
|
134
|
+
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
135
|
+
except ValueError:
|
|
136
|
+
cache = {}
|
|
137
|
+
rkey = recipe_key(recipe)
|
|
138
|
+
glob = recipe.get("glob") or "*"
|
|
139
|
+
|
|
140
|
+
def one_pass() -> List[Dict[str, Any]]:
|
|
141
|
+
results = []
|
|
142
|
+
files = sorted(p for p in folder.glob(glob) if p.is_file() and p.suffix.lower() in MEDIA_EXT and outdir not in p.parents)
|
|
143
|
+
for src in files:
|
|
144
|
+
key = f"{file_key(src)}:{rkey}"
|
|
145
|
+
hit = cache.get(key)
|
|
146
|
+
if hit and Path(hit.get("output", "")).exists() and not args.force:
|
|
147
|
+
info(f"skip (cached) {src.name}")
|
|
148
|
+
results.append({**hit, "cached": True})
|
|
149
|
+
continue
|
|
150
|
+
info(f"=== {src.name}")
|
|
151
|
+
r = process(src, recipe, outdir, work)
|
|
152
|
+
results.append(r)
|
|
153
|
+
if r["ok"] and not STATE["dry_run"]:
|
|
154
|
+
cache[key] = r
|
|
155
|
+
cache_path.write_text(json.dumps(cache, indent=2), encoding="utf-8")
|
|
156
|
+
return results
|
|
157
|
+
|
|
158
|
+
results = one_pass()
|
|
159
|
+
if args.watch:
|
|
160
|
+
info(f"watching {folder} every {args.watch:g}s (Ctrl-C to stop)")
|
|
161
|
+
try:
|
|
162
|
+
while True:
|
|
163
|
+
time.sleep(args.watch)
|
|
164
|
+
results = one_pass()
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
pass
|
|
167
|
+
done = sum(1 for r in results if r["ok"])
|
|
168
|
+
info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
|
|
169
|
+
emit(None, results=results, processed=done, total=len(results))
|
|
170
|
+
if not args.json:
|
|
171
|
+
for r in results:
|
|
172
|
+
print(f"{'OK ' if r['ok'] else 'FAIL'} {r['file']} -> {r['output']}" + (" (cached)" if r.get("cached") else ""))
|
|
173
|
+
return 0 if done == len(results) else 1
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
if __name__ == "__main__":
|
|
177
|
+
sys.exit(main())
|
package/scripts/caption.py
CHANGED
|
@@ -20,9 +20,10 @@ import argparse
|
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
22
|
import sys
|
|
23
|
-
from
|
|
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("--
|
|
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=
|
|
226
|
-
sty.add_argument("--color", default=
|
|
227
|
-
sty.add_argument("--outline-color", default=
|
|
228
|
-
sty.add_argument("--outline", type=float, default=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
|
|
249
|
-
|
|
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())
|
package/scripts/overlay.py
CHANGED
|
@@ -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(
|
|
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
|
-
{"
|
|
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
|
|
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("
|
|
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())
|