ffmpeg-skill 0.4.1 → 0.5.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 +7 -0
- package/SKILL.md +48 -7
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/_common.py +3 -2
- package/scripts/check.py +163 -0
- package/scripts/export.py +3 -1
- package/scripts/fit.py +2 -0
- package/scripts/join.py +13 -6
- package/scripts/loudness.py +3 -1
- package/scripts/render.py +333 -0
- package/scripts/scenes.py +155 -0
package/README.md
CHANGED
|
@@ -17,6 +17,9 @@ npx ffmpeg-skill
|
|
|
17
17
|
- **Probe first, verify last** — the skill forces the agent to read real duration/fps/resolution before editing and to check the result after, so you get "final.mp4: 59.98 s, 1080×1920, 30 fps" instead of guesses.
|
|
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
|
+
- **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
|
+
- **Scene detection and highlight picks** — find cuts and loud moments, get a 60-second digest proposal as a cut list.
|
|
22
|
+
- **Delivery checks** — PASS/FAIL against YouTube, Shorts, Reels, TikTok, X, LinkedIn, broadcast and podcast specs, with the fix for each failure.
|
|
20
23
|
- **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
|
|
21
24
|
- **Real-footage verification kit** — run the whole toolchain on your own device files and get a PASS/FAIL report.
|
|
22
25
|
- **Silence removal / jump cuts** — detect dead air, keep a margin around speech, render frame-accurate in one pass; export the cut list for hand editing.
|
|
@@ -92,6 +95,9 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
92
95
|
|--------|--------------|
|
|
93
96
|
| `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 |
|
|
94
97
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
|
|
98
|
+
| `render.py` | Render a whole edit from `project.json`; `--init`, `--dry-run`, `--stop-after` |
|
|
99
|
+
| `scenes.py` | Scene changes, audio peaks, highlight proposals and per-scene sheet |
|
|
100
|
+
| `check.py` | Pre-delivery compliance per platform (duration, aspect, codec, colour, loudness, size) |
|
|
95
101
|
| `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
|
|
96
102
|
| `verify.py` | Run the toolchain on real device files and report PASS/FAIL per step |
|
|
97
103
|
| `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
|
|
@@ -119,6 +125,7 @@ All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stder
|
|
|
119
125
|
```bash
|
|
120
126
|
bash examples/make_demo.sh # generates footage, runs every script, rebuilds assets/demo.gif
|
|
121
127
|
python3 tests/test_all.py # end-to-end tests incl. VFR, rotated, 5.1, 10-bit HDR10 and drifting sources (needs ffmpeg)
|
|
128
|
+
python3 evals/run.py --list # routing eval prompts (see evals/)
|
|
122
129
|
node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
|
|
123
130
|
```
|
|
124
131
|
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ffmpeg-skill
|
|
3
|
-
description: Professional video editing with local FFmpeg —
|
|
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.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
@@ -30,15 +30,23 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
30
30
|
`--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
|
|
31
31
|
prints percent and ETA on stderr for long encodes.
|
|
32
32
|
4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
|
|
33
|
-
fit → caption/overlay → sync → audio → loudness → export.
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
CRF 18 (the default) and only use `export.py` for the last step
|
|
37
|
-
|
|
33
|
+
join → silence → fit → caption/overlay → sync → audio → loudness → export.
|
|
34
|
+
Do frame changes (fit/crop) before captions and overlays so text is sized
|
|
35
|
+
for the final frame. Re-encode as few times as possible: keep intermediates
|
|
36
|
+
at CRF 18 (the default) and only use `export.py` for the last step; for
|
|
37
|
+
anything with more than two steps use `render.py` with a project.json.
|
|
38
|
+
5. **Check the deliverable.** Before reporting, run `check.py OUTPUT --platform X`
|
|
39
|
+
for the destination the user named; fix FAILs, mention WARNs.
|
|
40
|
+
6. **Verify the output.** Run `probe.py` on each result and confirm duration,
|
|
38
41
|
resolution, fps and audio match what was requested. Report those numbers to
|
|
39
42
|
the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
|
|
40
|
-
|
|
43
|
+
7. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
41
44
|
files next to the input or where the user asked.
|
|
45
|
+
8. **Look at the picture.** After captioning, overlaying, cropping or colour
|
|
46
|
+
work run `look.py OUTPUT` (contact sheet) or `look.py OUTPUT --at T` and
|
|
47
|
+
view the PNG: text inside the frame and not over faces, logos where asked,
|
|
48
|
+
crops keeping the subject, colours not washed out. Fix and re-run before
|
|
49
|
+
reporting. Numbers from probe are not enough.
|
|
42
50
|
|
|
43
51
|
## Request → script
|
|
44
52
|
|
|
@@ -61,6 +69,9 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
61
69
|
| "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
|
|
62
70
|
| "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
|
|
63
71
|
| "what would you run?", "don't render yet" | any script with `--dry-run` |
|
|
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
|
+
| "is this OK to upload?", "check it meets the Reels spec" | `check.py final.mp4 --platform reels` |
|
|
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` |
|
|
64
75
|
| "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
|
|
65
76
|
| "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) |
|
|
66
77
|
| "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
|
|
@@ -132,6 +143,36 @@ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
|
|
|
132
143
|
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
133
144
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
134
145
|
|
|
146
|
+
### render.py — the whole edit in one project.json
|
|
147
|
+
```
|
|
148
|
+
render.py --init project.json # starter file
|
|
149
|
+
render.py project.json [--fast] [--dry-run] [--stop-after STAGE] [--work DIR --keep]
|
|
150
|
+
```
|
|
151
|
+
Stages: clips (cut, optional speed) → join (transition) → silence → fit →
|
|
152
|
+
captions → overlays → audio → loudness → export → check. Keys mirror the
|
|
153
|
+
CLI flags of each script (see the docstring). Use it whenever an edit has
|
|
154
|
+
more than two steps or the user is likely to ask for changes: edit the JSON,
|
|
155
|
+
re-render, and the result is reproducible. `--dry-run --json` prints the
|
|
156
|
+
complete command plan for review.
|
|
157
|
+
|
|
158
|
+
### scenes.py — scene changes and highlight candidates
|
|
159
|
+
```
|
|
160
|
+
scenes.py INPUT [--threshold 10] [--min-scene 1] [--highlights N [--target SECONDS] [--max-scene 15]] [--edl picks.txt] [--sheet scenes.png] [--json]
|
|
161
|
+
```
|
|
162
|
+
Lists scenes with audio energy, the loudest moments, and (with
|
|
163
|
+
`--highlights`) proposes N ranges that add up to `--target` seconds, biased to
|
|
164
|
+
the loudest window of each scene. Review the sheet + JSON, adjust the EDL, then
|
|
165
|
+
`cut.py --segments`. It is a proposal engine, not a judgement of content:
|
|
166
|
+
tell the user what it picked and why (energy, scene length).
|
|
167
|
+
|
|
168
|
+
### check.py — pre-delivery compliance
|
|
169
|
+
```
|
|
170
|
+
check.py INPUT --platform youtube|shorts|reels|tiktok|x|linkedin|broadcast|podcast|custom [--no-loudness] [--json]
|
|
171
|
+
[--max-duration S] [--aspect 9:16] [--lufs -14] [--tp -1] [--max-mb N]
|
|
172
|
+
```
|
|
173
|
+
PASS/WARN/FAIL per check with the script that fixes it. Run it as the final
|
|
174
|
+
step before reporting a deliverable; fix FAILs, mention WARNs.
|
|
175
|
+
|
|
135
176
|
### multicam.py — align several cameras and switch between them
|
|
136
177
|
```
|
|
137
178
|
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: 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.5.0",
|
|
4
|
+
"description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: declarative project rendering, scene detection, delivery checks, cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
|
|
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
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -55,6 +55,7 @@ def require_tool(name: str) -> str:
|
|
|
55
55
|
return "" # unreachable
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
X264_PRESETS = ("ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow", "placebo")
|
|
58
59
|
STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": [], "progress": False, "fast": False, "duration_hint": None}
|
|
59
60
|
|
|
60
61
|
|
|
@@ -72,7 +73,7 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
72
73
|
STATE["json"] = bool(getattr(args, "json", False))
|
|
73
74
|
STATE["progress"] = bool(getattr(args, "progress", False))
|
|
74
75
|
STATE["fast"] = bool(getattr(args, "fast", False))
|
|
75
|
-
if STATE["fast"] and
|
|
76
|
+
if STATE["fast"] and getattr(args, "preset", None) in X264_PRESETS:
|
|
76
77
|
args.preset = "veryfast"
|
|
77
78
|
|
|
78
79
|
|
|
@@ -163,7 +164,7 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
163
164
|
if not os.path.exists(path):
|
|
164
165
|
if STATE["dry_run"]:
|
|
165
166
|
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
166
|
-
"video": {"codec": None, "width":
|
|
167
|
+
"video": {"codec": None, "width": 1920, "height": 1080, "fps": 30.0, "pix_fmt": None, "hdr": False,
|
|
167
168
|
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
168
169
|
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
|
|
169
170
|
die(f"input not found: {path}")
|
package/scripts/check.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Pre-delivery compliance check: does this file meet the platform's spec?
|
|
3
|
+
|
|
4
|
+
Checks duration, frame size / aspect, fps, codec, pixel format, colour tags,
|
|
5
|
+
file size, integrated loudness and true peak against the chosen platform
|
|
6
|
+
and prints a PASS/WARN/FAIL table. Exit code 1 when anything FAILs.
|
|
7
|
+
|
|
8
|
+
Platforms: youtube, shorts, reels, tiktok, x, linkedin, broadcast (EBU R128), podcast, custom
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
python3 check.py final.mp4 --platform youtube
|
|
12
|
+
python3 check.py reel.mp4 --platform reels --json
|
|
13
|
+
python3 check.py spot.mov --platform broadcast
|
|
14
|
+
python3 check.py clip.mp4 --platform custom --max-duration 30 --aspect 1:1 --lufs -16
|
|
15
|
+
"""
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import sys
|
|
20
|
+
from fractions import Fraction
|
|
21
|
+
from typing import Any, Dict, List
|
|
22
|
+
|
|
23
|
+
from _common import add_common, apply_common, die, emit, info, probe, require_tool, run
|
|
24
|
+
|
|
25
|
+
SPECS: Dict[str, Dict[str, Any]] = {
|
|
26
|
+
"youtube": {"max_duration": 12 * 3600, "aspects": ["16:9", "9:16", "1:1", "4:3"], "min_height": 720, "fps_max": 60, "codecs": ["h264", "hevc", "prores", "av1", "vp9"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
|
|
27
|
+
"shorts": {"max_duration": 180, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 256 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
|
|
28
|
+
"reels": {"max_duration": 90, "aspects": ["9:16", "4:5", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
|
|
29
|
+
"tiktok": {"max_duration": 600, "aspects": ["9:16", "1:1"], "min_height": 1080, "fps_max": 60, "codecs": ["h264", "hevc"], "max_bytes": 4 * 1024 ** 3, "lufs": -14, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": True},
|
|
30
|
+
"x": {"max_duration": 140, "aspects": ["16:9", "1:1", "9:16"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 512 * 1024 ** 2, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
|
|
31
|
+
"linkedin": {"max_duration": 600, "aspects": ["16:9", "1:1", "9:16", "4:5"], "min_height": 720, "fps_max": 60, "codecs": ["h264"], "max_bytes": 5 * 1024 ** 3, "lufs": -14, "lufs_tol": 3.0, "tp": -1.0, "sdr_only": True},
|
|
32
|
+
"broadcast": {"max_duration": None, "aspects": ["16:9"], "min_height": 1080, "fps_max": 60, "codecs": ["prores", "dnxhd", "h264", "hevc", "mpeg2video"], "max_bytes": None, "lufs": -23, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
|
|
33
|
+
"podcast": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": -16, "lufs_tol": 1.0, "tp": -1.0, "sdr_only": False},
|
|
34
|
+
"custom": {"max_duration": None, "aspects": None, "min_height": 0, "fps_max": None, "codecs": None, "max_bytes": None, "lufs": None, "lufs_tol": 2.0, "tp": -1.0, "sdr_only": False},
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def measure_loudness(path: str) -> Dict[str, float]:
|
|
39
|
+
ffmpeg = require_tool("ffmpeg")
|
|
40
|
+
proc = run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", "loudnorm=I=-14:TP=-1:LRA=11:print_format=json", "-f", "null", "-"], quiet=True, check=False)
|
|
41
|
+
m = re.search(r"\{[^{}]*\"input_i\"[^{}]*\}", proc.stderr, re.S)
|
|
42
|
+
if not m:
|
|
43
|
+
return {}
|
|
44
|
+
d = json.loads(m.group(0))
|
|
45
|
+
try:
|
|
46
|
+
return {"lufs": float(d["input_i"]), "tp": float(d["input_tp"]), "lra": float(d["input_lra"])}
|
|
47
|
+
except (KeyError, ValueError):
|
|
48
|
+
return {}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def aspect_name(w: int, h: int) -> str:
|
|
52
|
+
f = Fraction(w, h)
|
|
53
|
+
for name, target in (("16:9", Fraction(16, 9)), ("9:16", Fraction(9, 16)), ("1:1", Fraction(1)), ("4:5", Fraction(4, 5)), ("4:3", Fraction(4, 3)), ("21:9", Fraction(21, 9))):
|
|
54
|
+
if abs(float(f) - float(target)) < 0.02:
|
|
55
|
+
return name
|
|
56
|
+
return f"{f.numerator}:{f.denominator}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
61
|
+
ap.add_argument("input")
|
|
62
|
+
ap.add_argument("--platform", choices=sorted(SPECS), default="youtube")
|
|
63
|
+
ap.add_argument("--max-duration", type=float, help="override max duration in seconds")
|
|
64
|
+
ap.add_argument("--aspect", help="override allowed aspect (e.g. 9:16 or 16:9,1:1)")
|
|
65
|
+
ap.add_argument("--lufs", type=float, help="override loudness target")
|
|
66
|
+
ap.add_argument("--tp", type=float, help="override true-peak ceiling")
|
|
67
|
+
ap.add_argument("--max-mb", type=float, help="override max file size in MB")
|
|
68
|
+
ap.add_argument("--no-loudness", action="store_true", help="skip the loudness measurement (faster)")
|
|
69
|
+
add_common(ap)
|
|
70
|
+
args = ap.parse_args()
|
|
71
|
+
apply_common(args)
|
|
72
|
+
|
|
73
|
+
spec = dict(SPECS[args.platform])
|
|
74
|
+
if args.max_duration is not None:
|
|
75
|
+
spec["max_duration"] = args.max_duration
|
|
76
|
+
if args.aspect:
|
|
77
|
+
spec["aspects"] = [a.strip() for a in args.aspect.split(",")]
|
|
78
|
+
if args.lufs is not None:
|
|
79
|
+
spec["lufs"] = args.lufs
|
|
80
|
+
if args.tp is not None:
|
|
81
|
+
spec["tp"] = args.tp
|
|
82
|
+
if args.max_mb is not None:
|
|
83
|
+
spec["max_bytes"] = int(args.max_mb * 1024 * 1024)
|
|
84
|
+
|
|
85
|
+
meta = probe(args.input)
|
|
86
|
+
v, a = meta.get("video") or {}, meta.get("audio") or {}
|
|
87
|
+
rows: List[Dict[str, Any]] = []
|
|
88
|
+
|
|
89
|
+
def row(name: str, status: str, value: Any, expect: Any, fix: str = "") -> None:
|
|
90
|
+
rows.append({"check": name, "status": status, "value": value, "expected": expect, "fix": fix})
|
|
91
|
+
|
|
92
|
+
dur = meta.get("duration") or 0.0
|
|
93
|
+
if spec["max_duration"]:
|
|
94
|
+
row("duration", "PASS" if dur <= spec["max_duration"] else "FAIL", f"{dur:.2f}s", f"<= {spec['max_duration']:g}s", "fit.py --duration N or cut.py")
|
|
95
|
+
else:
|
|
96
|
+
row("duration", "PASS", f"{dur:.2f}s", "any")
|
|
97
|
+
|
|
98
|
+
if v:
|
|
99
|
+
w, h = v["width"], v["height"]
|
|
100
|
+
if v.get("rotation") in (90, -90, 270, -270):
|
|
101
|
+
w, h = h, w
|
|
102
|
+
asp = aspect_name(w, h)
|
|
103
|
+
if spec["aspects"]:
|
|
104
|
+
row("aspect", "PASS" if asp in spec["aspects"] else "FAIL", asp, "/".join(spec["aspects"]), f"fit.py --aspect {spec['aspects'][0]} --fit pad|crop")
|
|
105
|
+
else:
|
|
106
|
+
row("aspect", "PASS", asp, "any")
|
|
107
|
+
short = min(w, h)
|
|
108
|
+
if spec["min_height"]:
|
|
109
|
+
row("resolution", "PASS" if short >= spec["min_height"] else "WARN", f"{w}x{h}", f"short side >= {spec['min_height']}", "upscaling will not add detail; re-export from the master")
|
|
110
|
+
fps = v.get("fps") or 0
|
|
111
|
+
if spec["fps_max"]:
|
|
112
|
+
row("fps", "PASS" if fps <= spec["fps_max"] + 0.01 else "FAIL", f"{fps:g}", f"<= {spec['fps_max']}", "fit.py --fps 30")
|
|
113
|
+
row("vfr", "PASS" if not v.get("variable_frame_rate_suspected") else "WARN", "variable" if v.get("variable_frame_rate_suspected") else "constant", "constant", "fit.py --fps N (any re-encode conforms it)")
|
|
114
|
+
if spec["codecs"]:
|
|
115
|
+
row("video codec", "PASS" if v.get("codec") in spec["codecs"] else "FAIL", v.get("codec"), "/".join(spec["codecs"]), "export.py --preset " + args.platform.replace("shorts", "reels").replace("tiktok", "reels").replace("linkedin", "youtube"))
|
|
116
|
+
pf = v.get("pix_fmt") or ""
|
|
117
|
+
if args.platform in ("reels", "tiktok", "x", "linkedin"):
|
|
118
|
+
row("pixel format", "PASS" if pf == "yuv420p" else "FAIL", pf, "yuv420p (8-bit 4:2:0)", "export.py preset re-encodes to yuv420p")
|
|
119
|
+
if spec["sdr_only"] and v.get("hdr"):
|
|
120
|
+
row("colour", "FAIL", v.get("hdr_format"), "SDR BT.709", "color.py --to-sdr")
|
|
121
|
+
else:
|
|
122
|
+
tags = (v.get("color_primaries"), v.get("color_transfer"))
|
|
123
|
+
ok = v.get("hdr") or tags == ("bt709", "bt709") or (args.platform in ("podcast", "custom"))
|
|
124
|
+
row("colour", "PASS" if ok else "WARN", f"{tags[0]}/{tags[1]}" + (f" ({v.get('hdr_format')})" if v.get("hdr") else ""), "bt709/bt709 tagged (or HDR)", "color.py --retag bt709 when the picture really is 709")
|
|
125
|
+
elif args.platform not in ("podcast", "custom"):
|
|
126
|
+
row("video", "FAIL", "none", "video stream", "")
|
|
127
|
+
|
|
128
|
+
size = meta.get("size_bytes") or 0
|
|
129
|
+
if spec["max_bytes"]:
|
|
130
|
+
row("file size", "PASS" if size <= spec["max_bytes"] else "FAIL", f"{size / 1024 / 1024:.1f} MB", f"<= {spec['max_bytes'] / 1024 / 1024:.0f} MB", "export.py --crf 24 or lower resolution")
|
|
131
|
+
|
|
132
|
+
if a:
|
|
133
|
+
row("audio", "PASS", f"{a.get('codec')} {a.get('channels')}ch {a.get('sample_rate')}Hz", "present")
|
|
134
|
+
if a.get("sample_rate") and a["sample_rate"] not in (44100, 48000):
|
|
135
|
+
row("sample rate", "WARN", a["sample_rate"], "44100 or 48000", "loudness.py --sample-rate 48000")
|
|
136
|
+
if not args.no_loudness and spec["lufs"] is not None:
|
|
137
|
+
lm = measure_loudness(args.input)
|
|
138
|
+
if lm:
|
|
139
|
+
diff = abs(lm["lufs"] - spec["lufs"])
|
|
140
|
+
row("loudness", "PASS" if diff <= spec["lufs_tol"] else "FAIL", f"{lm['lufs']:.1f} LUFS", f"{spec['lufs']:g} ± {spec['lufs_tol']:g} LUFS", f"loudness.py -I {spec['lufs']:g}")
|
|
141
|
+
row("true peak", "PASS" if lm["tp"] <= spec["tp"] + 0.05 else "FAIL", f"{lm['tp']:.1f} dBTP", f"<= {spec['tp']:g} dBTP", f"loudness.py --tp {spec['tp']:g}")
|
|
142
|
+
elif args.platform in ("podcast",):
|
|
143
|
+
row("audio", "FAIL", "none", "audio stream", "audio.py --replace")
|
|
144
|
+
else:
|
|
145
|
+
row("audio", "WARN", "none", "audio stream", "audio.py --replace (silent uploads are often rejected)")
|
|
146
|
+
|
|
147
|
+
failed = [r for r in rows if r["status"] == "FAIL"]
|
|
148
|
+
warned = [r for r in rows if r["status"] == "WARN"]
|
|
149
|
+
if not args.json:
|
|
150
|
+
width = max(len(r["check"]) for r in rows)
|
|
151
|
+
print(f"{args.input} — {args.platform}")
|
|
152
|
+
for r in rows:
|
|
153
|
+
line = f" {r['status']:4s} {r['check']:{width}s} {r['value']} (expected {r['expected']})"
|
|
154
|
+
if r["status"] != "PASS" and r["fix"]:
|
|
155
|
+
line += f" -> {r['fix']}"
|
|
156
|
+
print(line)
|
|
157
|
+
print(f" {len(rows)} checks, {len(failed)} failed, {len(warned)} warnings")
|
|
158
|
+
emit(None, platform=args.platform, checks=rows, failed=len(failed), warnings=len(warned), ok=not failed)
|
|
159
|
+
return 1 if failed else 0
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
if __name__ == "__main__":
|
|
163
|
+
sys.exit(main())
|
package/scripts/export.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import Dict, List
|
|
24
24
|
|
|
25
|
-
from _common import add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
25
|
+
from _common import STATE, add_common, apply_common, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
26
26
|
|
|
27
27
|
PRESETS: Dict[str, Dict] = {
|
|
28
28
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
@@ -94,6 +94,8 @@ def main() -> int:
|
|
|
94
94
|
video = list(p["video"])
|
|
95
95
|
if args.crf is not None and "-crf" in video:
|
|
96
96
|
video[video.index("-crf") + 1] = str(args.crf)
|
|
97
|
+
if STATE["fast"] and "-preset" in video:
|
|
98
|
+
video[video.index("-preset") + 1] = "veryfast"
|
|
97
99
|
cmd += video
|
|
98
100
|
if "-r" not in video:
|
|
99
101
|
cmd += cfr_args(meta)
|
package/scripts/fit.py
CHANGED
|
@@ -102,6 +102,8 @@ def main() -> int:
|
|
|
102
102
|
if target <= 0:
|
|
103
103
|
die("target duration must be > 0")
|
|
104
104
|
if args.method == "speed":
|
|
105
|
+
if src_dur <= 0 and STATE["dry_run"]:
|
|
106
|
+
src_dur = target # planning against an intermediate that does not exist yet
|
|
105
107
|
factor = src_dur / target # >1 = speed up
|
|
106
108
|
if factor > args.max_speed or factor < 1 / args.max_speed:
|
|
107
109
|
die(f"required speed factor {factor:.2f}x exceeds --max-speed {args.max_speed}x; use --method trim or raise the limit")
|
package/scripts/join.py
CHANGED
|
@@ -14,7 +14,7 @@ import argparse
|
|
|
14
14
|
import sys
|
|
15
15
|
from typing import List
|
|
16
16
|
|
|
17
|
-
from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
|
|
17
|
+
from _common import STATE, video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
|
|
18
18
|
|
|
19
19
|
TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
|
|
20
20
|
"circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
|
|
@@ -44,17 +44,24 @@ def main() -> int:
|
|
|
44
44
|
if not m.get("video"):
|
|
45
45
|
die(f"{p} has no video stream")
|
|
46
46
|
first = metas[0]["video"]
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
47
|
+
fw, fh = first["width"], first["height"]
|
|
48
|
+
if first.get("rotation") in (90, -90, 270, -270):
|
|
49
|
+
fw, fh = fh, fw
|
|
50
|
+
if args.width and args.height:
|
|
51
|
+
w, h = args.width, args.height
|
|
52
|
+
elif args.width:
|
|
53
|
+
w, h = args.width, int(round(args.width * fh / fw))
|
|
54
|
+
elif args.height:
|
|
55
|
+
w, h = int(round(args.height * fw / fh)), args.height
|
|
56
|
+
else:
|
|
57
|
+
w, h = fw, fh
|
|
51
58
|
fps = args.fps or first.get("fps") or 30.0
|
|
52
59
|
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
53
60
|
w, h = w - (w % 2), h - (h % 2)
|
|
54
61
|
durs = [m.get("duration") or 0.0 for m in metas]
|
|
55
62
|
d = args.duration if args.transition != "none" else 0.0
|
|
56
63
|
for p, dur in zip(args.inputs, durs):
|
|
57
|
-
if d and dur <= d * 2:
|
|
64
|
+
if d and dur <= d * 2 and not STATE["dry_run"]:
|
|
58
65
|
die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
|
|
59
66
|
|
|
60
67
|
cmd = ffmpeg_base()
|
package/scripts/loudness.py
CHANGED
|
@@ -18,11 +18,13 @@ import os
|
|
|
18
18
|
import re
|
|
19
19
|
import sys
|
|
20
20
|
|
|
21
|
-
from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
21
|
+
from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
|
|
22
22
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
def measure(path: str, I: float, tp: float, lra: float) -> dict:
|
|
26
|
+
if STATE["dry_run"]:
|
|
27
|
+
return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0"}
|
|
26
28
|
ffmpeg = require_tool("ffmpeg")
|
|
27
29
|
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
|
|
28
30
|
proc = run(cmd, check=False)
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Declarative edits: describe the whole edit in one project.json and render it
|
|
3
|
+
in one command. Change a number, re-render. Non-destructive: sources are never
|
|
4
|
+
touched, intermediates live in a work directory.
|
|
5
|
+
|
|
6
|
+
Project format (all keys optional except clips):
|
|
7
|
+
{
|
|
8
|
+
"output": "final.mp4",
|
|
9
|
+
"frame": {"aspect": "9:16", "width": 1080, "fps": 30},
|
|
10
|
+
"clips": [
|
|
11
|
+
{"src": "a.mp4", "in": "0:05", "out": "0:20"},
|
|
12
|
+
{"src": "b.mp4", "in": 3, "out": 12, "speed": 1.25},
|
|
13
|
+
{"src": "c.mp4"}
|
|
14
|
+
],
|
|
15
|
+
"transition": {"type": "fade", "duration": 0.5},
|
|
16
|
+
"silence": {"threshold": -38, "min_silence": 0.8},
|
|
17
|
+
"captions": {"text": "cues.txt", "srt": null, "animate": "pop", "karaoke": true, "font": "Noto Sans CJK JP", "size": 28, "position": "bottom"},
|
|
18
|
+
"overlays": [
|
|
19
|
+
{"image": "logo.png", "position": "top-right", "scale": 160, "opacity": 0.9},
|
|
20
|
+
{"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
|
|
21
|
+
],
|
|
22
|
+
"audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "fade_out": 2},
|
|
23
|
+
"loudness": {"lufs": -14, "tp": -1},
|
|
24
|
+
"fit": {"duration": 60},
|
|
25
|
+
"export": {"preset": "reels"},
|
|
26
|
+
"check": {"platform": "reels"}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
Stages run in this order: clips (cut) → join → silence → fit → captions →
|
|
30
|
+
overlays → audio → loudness → export → check. Missing stages are skipped.
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
python3 render.py --init project.json # write a commented starter project
|
|
34
|
+
python3 render.py project.json # render
|
|
35
|
+
python3 render.py project.json --dry-run # show every command without rendering
|
|
36
|
+
python3 render.py project.json --fast # preview quality
|
|
37
|
+
"""
|
|
38
|
+
import argparse
|
|
39
|
+
import json
|
|
40
|
+
import os
|
|
41
|
+
import subprocess
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path
|
|
44
|
+
from typing import Any, Dict, List
|
|
45
|
+
|
|
46
|
+
from _common import STATE, add_common, apply_common, die, emit, info, probe
|
|
47
|
+
|
|
48
|
+
HERE = Path(__file__).resolve().parent
|
|
49
|
+
|
|
50
|
+
TEMPLATE = {
|
|
51
|
+
"output": "final.mp4",
|
|
52
|
+
"frame": {"aspect": "16:9", "width": 1920, "fps": 30},
|
|
53
|
+
"clips": [{"src": "REPLACE_ME.mp4", "in": "0:00", "out": "0:30"}],
|
|
54
|
+
"transition": {"type": "fade", "duration": 0.5},
|
|
55
|
+
"silence": None,
|
|
56
|
+
"captions": None,
|
|
57
|
+
"overlays": [],
|
|
58
|
+
"audio": None,
|
|
59
|
+
"loudness": {"lufs": -14, "tp": -1},
|
|
60
|
+
"fit": None,
|
|
61
|
+
"export": {"preset": "youtube"},
|
|
62
|
+
"check": {"platform": "youtube"},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
|
|
67
|
+
"""Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
|
|
68
|
+
cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or [])
|
|
69
|
+
if STATE["fast"]:
|
|
70
|
+
cmd.append("--fast")
|
|
71
|
+
if STATE["dry_run"]:
|
|
72
|
+
cmd.append("--dry-run")
|
|
73
|
+
info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
|
|
74
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
75
|
+
for line in proc.stderr.splitlines():
|
|
76
|
+
if line.startswith("$ ") or line.startswith("[dry-run]"):
|
|
77
|
+
STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
|
|
78
|
+
elif line.strip():
|
|
79
|
+
info(" " + line)
|
|
80
|
+
if proc.returncode != 0:
|
|
81
|
+
die(f"{script} failed")
|
|
82
|
+
out = proc.stdout.strip().splitlines()
|
|
83
|
+
return out[-1] if out else ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main() -> int:
|
|
87
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
88
|
+
ap.add_argument("project", nargs="?", help="project.json")
|
|
89
|
+
ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
|
|
90
|
+
ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
|
|
91
|
+
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)")
|
|
93
|
+
add_common(ap)
|
|
94
|
+
args = ap.parse_args()
|
|
95
|
+
apply_common(args)
|
|
96
|
+
|
|
97
|
+
if args.init:
|
|
98
|
+
Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
|
|
99
|
+
info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
|
|
100
|
+
print(args.init)
|
|
101
|
+
return 0
|
|
102
|
+
if not args.project:
|
|
103
|
+
die("give a project.json (or --init FILE)")
|
|
104
|
+
try:
|
|
105
|
+
proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
|
|
106
|
+
except (OSError, ValueError) as exc:
|
|
107
|
+
die(f"cannot read project: {exc}")
|
|
108
|
+
base = Path(args.project).resolve().parent
|
|
109
|
+
|
|
110
|
+
def rel(p: Any) -> str:
|
|
111
|
+
p = str(p)
|
|
112
|
+
return p if os.path.isabs(p) else str(base / p)
|
|
113
|
+
|
|
114
|
+
clips = proj.get("clips") or []
|
|
115
|
+
if not clips:
|
|
116
|
+
die("project.clips is empty")
|
|
117
|
+
output = rel(proj.get("output") or "final.mp4")
|
|
118
|
+
work = Path(args.work) if args.work else Path(str(Path(output).with_suffix("")) + "_work")
|
|
119
|
+
work.mkdir(parents=True, exist_ok=True)
|
|
120
|
+
frame = proj.get("frame") or {}
|
|
121
|
+
trans = proj.get("transition") or {}
|
|
122
|
+
stages_done: List[str] = []
|
|
123
|
+
|
|
124
|
+
# ---- clips
|
|
125
|
+
parts: List[str] = []
|
|
126
|
+
for i, c in enumerate(clips):
|
|
127
|
+
src = rel(c["src"])
|
|
128
|
+
if not STATE["dry_run"]:
|
|
129
|
+
probe(src)
|
|
130
|
+
needs_cut = c.get("in") is not None or c.get("out") is not None
|
|
131
|
+
part = str(work / f"clip{i:02d}.mp4")
|
|
132
|
+
if needs_cut:
|
|
133
|
+
argv: List[Any] = [src, "-o", part, "--accurate"]
|
|
134
|
+
if c.get("in") is not None:
|
|
135
|
+
argv += ["--start", c["in"]]
|
|
136
|
+
if c.get("out") is not None:
|
|
137
|
+
argv += ["--end", c["out"]]
|
|
138
|
+
sh("cut.py", *argv)
|
|
139
|
+
else:
|
|
140
|
+
part = src
|
|
141
|
+
if c.get("speed"):
|
|
142
|
+
spd = float(c["speed"])
|
|
143
|
+
dur = (probe(part).get("duration") or 0.0) if not STATE["dry_run"] else 10.0
|
|
144
|
+
fitted = str(work / f"clip{i:02d}_speed.mp4")
|
|
145
|
+
sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
|
|
146
|
+
part = fitted
|
|
147
|
+
parts.append(part)
|
|
148
|
+
stages_done.append("clips")
|
|
149
|
+
current = parts[0]
|
|
150
|
+
if args.stop_after == "clips":
|
|
151
|
+
emit(current, stages=stages_done)
|
|
152
|
+
return 0
|
|
153
|
+
|
|
154
|
+
# ---- join
|
|
155
|
+
if len(parts) > 1:
|
|
156
|
+
current = str(work / "joined.mp4")
|
|
157
|
+
argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
|
|
158
|
+
if frame.get("width"):
|
|
159
|
+
argv += ["--width", str(frame["width"])]
|
|
160
|
+
if frame.get("height"):
|
|
161
|
+
argv += ["--height", str(frame["height"])]
|
|
162
|
+
if frame.get("fps"):
|
|
163
|
+
argv += ["--fps", str(frame["fps"])]
|
|
164
|
+
sh("join.py", *argv)
|
|
165
|
+
stages_done.append("join")
|
|
166
|
+
if args.stop_after == "join":
|
|
167
|
+
emit(current, stages=stages_done)
|
|
168
|
+
return 0
|
|
169
|
+
|
|
170
|
+
# ---- silence
|
|
171
|
+
sil = proj.get("silence")
|
|
172
|
+
if sil:
|
|
173
|
+
nxt = str(work / "tight.mp4")
|
|
174
|
+
argv = [current, "-o", nxt]
|
|
175
|
+
for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
|
|
176
|
+
if sil.get(k) is not None:
|
|
177
|
+
argv += [flag, str(sil[k])]
|
|
178
|
+
sh("silence.py", *argv)
|
|
179
|
+
current = nxt
|
|
180
|
+
stages_done.append("silence")
|
|
181
|
+
if args.stop_after == "silence":
|
|
182
|
+
emit(current, stages=stages_done)
|
|
183
|
+
return 0
|
|
184
|
+
|
|
185
|
+
# ---- fit (duration and/or frame)
|
|
186
|
+
fit = dict(proj.get("fit") or {})
|
|
187
|
+
if frame.get("aspect"):
|
|
188
|
+
fit.setdefault("aspect", frame["aspect"])
|
|
189
|
+
if frame.get("width") and len(parts) == 1:
|
|
190
|
+
fit.setdefault("width", frame["width"])
|
|
191
|
+
if frame.get("fps") and len(parts) == 1:
|
|
192
|
+
fit.setdefault("fps", frame["fps"])
|
|
193
|
+
if fit:
|
|
194
|
+
nxt = str(work / "fit.mp4")
|
|
195
|
+
argv = [current, "-o", nxt]
|
|
196
|
+
for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("fps", "--fps"), ("smooth", "--smooth")):
|
|
197
|
+
if fit.get(k) is not None:
|
|
198
|
+
argv += [flag, str(fit[k])]
|
|
199
|
+
sh("fit.py", *argv)
|
|
200
|
+
current = nxt
|
|
201
|
+
stages_done.append("fit")
|
|
202
|
+
if args.stop_after == "fit":
|
|
203
|
+
emit(current, stages=stages_done)
|
|
204
|
+
return 0
|
|
205
|
+
|
|
206
|
+
# ---- captions
|
|
207
|
+
cap = proj.get("captions")
|
|
208
|
+
if cap:
|
|
209
|
+
nxt = str(work / "captioned.mp4")
|
|
210
|
+
argv = [current, "-o", nxt]
|
|
211
|
+
if cap.get("text"):
|
|
212
|
+
argv += ["--text", rel(cap["text"])]
|
|
213
|
+
elif cap.get("srt"):
|
|
214
|
+
argv += ["--srt", rel(cap["srt"])]
|
|
215
|
+
elif cap.get("ass"):
|
|
216
|
+
argv += ["--ass", rel(cap["ass"])]
|
|
217
|
+
else:
|
|
218
|
+
die("captions needs text, srt or ass")
|
|
219
|
+
for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline")):
|
|
220
|
+
if cap.get(k) is not None:
|
|
221
|
+
argv += [flag, str(cap[k])]
|
|
222
|
+
for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
|
|
223
|
+
if cap.get(k):
|
|
224
|
+
argv.append(flag)
|
|
225
|
+
sh("caption.py", *argv)
|
|
226
|
+
current = nxt
|
|
227
|
+
stages_done.append("captions")
|
|
228
|
+
if args.stop_after == "captions":
|
|
229
|
+
emit(current, stages=stages_done)
|
|
230
|
+
return 0
|
|
231
|
+
|
|
232
|
+
# ---- overlays
|
|
233
|
+
for i, ov in enumerate(proj.get("overlays") or []):
|
|
234
|
+
nxt = str(work / f"overlay{i:02d}.mp4")
|
|
235
|
+
argv = [current, "-o", nxt]
|
|
236
|
+
if ov.get("image"):
|
|
237
|
+
argv += ["--image", rel(ov["image"])]
|
|
238
|
+
elif ov.get("text"):
|
|
239
|
+
argv += ["--text", ov["text"]]
|
|
240
|
+
else:
|
|
241
|
+
die(f"overlays[{i}] needs image or text")
|
|
242
|
+
for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin")):
|
|
243
|
+
if ov.get(k) is not None:
|
|
244
|
+
argv += [flag, str(ov[k])]
|
|
245
|
+
if ov.get("box"):
|
|
246
|
+
argv.append("--box")
|
|
247
|
+
sh("overlay.py", *argv)
|
|
248
|
+
current = nxt
|
|
249
|
+
if "overlays" not in stages_done:
|
|
250
|
+
stages_done.append("overlays")
|
|
251
|
+
if args.stop_after == "overlays":
|
|
252
|
+
emit(current, stages=stages_done)
|
|
253
|
+
return 0
|
|
254
|
+
|
|
255
|
+
# ---- audio
|
|
256
|
+
au = proj.get("audio")
|
|
257
|
+
if au:
|
|
258
|
+
nxt = str(work / "audio.mp4")
|
|
259
|
+
argv = [current, "-o", nxt]
|
|
260
|
+
for k, flag in (("music", "--music"), ("replace", "--replace")):
|
|
261
|
+
if au.get(k):
|
|
262
|
+
argv += [flag, rel(au[k])]
|
|
263
|
+
for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
|
|
264
|
+
if au.get(k) is not None:
|
|
265
|
+
argv += [flag, str(au[k])]
|
|
266
|
+
for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
|
|
267
|
+
if au.get(k):
|
|
268
|
+
argv.append(flag)
|
|
269
|
+
sh("audio.py", *argv)
|
|
270
|
+
current = nxt
|
|
271
|
+
stages_done.append("audio")
|
|
272
|
+
if args.stop_after == "audio":
|
|
273
|
+
emit(current, stages=stages_done)
|
|
274
|
+
return 0
|
|
275
|
+
|
|
276
|
+
# ---- loudness
|
|
277
|
+
ld = proj.get("loudness")
|
|
278
|
+
if ld:
|
|
279
|
+
nxt = str(work / "loudnorm.mp4")
|
|
280
|
+
argv = [current, "-o", nxt]
|
|
281
|
+
if ld.get("lufs") is not None:
|
|
282
|
+
argv += ["-I", str(ld["lufs"])]
|
|
283
|
+
if ld.get("tp") is not None:
|
|
284
|
+
argv += ["--tp", str(ld["tp"])]
|
|
285
|
+
sh("loudness.py", *argv)
|
|
286
|
+
current = nxt
|
|
287
|
+
stages_done.append("loudness")
|
|
288
|
+
if args.stop_after == "loudness":
|
|
289
|
+
emit(current, stages=stages_done)
|
|
290
|
+
return 0
|
|
291
|
+
|
|
292
|
+
# ---- export
|
|
293
|
+
ex = proj.get("export")
|
|
294
|
+
if ex and ex.get("preset"):
|
|
295
|
+
argv = [current, "--preset", ex["preset"], "-o", output]
|
|
296
|
+
if ex.get("fit"):
|
|
297
|
+
argv += ["--fit", ex["fit"]]
|
|
298
|
+
if ex.get("crf") is not None:
|
|
299
|
+
argv += ["--crf", str(ex["crf"])]
|
|
300
|
+
sh("export.py", *argv)
|
|
301
|
+
stages_done.append("export")
|
|
302
|
+
else:
|
|
303
|
+
if not STATE["dry_run"]:
|
|
304
|
+
import shutil
|
|
305
|
+
shutil.copyfile(current, output)
|
|
306
|
+
info(f"copied final stage to {output}")
|
|
307
|
+
current = output
|
|
308
|
+
|
|
309
|
+
# ---- check
|
|
310
|
+
ck = proj.get("check")
|
|
311
|
+
check_result = None
|
|
312
|
+
if ck and ck.get("platform") and not STATE["dry_run"]:
|
|
313
|
+
proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
314
|
+
try:
|
|
315
|
+
check_result = json.loads(proc.stdout)
|
|
316
|
+
except ValueError:
|
|
317
|
+
check_result = {"error": proc.stderr.strip()[-300:]}
|
|
318
|
+
if check_result.get("failed"):
|
|
319
|
+
info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
|
|
320
|
+
else:
|
|
321
|
+
info(f"check: OK for {ck['platform']}")
|
|
322
|
+
stages_done.append("check")
|
|
323
|
+
|
|
324
|
+
if not args.keep and not args.work and not STATE["dry_run"]:
|
|
325
|
+
import shutil
|
|
326
|
+
shutil.rmtree(work, ignore_errors=True)
|
|
327
|
+
info(f"rendered {output} via {' → '.join(stages_done)}")
|
|
328
|
+
emit(output, stages=stages_done, check=check_result)
|
|
329
|
+
return 0
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
if __name__ == "__main__":
|
|
333
|
+
sys.exit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Find scene changes and loud moments, and propose highlight candidates so the
|
|
3
|
+
agent can plan an edit or a digest without watching the whole file.
|
|
4
|
+
|
|
5
|
+
Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
|
|
6
|
+
of the audio. Highlight candidates are the scenes ranked by audio energy
|
|
7
|
+
(and, optionally, by motion).
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
python3 scenes.py talk.mp4 # scenes + peaks, JSON
|
|
11
|
+
python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
|
|
12
|
+
python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
|
|
13
|
+
python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
|
|
14
|
+
"""
|
|
15
|
+
import argparse
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import struct
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Dict, List, Tuple
|
|
23
|
+
|
|
24
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
|
|
25
|
+
|
|
26
|
+
SCENE_RE = re.compile(r"lavfi\.scd\.time=([0-9.]+)")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def detect_scenes(path: str, threshold: float, min_len: float, duration: float) -> List[float]:
|
|
30
|
+
ffmpeg = require_tool("ffmpeg")
|
|
31
|
+
proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
|
|
32
|
+
f"scale=320:-2,scdet=threshold={threshold}:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
|
|
33
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
34
|
+
times = [float(t) for t in SCENE_RE.findall(proc.stdout)]
|
|
35
|
+
cuts = [0.0]
|
|
36
|
+
for t in times:
|
|
37
|
+
if t - cuts[-1] >= min_len:
|
|
38
|
+
cuts.append(t)
|
|
39
|
+
if duration - cuts[-1] < min_len and len(cuts) > 1:
|
|
40
|
+
cuts.pop()
|
|
41
|
+
return cuts
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def audio_envelope(path: str, step_s: float) -> List[float]:
|
|
45
|
+
ffmpeg = require_tool("ffmpeg")
|
|
46
|
+
proc = subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
|
|
47
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
48
|
+
n = len(proc.stdout) // 2
|
|
49
|
+
if n == 0:
|
|
50
|
+
return []
|
|
51
|
+
samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
|
|
52
|
+
step = max(1, int(8000 * step_s))
|
|
53
|
+
env = []
|
|
54
|
+
for i in range(0, n, step):
|
|
55
|
+
block = samples[i:i + step]
|
|
56
|
+
env.append(math.sqrt(sum(x * x for x in block) / len(block)) / 32768.0)
|
|
57
|
+
return env
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def main() -> int:
|
|
61
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
62
|
+
ap.add_argument("input")
|
|
63
|
+
ap.add_argument("--threshold", type=float, default=10.0, help="scdet threshold 0-100 (default 10; lower = more cuts)")
|
|
64
|
+
ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
|
|
65
|
+
ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
|
|
66
|
+
ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
|
|
67
|
+
ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
|
|
68
|
+
ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
|
|
69
|
+
ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
|
|
70
|
+
add_common(ap)
|
|
71
|
+
args = ap.parse_args()
|
|
72
|
+
apply_common(args)
|
|
73
|
+
|
|
74
|
+
meta = probe(args.input)
|
|
75
|
+
if not meta.get("video"):
|
|
76
|
+
die("input has no video stream")
|
|
77
|
+
dur = meta.get("duration") or 0.0
|
|
78
|
+
cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur)
|
|
79
|
+
bounds = cuts + [dur]
|
|
80
|
+
step_s = 0.5
|
|
81
|
+
env = audio_envelope(args.input, step_s) if meta.get("audio") else []
|
|
82
|
+
|
|
83
|
+
scenes = []
|
|
84
|
+
for i in range(len(bounds) - 1):
|
|
85
|
+
s, e = bounds[i], bounds[i + 1]
|
|
86
|
+
if e - s <= 0.05:
|
|
87
|
+
continue
|
|
88
|
+
seg = env[int(s / step_s): max(int(s / step_s) + 1, int(e / step_s))] if env else []
|
|
89
|
+
energy = (sum(seg) / len(seg)) if seg else 0.0
|
|
90
|
+
peak = max(seg) if seg else 0.0
|
|
91
|
+
scenes.append({"index": len(scenes), "start": round(s, 3), "end": round(e, 3), "duration": round(e - s, 3),
|
|
92
|
+
"audio_rms": round(energy, 4), "audio_peak": round(peak, 4)})
|
|
93
|
+
peaks = []
|
|
94
|
+
if env:
|
|
95
|
+
thr = sorted(env)[int(len(env) * 0.9)] if len(env) > 10 else max(env)
|
|
96
|
+
for i, val in enumerate(env):
|
|
97
|
+
if val >= thr and val > 0.02 and (i == 0 or env[i - 1] < val) and (i == len(env) - 1 or env[i + 1] <= val):
|
|
98
|
+
peaks.append({"time": round(i * step_s, 2), "rms": round(val, 4)})
|
|
99
|
+
peaks = sorted(peaks, key=lambda p: -p["rms"])[:20]
|
|
100
|
+
peaks.sort(key=lambda p: p["time"])
|
|
101
|
+
|
|
102
|
+
result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
|
|
103
|
+
info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
|
|
104
|
+
|
|
105
|
+
if args.highlights:
|
|
106
|
+
ranked = sorted(scenes, key=lambda sc: (-sc["audio_rms"], sc["start"]))[: args.highlights]
|
|
107
|
+
picks: List[Tuple[float, float]] = []
|
|
108
|
+
budget = args.target if args.target else None
|
|
109
|
+
per = (budget / max(1, len(ranked))) if budget else args.max_scene
|
|
110
|
+
for sc in ranked:
|
|
111
|
+
length = min(sc["duration"], per, args.max_scene)
|
|
112
|
+
# take the loudest window inside the scene
|
|
113
|
+
best_s = sc["start"]
|
|
114
|
+
if env and length < sc["duration"]:
|
|
115
|
+
best, best_s = -1.0, sc["start"]
|
|
116
|
+
win = max(1, int(length / step_s))
|
|
117
|
+
lo, hi = int(sc["start"] / step_s), max(int(sc["start"] / step_s) + 1, int(sc["end"] / step_s) - win)
|
|
118
|
+
for i in range(lo, hi + 1):
|
|
119
|
+
val = sum(env[i:i + win])
|
|
120
|
+
if val > best:
|
|
121
|
+
best, best_s = val, i * step_s
|
|
122
|
+
picks.append((round(best_s, 2), round(min(sc["end"], best_s + length), 2)))
|
|
123
|
+
picks.sort()
|
|
124
|
+
result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
|
|
125
|
+
result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
|
|
126
|
+
info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
|
|
127
|
+
if args.edl:
|
|
128
|
+
with open(args.edl, "w", encoding="utf-8") as fh:
|
|
129
|
+
for s, e in picks:
|
|
130
|
+
fh.write(f"{s:.2f}-{e:.2f}\n")
|
|
131
|
+
info(f"wrote {args.edl}")
|
|
132
|
+
|
|
133
|
+
if args.sheet:
|
|
134
|
+
n = len(scenes)
|
|
135
|
+
cols = min(4, max(1, n))
|
|
136
|
+
rows = max(1, math.ceil(n / cols))
|
|
137
|
+
tile_w = 1280 // cols // 2 * 2
|
|
138
|
+
# exactly one frame per scene: the frame index at the scene start
|
|
139
|
+
fps = meta["video"].get("fps") or 30.0
|
|
140
|
+
expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
|
|
141
|
+
vf = (f"select='{expr}',scale={tile_w}:-2,drawtext=text='%{{pts\\:hms}}':fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6,"
|
|
142
|
+
f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
|
|
143
|
+
run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
|
|
144
|
+
info(f"wrote {args.sheet}")
|
|
145
|
+
result["sheet"] = args.sheet
|
|
146
|
+
|
|
147
|
+
if args.json:
|
|
148
|
+
emit(None, **result)
|
|
149
|
+
else:
|
|
150
|
+
print_json(result)
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
if __name__ == "__main__":
|
|
155
|
+
sys.exit(main())
|