ffmpeg-skill 0.5.0 → 0.6.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 +5 -0
- package/SKILL.md +34 -2
- package/package.json +2 -2
- package/scripts/__pycache__/_common.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/caption.py +27 -9
- 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,9 @@ npx ffmpeg-skill
|
|
|
18
18
|
- **Lossless when possible** — cuts and joins use stream copy by default; re-encoding only happens when it must (frame-accurate cuts, filters, format changes).
|
|
19
19
|
- **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
|
|
20
20
|
- **Declarative edits** — describe the whole edit in a `project.json` (clips, transitions, captions, overlays, music, loudness, export, check) and re-render after every tweak.
|
|
21
|
+
- **Brand kit** — one `brand.json` (fonts, colours, logo, safe margins, caption style) applied by captions, overlays, graphics and projects.
|
|
22
|
+
- **Motion graphics without assets** — lower-thirds, title cards, chapter chips, progress bars, countdowns and corner bugs drawn by FFmpeg.
|
|
23
|
+
- **HTML delivery report** — before/after contact sheets, media facts, loudness, compliance and the commands run, in one file.
|
|
21
24
|
- **Scene detection and highlight picks** — find cuts and loud moments, get a 60-second digest proposal as a cut list.
|
|
22
25
|
- **Delivery checks** — PASS/FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, broadcast and podcast specs, with the fix for each failure.
|
|
23
26
|
- **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
|
|
@@ -96,6 +99,8 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
96
99
|
| `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
100
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
|
|
98
101
|
| `render.py` | Render a whole edit from `project.json`; `--init`, `--dry-run`, `--stop-after` |
|
|
102
|
+
| `graphics.py` | Lower-third, title, chapter, progress, countdown, bug templates (brand colours) |
|
|
103
|
+
| `report.py` | Single-file HTML delivery report with sheets, facts, loudness, compliance, commands |
|
|
99
104
|
| `scenes.py` | Scene changes, audio peaks, highlight proposals and per-scene sheet |
|
|
100
105
|
| `check.py` | Pre-delivery compliance per platform (duration, aspect, codec, colour, loudness, size) |
|
|
101
106
|
| `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
|
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 — 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,9 @@ 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` |
|
|
75
78
|
| "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
|
|
76
79
|
| "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
80
|
| "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
|
|
@@ -149,7 +152,7 @@ render.py --init project.json # starter file
|
|
|
149
152
|
render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
|
|
150
153
|
```
|
|
151
154
|
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
152
|
-
captions → overlays → audio → loudness → export → check. Keys mirror the
|
|
155
|
+
captions → graphics → overlays → audio → loudness → export → check. Keys mirror the
|
|
153
156
|
CLI flags of each script (see the docstring). Use it whenever an edit has
|
|
154
157
|
more than two steps or the user is likely to ask for changes: edit the JSON,
|
|
155
158
|
re-render, and the result is reproducible. `--dry-run --json` prints the
|
|
@@ -173,6 +176,35 @@ check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podca
|
|
|
173
176
|
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
174
177
|
step before reporting a deliverable; fix FAILs, mention WARNs.
|
|
175
178
|
|
|
179
|
+
### graphics.py — motion-graphics templates
|
|
180
|
+
```
|
|
181
|
+
graphics.py INPUT --template lower-third|title|chapter|progress|countdown|bug [--name] [--title] [--subtitle]
|
|
182
|
+
[--from N] [--start S] [--end E] [--position CORNER] [--brand brand.json] [--primary RRGGBB] [--scale 1.0] [-o OUT]
|
|
183
|
+
```
|
|
184
|
+
Drawn with drawbox/drawtext/overlay — no PNG assets needed. Sizes scale with
|
|
185
|
+
the frame's short side; colours, font and safe margin come from `--brand`.
|
|
186
|
+
Lower-third slides in over 0.4 s and out over 0.3 s; title/chapter/bug fade.
|
|
187
|
+
|
|
188
|
+
### brand.json — one file for fonts, colours, logo, margins
|
|
189
|
+
```json
|
|
190
|
+
{"font": "Noto Sans CJK JP", "font_file": "fonts/NotoSansCJK-Bold.ttc",
|
|
191
|
+
"colors": {"primary": "FF6A00", "text": "FFFFFF", "outline": "000000", "background": "0B1D2A"},
|
|
192
|
+
"logo": "logo.png", "logo_position": "top-right", "logo_scale": 160, "logo_opacity": 0.9,
|
|
193
|
+
"safe_margin": 48, "caption": {"size": 28, "position": "bottom", "animate": "pop", "karaoke": true, "bold": true}}
|
|
194
|
+
```
|
|
195
|
+
`caption.py --brand`, `overlay.py --brand --logo`, `graphics.py --brand`, and
|
|
196
|
+
`"brand": "brand.json"` in a render project. Explicit flags still win. When a
|
|
197
|
+
user mentions brand guidelines, colours, "our font" or a logo, ask for or
|
|
198
|
+
write a brand.json once and reuse it across every output.
|
|
199
|
+
|
|
200
|
+
### report.py — HTML delivery report
|
|
201
|
+
```
|
|
202
|
+
report.py --after FINAL [--before SOURCE] [--platform youtube] [--commands cmds.txt] [--notes notes.md] [--title T] [--no-sheets] [-o report.html]
|
|
203
|
+
```
|
|
204
|
+
One self-contained HTML: before/after facts and contact sheets, loudness,
|
|
205
|
+
compliance table with fixes, commands. Produce it for any multi-step job and
|
|
206
|
+
hand the path to the user together with the numbers.
|
|
207
|
+
|
|
176
208
|
### multicam.py — align several cameras and switch between them
|
|
177
209
|
```
|
|
178
210
|
multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
|
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.6.0",
|
|
4
|
+
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: declarative project rendering, brand kits, motion-graphics templates, HTML delivery reports, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
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
|
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/caption.py
CHANGED
|
@@ -20,9 +20,10 @@ import argparse
|
|
|
20
20
|
import os
|
|
21
21
|
import re
|
|
22
22
|
import sys
|
|
23
|
+
from pathlib import Path
|
|
23
24
|
from typing import List, 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
|
|
|
@@ -220,21 +221,22 @@ def main() -> int:
|
|
|
220
221
|
src.add_argument("--auto-seconds", type=float, default=3.0, help="duration for cues without timing (default 3)")
|
|
221
222
|
src.add_argument("--gap", type=float, default=0.0, help="gap after auto-timed cues in seconds")
|
|
222
223
|
sty = ap.add_argument_group("style (SRT only)")
|
|
223
|
-
sty.add_argument("--
|
|
224
|
+
sty.add_argument("--brand", help="brand.json: font, colours, caption size/position/animation defaults")
|
|
225
|
+
sty.add_argument("--font", default=None, help="font family, e.g. 'Noto Sans CJK JP' for Japanese (default DejaVu Sans or brand font)")
|
|
224
226
|
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=
|
|
227
|
+
sty.add_argument("--size", type=int, default=None, help="font size in ASS points (relative to a 288p script height, scales automatically)")
|
|
228
|
+
sty.add_argument("--color", default=None, help="text colour RRGGBB (default FFFFFF or brand text colour)")
|
|
229
|
+
sty.add_argument("--outline-color", default=None, help="outline colour RRGGBB")
|
|
230
|
+
sty.add_argument("--outline", type=float, default=None, help="outline width (default 2)")
|
|
229
231
|
sty.add_argument("--shadow", type=float, default=0.0, help="shadow depth (default 0)")
|
|
230
232
|
sty.add_argument("--bold", action="store_true")
|
|
231
|
-
sty.add_argument("--position", choices=sorted(ALIGN), default=
|
|
233
|
+
sty.add_argument("--position", choices=sorted(ALIGN), default=None, help="on-screen placement (default bottom)")
|
|
232
234
|
sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
|
|
233
235
|
sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
|
|
234
236
|
anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
|
|
235
|
-
anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default=
|
|
237
|
+
anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default=None, help="per-cue entrance animation (default none, or brand caption.animate)")
|
|
236
238
|
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=
|
|
239
|
+
anim.add_argument("--highlight-color", default=None, help="karaoke fill colour RRGGBB (default FFD200 or brand primary)")
|
|
238
240
|
anim.add_argument("--karaoke-timing", choices=["even", "energy"], default="energy",
|
|
239
241
|
help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
|
|
240
242
|
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
@@ -245,6 +247,22 @@ def main() -> int:
|
|
|
245
247
|
args = ap.parse_args()
|
|
246
248
|
apply_common(args)
|
|
247
249
|
|
|
250
|
+
brand = load_brand(args.brand)
|
|
251
|
+
bc, bcap = brand["colors"], brand["caption"]
|
|
252
|
+
args.font = args.font or brand.get("font") or "DejaVu Sans"
|
|
253
|
+
args.size = args.size if args.size is not None else (bcap.get("size", 24) if args.brand else 24)
|
|
254
|
+
args.color = color_hex(args.color or bc.get("text", "FFFFFF"))
|
|
255
|
+
args.outline_color = color_hex(args.outline_color or bc.get("outline", "000000"))
|
|
256
|
+
args.outline = args.outline if args.outline is not None else (float(bcap.get("outline", 2)) if args.brand else 2.0)
|
|
257
|
+
args.position = args.position or (bcap.get("position", "bottom") if args.brand else "bottom")
|
|
258
|
+
args.animate = args.animate or (bcap.get("animate", "none") if args.brand else "none")
|
|
259
|
+
args.highlight_color = color_hex(args.highlight_color or bc.get("primary", "FFD200"))
|
|
260
|
+
if args.brand and bcap.get("bold") and not args.bold:
|
|
261
|
+
args.bold = True
|
|
262
|
+
if args.brand and bcap.get("karaoke") and not args.karaoke:
|
|
263
|
+
args.karaoke = True
|
|
264
|
+
if args.brand and brand.get("font_file") and not args.fonts_dir:
|
|
265
|
+
args.fonts_dir = str(Path(brand["font_file"]).parent)
|
|
248
266
|
if not (args.srt or args.ass or args.text):
|
|
249
267
|
die("give one of --srt, --ass or --text")
|
|
250
268
|
|
|
@@ -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())
|