ffmpeg-skill 0.3.0 → 0.4.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 +9 -5
- package/SKILL.md +39 -3
- package/package.json +2 -2
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.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__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
- package/scripts/_common.py +95 -2
- package/scripts/caption.py +69 -4
- package/scripts/color.py +19 -0
- package/scripts/fit.py +2 -1
- package/scripts/multicam.py +196 -0
- package/scripts/probe.py +10 -1
- package/scripts/verify.py +157 -0
package/README.md
CHANGED
|
@@ -17,15 +17,17 @@ 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
|
+
- **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
|
|
21
|
+
- **Real-footage verification kit** — run the whole toolchain on your own device files and get a PASS/FAIL report.
|
|
20
22
|
- **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.
|
|
21
23
|
- **Join with transitions** — crossfade, wipes, fade-to-black between mismatched clips (any size, fps, audio layout).
|
|
22
24
|
- **Agent eyes** — contact sheets, single frames and before/after comparisons as PNG so the agent verifies caption placement, crops and colour visually.
|
|
23
|
-
- **Plan before render** — every script has `--dry-run` (print the ffmpeg commands)
|
|
24
|
-
- **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file; animated (fade/pop/slide) and word-by-word karaoke highlight
|
|
25
|
+
- **Plan before render** — every script has `--dry-run` (print the ffmpeg commands), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress` (percent / ETA).
|
|
26
|
+
- **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file; animated (fade/pop/slide) and word-by-word karaoke highlight timed to the speech energy in the audio.
|
|
25
27
|
- **Fit** to an exact duration (pitch-preserving speed change or trim) and to 16:9 / 9:16 / 1:1 / 4:5 by padding or cropping; motion-interpolated or blended slow motion.
|
|
26
28
|
- **Real-world footage handling** — variable-frame-rate phone clips are conformed to constant fps automatically, rotation metadata is honoured, 10-bit HEVC and 5.1 sources are handled.
|
|
27
29
|
- **Multicam / external-audio sync** — offset detection by cross-correlation implemented in pure Python (no numpy), 1 ms resolution, plus clock-drift correction for long takes.
|
|
28
|
-
- **Colour management** —
|
|
30
|
+
- **Colour management** — HDR10 / HLG / Dolby Vision (iPhone) → SDR BT.709 tone mapping, Dolby Vision layer stripping, 3D LUT (.cube) for Log footage and looks, Log-footage detection, metadata-only retagging.
|
|
29
31
|
- **Audio post** — voice clean-up chain (highpass, de-esser, FFT denoise, compressor), background music with sidechain ducking, fades, 5.1 → stereo downmix, track replacement.
|
|
30
32
|
- **Loudness** — two-pass EBU R128 normalisation to −14 LUFS (or any target) with true-peak ceiling.
|
|
31
33
|
- **Overlays** — logos, watermarks and titles with position, time range, opacity and fades.
|
|
@@ -88,15 +90,17 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
88
90
|
|
|
89
91
|
| Script | What it does |
|
|
90
92
|
|--------|--------------|
|
|
91
|
-
| `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format, colour space, rotation, audio channels as JSON |
|
|
93
|
+
| `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 |
|
|
92
94
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
|
|
95
|
+
| `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
|
|
96
|
+
| `verify.py` | Run the toolchain on real device files and report PASS/FAIL per step |
|
|
93
97
|
| `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
|
|
94
98
|
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps and audio |
|
|
95
99
|
| `look.py` | Contact sheet, single frames, side-by-side comparison as PNG for visual checks |
|
|
96
100
|
| `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text; animated + karaoke ASS |
|
|
97
101
|
| `fit.py` | Fit to a duration (speed or trim, smooth slow-mo) and/or aspect ratio (pad or crop), force constant fps |
|
|
98
102
|
| `sync.py` | Detect offset between two recordings by audio cross-correlation (1 ms), correct clock drift; output aligned video/audio |
|
|
99
|
-
| `color.py` | HDR10/HLG → SDR
|
|
103
|
+
| `color.py` | HDR10/HLG/Dolby Vision → SDR tone mapping, DV layer stripping, 3D LUT application, colour-tag rewriting |
|
|
100
104
|
| `audio.py` | Denoise / voice chain, music bed with auto-ducking, fades, downmix, replace track |
|
|
101
105
|
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP (or custom), video stream-copied |
|
|
102
106
|
| `overlay.py` | Composite image/logo or drawtext title with position, time range, opacity, fade |
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: ffmpeg-skill
|
|
3
|
-
description: Professional video editing with local FFmpeg — cut, remove silences, join with transitions, caption (animated/karaoke), fit to duration/aspect, sync
|
|
3
|
+
description: Professional video editing with local FFmpeg — cut, remove silences, join with transitions, multicam switching, caption (animated/karaoke timed to speech), fit to duration/aspect, sync audio with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs and Log detection, denoise/duck/mix audio, 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
|
|
@@ -27,6 +27,8 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
27
27
|
`--dry-run` (prints the ffmpeg commands, runs nothing) and `--json`
|
|
28
28
|
(structured result: output path, probe of the output, commands run). Use
|
|
29
29
|
them to confirm a plan before long encodes and to report exact facts.
|
|
30
|
+
`--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
|
|
31
|
+
prints percent and ETA on stderr for long encodes.
|
|
30
32
|
4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
|
|
31
33
|
fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
|
|
32
34
|
text so captions are sized for the final frame. Re-encode as few times as
|
|
@@ -59,6 +61,11 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
59
61
|
| "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
|
|
60
62
|
| "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
|
|
61
63
|
| "what would you run?", "don't render yet" | any script with `--dry-run` |
|
|
64
|
+
| "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
|
|
65
|
+
| "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
|
+
| "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
|
|
67
|
+
| "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
|
|
68
|
+
| "show me progress", "quick preview first" | any encoding script with `--progress` and/or `--fast` |
|
|
62
69
|
| "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
|
|
63
70
|
| "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
|
|
64
71
|
| "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (no re-encode) |
|
|
@@ -125,6 +132,29 @@ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
|
|
|
125
132
|
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
126
133
|
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
127
134
|
|
|
135
|
+
### multicam.py — align several cameras and switch between them
|
|
136
|
+
```
|
|
137
|
+
multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
|
|
138
|
+
[--offsets-only] [--width W --height H --fps N] [-o OUT]
|
|
139
|
+
```
|
|
140
|
+
All inputs are aligned to the first one by audio (same engine as `sync.py`,
|
|
141
|
+
`--fix-drift` for long takes). `--switch` names which camera is on screen for
|
|
142
|
+
each range of the reference timeline (gaps fall back to camera 0), `--auto N`
|
|
143
|
+
simply alternates every N seconds. Audio comes from the reference unless
|
|
144
|
+
`--audio` picks another input, e.g. an external recorder that has no video.
|
|
145
|
+
`--offsets-only` reports offsets and confidence without rendering.
|
|
146
|
+
|
|
147
|
+
### verify.py — real-footage verification kit
|
|
148
|
+
```
|
|
149
|
+
verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
|
|
150
|
+
```
|
|
151
|
+
Runs the toolchain on the user's own files (phone HDR, GoPro, OBS, Log, Zoom)
|
|
152
|
+
and prints a PASS/FAIL table per step (probe, copy cut, accurate cut, fit,
|
|
153
|
+
caption, overlay, look, export, loudness, silence, plus `color --to-sdr` for
|
|
154
|
+
HDR and `audio --downmix` for >2 channels). Exit code 1 if anything fails.
|
|
155
|
+
Run this first when a user hands over footage from a device you have not
|
|
156
|
+
seen before, and fix or report what fails.
|
|
157
|
+
|
|
128
158
|
### look.py — see the result
|
|
129
159
|
```
|
|
130
160
|
look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
|
|
@@ -173,12 +203,17 @@ take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
|
|
|
173
203
|
Use it whenever the recording is longer than ~10 minutes. Check `confidence`
|
|
174
204
|
(0–1); below ~0.3 the match is doubtful — use a window with a clear event.
|
|
175
205
|
|
|
176
|
-
### color.py — HDR to SDR, LUTs, colour tags
|
|
206
|
+
### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
|
|
177
207
|
```
|
|
178
208
|
color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
|
|
179
209
|
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
180
210
|
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
|
|
211
|
+
color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
|
|
181
212
|
```
|
|
213
|
+
iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
|
|
214
|
+
`probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
|
|
215
|
+
tone-maps it from the HLG base layer. When the user wants to keep HDR but
|
|
216
|
+
players mis-render the DV layer, `--strip-dovi` removes it losslessly.
|
|
182
217
|
`--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
|
|
183
218
|
(default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
|
|
184
219
|
broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
|
|
@@ -233,7 +268,8 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
|
233
268
|
`color.py --to-sdr` **first**; other scripts would tag the HDR picture as
|
|
234
269
|
BT.709 and it would look flat and desaturated (`export.py` warns about this).
|
|
235
270
|
For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
|
|
236
|
-
tagged SDR)
|
|
271
|
+
tagged SDR) run `probe.py --analyze`; `looks_like_log: true` means apply the
|
|
272
|
+
manufacturer's `.cube` with `color.py --lut` before anything else. Keep
|
|
237
273
|
ProRes masters at source colour: `export.py --preset prores` does not retag.
|
|
238
274
|
- **CJK and other non-Latin text.** libass and drawtext need a font that has
|
|
239
275
|
the glyphs. Check with `fc-list | grep -i cjk`. Then either name it
|
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, 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.4.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.",
|
|
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
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/scripts/_common.py
CHANGED
|
@@ -55,7 +55,7 @@ def require_tool(name: str) -> str:
|
|
|
55
55
|
return "" # unreachable
|
|
56
56
|
|
|
57
57
|
|
|
58
|
-
STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": []}
|
|
58
|
+
STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": [], "progress": False, "fast": False, "duration_hint": None}
|
|
59
59
|
|
|
60
60
|
|
|
61
61
|
def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
@@ -63,11 +63,17 @@ def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
|
63
63
|
g = ap.add_argument_group("agent options")
|
|
64
64
|
g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
|
|
65
65
|
g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
|
|
66
|
+
g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
|
|
67
|
+
g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
|
|
66
68
|
|
|
67
69
|
|
|
68
70
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
69
71
|
STATE["dry_run"] = bool(getattr(args, "dry_run", False))
|
|
70
72
|
STATE["json"] = bool(getattr(args, "json", False))
|
|
73
|
+
STATE["progress"] = bool(getattr(args, "progress", False))
|
|
74
|
+
STATE["fast"] = bool(getattr(args, "fast", False))
|
|
75
|
+
if STATE["fast"] and hasattr(args, "preset"):
|
|
76
|
+
args.preset = "veryfast"
|
|
71
77
|
|
|
72
78
|
|
|
73
79
|
def emit(output: Optional[str], **extra: Any) -> None:
|
|
@@ -95,6 +101,8 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
95
101
|
info(("[dry-run] $ " if STATE["dry_run"] and is_ffmpeg else "$ ") + " ".join(shell_quote(c) for c in cmd))
|
|
96
102
|
if STATE["dry_run"] and is_ffmpeg:
|
|
97
103
|
return subprocess.CompletedProcess(list(cmd), 0, "", "")
|
|
104
|
+
if STATE["progress"] and is_ffmpeg and cmd[-1] != "-":
|
|
105
|
+
return _run_with_progress(list(cmd), check)
|
|
98
106
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
99
107
|
if check and proc.returncode != 0:
|
|
100
108
|
tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
|
|
@@ -102,6 +110,42 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
102
110
|
return proc
|
|
103
111
|
|
|
104
112
|
|
|
113
|
+
def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
|
|
114
|
+
"""Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
|
|
115
|
+
import time
|
|
116
|
+
total = STATE.get("duration_hint") or 0.0
|
|
117
|
+
full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
|
|
118
|
+
t0 = time.time()
|
|
119
|
+
proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
120
|
+
last = ""
|
|
121
|
+
assert proc.stdout is not None
|
|
122
|
+
for line in proc.stdout:
|
|
123
|
+
if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
|
|
124
|
+
try:
|
|
125
|
+
done = int(line.split("=")[1]) / 1_000_000
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
if total > 0:
|
|
129
|
+
pct = min(99.9, done / total * 100)
|
|
130
|
+
elapsed = time.time() - t0
|
|
131
|
+
eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
|
|
132
|
+
msg = f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
|
|
133
|
+
else:
|
|
134
|
+
msg = f"\r {done:7.1f}s encoded"
|
|
135
|
+
if msg != last:
|
|
136
|
+
sys.stderr.write(msg)
|
|
137
|
+
sys.stderr.flush()
|
|
138
|
+
last = msg
|
|
139
|
+
_, err = proc.communicate()
|
|
140
|
+
if last:
|
|
141
|
+
sys.stderr.write("\r" + " " * len(last) + "\r")
|
|
142
|
+
result = subprocess.CompletedProcess(full, proc.returncode, "", err)
|
|
143
|
+
if check and proc.returncode != 0:
|
|
144
|
+
tail = "\n".join(err.strip().splitlines()[-15:])
|
|
145
|
+
die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
|
|
146
|
+
return result
|
|
147
|
+
|
|
148
|
+
|
|
105
149
|
def shell_quote(s: str) -> str:
|
|
106
150
|
if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
|
|
107
151
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
@@ -143,6 +187,8 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
143
187
|
duration = _to_float(video.get("duration"))
|
|
144
188
|
if duration is None and audio:
|
|
145
189
|
duration = _to_float(audio.get("duration"))
|
|
190
|
+
if duration and STATE.get("duration_hint") is None:
|
|
191
|
+
STATE["duration_hint"] = duration
|
|
146
192
|
|
|
147
193
|
out: Dict[str, Any] = {
|
|
148
194
|
"file": path,
|
|
@@ -173,6 +219,12 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
173
219
|
trc = video.get("color_transfer") or ""
|
|
174
220
|
prim = video.get("color_primaries") or ""
|
|
175
221
|
hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
|
|
222
|
+
dovi = None
|
|
223
|
+
for sd in video.get("side_data_list", []) or []:
|
|
224
|
+
if "dv_profile" in sd or "DOVI" in str(sd.get("side_data_type", "")):
|
|
225
|
+
dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
|
|
226
|
+
if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
|
|
227
|
+
hdr = True
|
|
176
228
|
out["video"] = {
|
|
177
229
|
"codec": video.get("codec_name"),
|
|
178
230
|
"profile": video.get("profile"),
|
|
@@ -186,7 +238,9 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
186
238
|
"pix_fmt": video.get("pix_fmt"),
|
|
187
239
|
"bit_depth": 10 if "10" in pix else (12 if "12" in pix else 8),
|
|
188
240
|
"hdr": hdr,
|
|
189
|
-
"hdr_format": ("
|
|
241
|
+
"hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
|
|
242
|
+
"HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
243
|
+
"dolby_vision": dovi,
|
|
190
244
|
"color_space": video.get("color_space"),
|
|
191
245
|
"color_primaries": video.get("color_primaries"),
|
|
192
246
|
"color_transfer": video.get("color_transfer"),
|
|
@@ -298,6 +352,45 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
|
|
|
298
352
|
return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
|
|
299
353
|
|
|
300
354
|
|
|
355
|
+
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
356
|
+
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
357
|
+
|
|
358
|
+
Log gammas (S-Log3, V-Log, C-Log, HLG-looking flat profiles) put black around 90-95/255 and
|
|
359
|
+
white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
|
|
360
|
+
"""
|
|
361
|
+
ffmpeg = require_tool("ffmpeg")
|
|
362
|
+
cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
|
|
363
|
+
"-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
|
|
364
|
+
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
365
|
+
vals: Dict[str, List[float]] = {}
|
|
366
|
+
for line in proc.stdout.splitlines():
|
|
367
|
+
if "lavfi.signalstats." in line and "=" in line:
|
|
368
|
+
key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
|
|
369
|
+
try:
|
|
370
|
+
vals.setdefault(key, []).append(float(val))
|
|
371
|
+
except ValueError:
|
|
372
|
+
pass
|
|
373
|
+
if not vals.get("YAVG"):
|
|
374
|
+
return {"error": "no frames analysed"}
|
|
375
|
+
def mean(k: str) -> float:
|
|
376
|
+
v = vals.get(k) or [0.0]
|
|
377
|
+
return sum(v) / len(v)
|
|
378
|
+
ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
|
|
379
|
+
# 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
|
|
380
|
+
lows = sorted(vals.get("YLOW") or vals.get("YMIN") or [0])
|
|
381
|
+
highs = sorted(vals.get("YHIGH") or vals.get("YMAX") or [255])
|
|
382
|
+
p_low = lows[len(lows) // 20]
|
|
383
|
+
p_high = highs[-1 - len(highs) // 20]
|
|
384
|
+
looks_log = p_low >= 64 and p_high <= 235 and sat < 40
|
|
385
|
+
return {
|
|
386
|
+
"y_min": ymin, "y_max": ymax, "y_avg": round(yavg, 1), "y_low_p5": p_low, "y_high_p95": p_high,
|
|
387
|
+
"saturation_avg": round(sat, 1),
|
|
388
|
+
"looks_like_log": looks_log,
|
|
389
|
+
"note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
|
|
390
|
+
"Apply the camera's conversion LUT with color.py --lut" if looks_log else "contrast and saturation look like normal display-referred SDR"),
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
301
394
|
def print_json(obj: Any) -> None:
|
|
302
395
|
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
303
396
|
|
package/scripts/caption.py
CHANGED
|
@@ -86,7 +86,65 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
|
86
86
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
87
87
|
|
|
88
88
|
|
|
89
|
-
def
|
|
89
|
+
def word_durations_from_audio(video: str, start: float, end: float, n_words: int) -> List[int]:
|
|
90
|
+
"""Split a cue's time across n_words in proportion to speech energy (centiseconds each).
|
|
91
|
+
|
|
92
|
+
Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
|
|
93
|
+
and cuts at equal cumulative-energy quantiles: pauses get no words, loud stretches get more time.
|
|
94
|
+
Falls back to an even split when the window is silent or too short.
|
|
95
|
+
"""
|
|
96
|
+
import struct
|
|
97
|
+
import subprocess as sp
|
|
98
|
+
from _common import require_tool
|
|
99
|
+
total_cs = max(1, int(round((end - start) * 100)))
|
|
100
|
+
if n_words <= 1:
|
|
101
|
+
return [total_cs]
|
|
102
|
+
ffmpeg = require_tool("ffmpeg")
|
|
103
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
|
|
104
|
+
"-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
|
|
105
|
+
proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
|
|
106
|
+
n = len(proc.stdout) // 2
|
|
107
|
+
if proc.returncode != 0 or n < 800:
|
|
108
|
+
per = total_cs // n_words
|
|
109
|
+
return [per] * (n_words - 1) + [total_cs - per * (n_words - 1)]
|
|
110
|
+
samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
|
|
111
|
+
step = 80 # 10 ms
|
|
112
|
+
env = []
|
|
113
|
+
for i in range(0, n - step + 1, step):
|
|
114
|
+
block = samples[i:i + step]
|
|
115
|
+
env.append((sum(x * x for x in block) / step) ** 0.5)
|
|
116
|
+
floor = sorted(env)[len(env) // 5] # 20th percentile ~ noise floor
|
|
117
|
+
energy = [max(0.0, e - floor) for e in env]
|
|
118
|
+
total_e = sum(energy)
|
|
119
|
+
if total_e <= 0:
|
|
120
|
+
per = total_cs // n_words
|
|
121
|
+
return [per] * (n_words - 1) + [total_cs - per * (n_words - 1)]
|
|
122
|
+
# boundaries at cumulative-energy quantiles 1/n .. (n-1)/n
|
|
123
|
+
bounds = []
|
|
124
|
+
acc = 0.0
|
|
125
|
+
k = 1
|
|
126
|
+
for idx, e in enumerate(energy):
|
|
127
|
+
acc += e
|
|
128
|
+
while k < n_words and acc >= total_e * k / n_words:
|
|
129
|
+
bounds.append(idx + 1)
|
|
130
|
+
k += 1
|
|
131
|
+
while len(bounds) < n_words - 1:
|
|
132
|
+
bounds.append(len(energy))
|
|
133
|
+
prev = 0
|
|
134
|
+
out = []
|
|
135
|
+
for b in bounds:
|
|
136
|
+
cs = max(5, int(round((b - prev) * 1.0))) # 10 ms blocks -> centiseconds
|
|
137
|
+
out.append(cs)
|
|
138
|
+
prev = b
|
|
139
|
+
out.append(max(5, total_cs - sum(out)))
|
|
140
|
+
# normalise to the exact cue length
|
|
141
|
+
scale = total_cs / max(1, sum(out))
|
|
142
|
+
out = [max(5, int(round(x * scale))) for x in out]
|
|
143
|
+
out[-1] += total_cs - sum(out)
|
|
144
|
+
return out
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
|
|
90
148
|
"""Write a styled ASS file with optional animation and word-by-word highlight."""
|
|
91
149
|
def t(sec: float) -> str:
|
|
92
150
|
cs = int(round(sec * 100))
|
|
@@ -126,11 +184,16 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
|
|
|
126
184
|
dur_cs = max(1, int(round((end - start) * 100)))
|
|
127
185
|
segments = body.split("\\N")
|
|
128
186
|
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
129
|
-
|
|
187
|
+
if getattr(args, "karaoke_timing", "even") == "energy" and video:
|
|
188
|
+
durs = word_durations_from_audio(video, start, end, len(words))
|
|
189
|
+
else:
|
|
190
|
+
per = max(1, dur_cs // max(1, len(words)))
|
|
191
|
+
durs = [per] * len(words)
|
|
192
|
+
it = iter(durs)
|
|
130
193
|
out_segments = []
|
|
131
194
|
for seg in segments:
|
|
132
195
|
ws = [w for w in seg.split(" ") if w]
|
|
133
|
-
out_segments.append(" ".join(f"{{\\kf{
|
|
196
|
+
out_segments.append(" ".join(f"{{\\kf{next(it)}}}{w}" for w in ws))
|
|
134
197
|
body = "\\N".join(out_segments)
|
|
135
198
|
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
136
199
|
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
@@ -172,6 +235,8 @@ def main() -> int:
|
|
|
172
235
|
anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default="none", help="per-cue entrance animation")
|
|
173
236
|
anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
|
|
174
237
|
anim.add_argument("--highlight-color", default="FFD200", help="karaoke fill colour RRGGBB (default FFD200)")
|
|
238
|
+
anim.add_argument("--karaoke-timing", choices=["even", "energy"], default="energy",
|
|
239
|
+
help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
|
|
175
240
|
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
176
241
|
enc = ap.add_argument_group("encoding")
|
|
177
242
|
enc.add_argument("--crf", type=int, default=18)
|
|
@@ -206,7 +271,7 @@ def main() -> int:
|
|
|
206
271
|
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
207
272
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
208
273
|
w, h = h, w
|
|
209
|
-
write_ass(cues_for_ass, ass_path, args, w, h)
|
|
274
|
+
write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
|
|
210
275
|
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
211
276
|
args.ass = ass_path
|
|
212
277
|
|
package/scripts/color.py
CHANGED
|
@@ -9,6 +9,8 @@ Examples:
|
|
|
9
9
|
python3 color.py slog3.mp4 --lut SLog3_to_Rec709.cube # apply LUT (any Log -> 709 or a look)
|
|
10
10
|
python3 color.py clip.mp4 --lut look.cube --lut-strength 0.6
|
|
11
11
|
python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
|
|
12
|
+
python3 color.py iphone_dv.mov --strip-dovi # drop Dolby Vision RPU, keep HLG base layer
|
|
13
|
+
python3 color.py iphone_dv.mov --to-sdr # DV 8.4 = HLG base layer -> tone-mapped SDR
|
|
12
14
|
"""
|
|
13
15
|
import argparse
|
|
14
16
|
import os
|
|
@@ -45,6 +47,7 @@ def main() -> int:
|
|
|
45
47
|
mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
|
|
46
48
|
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
47
49
|
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
50
|
+
mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
|
|
48
51
|
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
49
52
|
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
50
53
|
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
@@ -62,6 +65,22 @@ def main() -> int:
|
|
|
62
65
|
v = meta["video"]
|
|
63
66
|
has_audio = bool(meta.get("audio"))
|
|
64
67
|
|
|
68
|
+
if args.strip_dovi:
|
|
69
|
+
output = args.output or default_output(args.input, "nodv")
|
|
70
|
+
if v.get("codec") != "hevc":
|
|
71
|
+
die("--strip-dovi only applies to HEVC (Dolby Vision) streams")
|
|
72
|
+
if not v.get("dolby_vision"):
|
|
73
|
+
info("note: no Dolby Vision metadata detected; removing unregistered SEI anyway")
|
|
74
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy", "-bsf:v", "filter_units=remove_types=62", "-tag:v", "hvc1"]
|
|
75
|
+
if os.path.splitext(output)[1].lower() in (".mp4", ".mov", ".m4v"):
|
|
76
|
+
cmd += ["-movflags", "+faststart"]
|
|
77
|
+
cmd.append(output)
|
|
78
|
+
run(cmd)
|
|
79
|
+
r = probe(output)
|
|
80
|
+
info(f"wrote {output} (dolby_vision={r['video'].get('dolby_vision')})")
|
|
81
|
+
emit(output)
|
|
82
|
+
return 0
|
|
83
|
+
|
|
65
84
|
if args.retag:
|
|
66
85
|
tags = {
|
|
67
86
|
"bt709": ["bt709", "bt709", "bt709"],
|
package/scripts/fit.py
CHANGED
|
@@ -19,7 +19,7 @@ import sys
|
|
|
19
19
|
from fractions import Fraction
|
|
20
20
|
from typing import List
|
|
21
21
|
|
|
22
|
-
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
22
|
+
from _common import STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
23
23
|
|
|
24
24
|
ASPECT_PRESETS = {"16:9": Fraction(16, 9), "9:16": Fraction(9, 16), "1:1": Fraction(1, 1), "4:5": Fraction(4, 5), "4:3": Fraction(4, 3), "21:9": Fraction(21, 9)}
|
|
25
25
|
|
|
@@ -116,6 +116,7 @@ def main() -> int:
|
|
|
116
116
|
if has_audio:
|
|
117
117
|
af.append(atempo_chain(factor))
|
|
118
118
|
post += ["-t", f"{target:.3f}"]
|
|
119
|
+
STATE["duration_hint"] = target
|
|
119
120
|
else:
|
|
120
121
|
if target < src_dur:
|
|
121
122
|
start = (src_dur - target) / 2 if args.from_center else 0.0
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Multicam: align two or more cameras (and an optional external recorder) by
|
|
3
|
+
audio, then cut between them from a simple switch list.
|
|
4
|
+
|
|
5
|
+
All sources are aligned to the FIRST input (the reference) using the same
|
|
6
|
+
cross-correlation as sync.py. The output takes video from whichever camera the
|
|
7
|
+
switch list names for each time range (reference timeline), and audio from the
|
|
8
|
+
reference unless --audio picks another source.
|
|
9
|
+
|
|
10
|
+
Switch list format: "START-END:CAM,START-END:CAM,..." with times on the
|
|
11
|
+
reference timeline (seconds or mm:ss) and CAM = input index (0 = reference).
|
|
12
|
+
Gaps fall back to camera 0.
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
python3 multicam.py camA.mp4 camB.mp4 --offsets-only # just report the offsets
|
|
16
|
+
python3 multicam.py camA.mp4 camB.mp4 --switch "0-12:0,12-30:1,30-45:0" -o edit.mp4
|
|
17
|
+
python3 multicam.py camA.mp4 camB.mp4 recorder.wav --audio 2 --switch "0-20:0,20-40:1" --fix-drift
|
|
18
|
+
python3 multicam.py camA.mp4 camB.mp4 --auto 8 -o edit.mp4 # alternate cameras every 8 s
|
|
19
|
+
"""
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
from typing import List, Tuple
|
|
23
|
+
|
|
24
|
+
from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
25
|
+
from sync import measure_offset
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_switch(spec: str, n: int) -> List[Tuple[float, float, int]]:
|
|
29
|
+
out = []
|
|
30
|
+
for raw in spec.split(","):
|
|
31
|
+
raw = raw.strip()
|
|
32
|
+
if not raw:
|
|
33
|
+
continue
|
|
34
|
+
try:
|
|
35
|
+
rng, cam = raw.rsplit(":", 1)
|
|
36
|
+
a, b = rng.rsplit("-", 1)
|
|
37
|
+
s, e, c = parse_time(a), parse_time(b), int(cam)
|
|
38
|
+
except ValueError:
|
|
39
|
+
die(f"bad switch entry '{raw}' (want START-END:CAM)")
|
|
40
|
+
if not 0 <= c < n:
|
|
41
|
+
die(f"camera {c} does not exist (inputs are 0..{n - 1})")
|
|
42
|
+
if e <= s:
|
|
43
|
+
die(f"switch entry '{raw}': end must be after start")
|
|
44
|
+
out.append((s, e, c))
|
|
45
|
+
out.sort()
|
|
46
|
+
return out
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def main() -> int:
|
|
50
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
51
|
+
ap.add_argument("inputs", nargs="+", help="reference camera first, then other cameras / recorders")
|
|
52
|
+
ap.add_argument("-o", "--output", help="output file (default: <reference>_multicam.mp4)")
|
|
53
|
+
ap.add_argument("--switch", help="switch list START-END:CAM,... on the reference timeline")
|
|
54
|
+
ap.add_argument("--auto", type=float, help="no switch list: alternate through the cameras every N seconds")
|
|
55
|
+
ap.add_argument("--audio", type=int, default=0, help="input index to take audio from (default 0 = reference)")
|
|
56
|
+
ap.add_argument("--offsets-only", action="store_true", help="print the measured offsets and exit")
|
|
57
|
+
ap.add_argument("--max-offset", type=float, default=30.0)
|
|
58
|
+
ap.add_argument("--analyze-seconds", type=float, default=120.0)
|
|
59
|
+
ap.add_argument("--fix-drift", action="store_true", help="also correct clock drift of each source (long recordings)")
|
|
60
|
+
ap.add_argument("--width", type=int, help="output width (default: reference)")
|
|
61
|
+
ap.add_argument("--height", type=int, help="output height (default: reference)")
|
|
62
|
+
ap.add_argument("--fps", type=float, help="output fps (default: reference)")
|
|
63
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
64
|
+
ap.add_argument("--preset", default="medium")
|
|
65
|
+
add_common(ap)
|
|
66
|
+
args = ap.parse_args()
|
|
67
|
+
apply_common(args)
|
|
68
|
+
|
|
69
|
+
n = len(args.inputs)
|
|
70
|
+
if n < 2:
|
|
71
|
+
die("give at least two inputs")
|
|
72
|
+
metas = [probe(p) for p in args.inputs]
|
|
73
|
+
for p, m in zip(args.inputs, metas):
|
|
74
|
+
if not m.get("audio"):
|
|
75
|
+
die(f"{p} has no audio to align with")
|
|
76
|
+
if not metas[0].get("video"):
|
|
77
|
+
die("the reference (first input) must have video")
|
|
78
|
+
|
|
79
|
+
offsets = [0.0]
|
|
80
|
+
ratios = [1.0]
|
|
81
|
+
conf = [1.0]
|
|
82
|
+
for p in args.inputs[1:]:
|
|
83
|
+
off, score = measure_offset(args.inputs[0], p, 0.0, args.analyze_seconds, 20.0, args.max_offset, 1.0)
|
|
84
|
+
ratio = 1.0
|
|
85
|
+
if args.fix_drift:
|
|
86
|
+
ref_dur = metas[0]["duration"] or 0.0
|
|
87
|
+
sec_dur = probe(p)["duration"] or 0.0
|
|
88
|
+
overlap_end = min(ref_dur, sec_dur + off)
|
|
89
|
+
window = 60.0
|
|
90
|
+
head_len = min(args.analyze_seconds, overlap_end)
|
|
91
|
+
tail_start = overlap_end - window
|
|
92
|
+
if tail_start > head_len / 2 + 5:
|
|
93
|
+
ref_start, sec_start = tail_start, tail_start - off
|
|
94
|
+
if sec_start < 0:
|
|
95
|
+
ref_start -= sec_start
|
|
96
|
+
sec_start = 0.0
|
|
97
|
+
from sync import decode_mono, envelope, cross_correlate, refine, SR
|
|
98
|
+
ref_s = decode_mono(args.inputs[0], window, ref_start)
|
|
99
|
+
oth_s = decode_mono(p, window, sec_start)
|
|
100
|
+
step = int(SR * 0.02)
|
|
101
|
+
lag, sc = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
|
|
102
|
+
residual = refine(ref_s, oth_s, lag * step / SR, int(SR * 0.001), 0.04)
|
|
103
|
+
elapsed = (ref_start + window / 2) - head_len / 2
|
|
104
|
+
if elapsed > 0 and sc > 0.1:
|
|
105
|
+
ratio = 1.0 - residual / elapsed
|
|
106
|
+
off = off + (ratio - 1.0) * (head_len / 2)
|
|
107
|
+
offsets.append(off)
|
|
108
|
+
ratios.append(ratio)
|
|
109
|
+
conf.append(score)
|
|
110
|
+
info(f"{p}: offset {off:+.3f}s (confidence {score:.2f})" + (f", drift {(ratio - 1) * 1e6:+.0f} ppm" if args.fix_drift else ""))
|
|
111
|
+
|
|
112
|
+
report = {"inputs": args.inputs, "offsets_seconds": [round(o, 4) for o in offsets],
|
|
113
|
+
"confidence": [round(c, 3) for c in conf]}
|
|
114
|
+
if args.fix_drift:
|
|
115
|
+
report["drift_ppm"] = [round((r - 1) * 1e6, 1) for r in ratios]
|
|
116
|
+
if args.offsets_only:
|
|
117
|
+
emit(None, **report)
|
|
118
|
+
if not args.json:
|
|
119
|
+
for p, o, c in zip(args.inputs, offsets, conf):
|
|
120
|
+
print(f"{p}: {o:+.3f}s (confidence {c:.2f})")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
ref_dur = metas[0]["duration"] or 0.0
|
|
124
|
+
if args.switch:
|
|
125
|
+
cuts = parse_switch(args.switch, n)
|
|
126
|
+
elif args.auto:
|
|
127
|
+
cams = [i for i, m in enumerate(metas) if m.get("video")]
|
|
128
|
+
cuts, t, k = [], 0.0, 0
|
|
129
|
+
while t < ref_dur:
|
|
130
|
+
cuts.append((t, min(ref_dur, t + args.auto), cams[k % len(cams)]))
|
|
131
|
+
t += args.auto
|
|
132
|
+
k += 1
|
|
133
|
+
else:
|
|
134
|
+
die("give --switch or --auto (or --offsets-only)")
|
|
135
|
+
# fill gaps with camera 0 and clip to the reference length
|
|
136
|
+
filled: List[Tuple[float, float, int]] = []
|
|
137
|
+
cursor = 0.0
|
|
138
|
+
for s, e, c in cuts:
|
|
139
|
+
s, e = max(0.0, s), min(ref_dur, e)
|
|
140
|
+
if s > cursor:
|
|
141
|
+
filled.append((cursor, s, 0))
|
|
142
|
+
if e > s:
|
|
143
|
+
filled.append((s, e, c))
|
|
144
|
+
cursor = max(cursor, e)
|
|
145
|
+
if cursor < ref_dur:
|
|
146
|
+
filled.append((cursor, ref_dur, 0))
|
|
147
|
+
for s, e, c in filled:
|
|
148
|
+
if not metas[c].get("video"):
|
|
149
|
+
die(f"camera {c} ({args.inputs[c]}) has no video; it can only be used with --audio")
|
|
150
|
+
|
|
151
|
+
v0 = metas[0]["video"]
|
|
152
|
+
w, h = args.width or v0["width"], args.height or v0["height"]
|
|
153
|
+
if v0.get("rotation") in (90, -90, 270, -270) and not (args.width or args.height):
|
|
154
|
+
w, h = h, w
|
|
155
|
+
fps = args.fps or v0.get("fps") or 30.0
|
|
156
|
+
fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
|
|
157
|
+
geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format=yuv420p"
|
|
158
|
+
|
|
159
|
+
cmd = ffmpeg_base()
|
|
160
|
+
for p in args.inputs:
|
|
161
|
+
cmd += ["-i", p]
|
|
162
|
+
parts: List[str] = []
|
|
163
|
+
labels: List[str] = []
|
|
164
|
+
for i, (s, e, c) in enumerate(filled):
|
|
165
|
+
# reference time t maps to source time (t - offset_c) * ratio_c
|
|
166
|
+
src_s = (s - offsets[c]) * ratios[c]
|
|
167
|
+
src_e = (e - offsets[c]) * ratios[c]
|
|
168
|
+
if src_s < 0:
|
|
169
|
+
info(f"warning: camera {c} has not started at reference {s:.2f}s; using camera 0 for that range")
|
|
170
|
+
c, src_s, src_e = 0, s, e
|
|
171
|
+
parts.append(f"[{c}:v]trim=start={src_s:.4f}:end={src_e:.4f},setpts=PTS-STARTPTS,{geo}[v{i}]")
|
|
172
|
+
labels.append(f"[v{i}]")
|
|
173
|
+
parts.append("".join(labels) + f"concat=n={len(filled)}:v=1:a=0[vout]")
|
|
174
|
+
a = args.audio
|
|
175
|
+
a_start = -offsets[a] if offsets[a] < 0 else 0.0
|
|
176
|
+
afx = []
|
|
177
|
+
if abs(ratios[a] - 1.0) > 1e-7:
|
|
178
|
+
sr = metas[a]["audio"].get("sample_rate") or 48000
|
|
179
|
+
afx += [f"asetrate={sr * ratios[a]:.6f}", f"aresample={sr}"]
|
|
180
|
+
if offsets[a] > 0:
|
|
181
|
+
afx.append(f"adelay={int(round(offsets[a] * 1000))}:all=1")
|
|
182
|
+
afx += [f"atrim=start={a_start:.4f}", "asetpts=PTS-STARTPTS", f"atrim=0:{ref_dur:.3f}", "aformat=sample_rates=48000:channel_layouts=stereo"]
|
|
183
|
+
parts.append(f"[{a}:a]{','.join(afx)}[aout]")
|
|
184
|
+
|
|
185
|
+
output = args.output or default_output(args.inputs[0], "multicam", "mp4")
|
|
186
|
+
cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
|
|
187
|
+
cmd += x264_args(args.crf, args.preset) + aac_args() + ["-shortest", output]
|
|
188
|
+
run(cmd)
|
|
189
|
+
r = probe(output)
|
|
190
|
+
info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
|
|
191
|
+
emit(output, cuts=[[round(s, 3), round(e, 3), c] for s, e, c in filled], **report)
|
|
192
|
+
return 0
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
if __name__ == "__main__":
|
|
196
|
+
sys.exit(main())
|
package/scripts/probe.py
CHANGED
|
@@ -12,7 +12,7 @@ Examples:
|
|
|
12
12
|
import argparse
|
|
13
13
|
import sys
|
|
14
14
|
|
|
15
|
-
from _common import print_json, probe
|
|
15
|
+
from _common import analyze_levels, print_json, probe
|
|
16
16
|
|
|
17
17
|
|
|
18
18
|
def main() -> int:
|
|
@@ -20,9 +20,14 @@ def main() -> int:
|
|
|
20
20
|
ap.add_argument("inputs", nargs="+", help="media file(s) to inspect")
|
|
21
21
|
ap.add_argument("--compact", action="store_true", help="one human-readable line per file instead of JSON")
|
|
22
22
|
ap.add_argument("--field", help="print only this top-level field (e.g. duration) or dotted path (video.fps)")
|
|
23
|
+
ap.add_argument("--analyze", action="store_true", help="also sample picture levels (first 20 s) and flag Log-looking footage")
|
|
23
24
|
args = ap.parse_args()
|
|
24
25
|
|
|
25
26
|
results = [probe(p) for p in args.inputs]
|
|
27
|
+
if args.analyze:
|
|
28
|
+
for r in results:
|
|
29
|
+
if r.get("video"):
|
|
30
|
+
r["levels"] = analyze_levels(r["file"])
|
|
26
31
|
|
|
27
32
|
if args.field:
|
|
28
33
|
for r in results:
|
|
@@ -41,6 +46,10 @@ def main() -> int:
|
|
|
41
46
|
line += f" | {v.get('width')}x{v.get('height')} @ {v.get('fps')}fps {v.get('codec')} {v.get('pix_fmt')}"
|
|
42
47
|
if v.get("variable_frame_rate_suspected"):
|
|
43
48
|
line += " (VFR?)"
|
|
49
|
+
if v.get("hdr"):
|
|
50
|
+
line += f" [{v.get('hdr_format')}]"
|
|
51
|
+
if r.get("levels", {}).get("looks_like_log"):
|
|
52
|
+
line += " [Log?]"
|
|
44
53
|
else:
|
|
45
54
|
line += " | no video"
|
|
46
55
|
if a:
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Real-footage verification kit: run the whole toolchain against your own
|
|
3
|
+
files (phone HDR, GoPro, OBS screen captures, Log footage, Zoom recordings)
|
|
4
|
+
and get a pass/fail table. Synthetic test media never shows what a real
|
|
5
|
+
container does; this does.
|
|
6
|
+
|
|
7
|
+
Every file gets: probe, lossless cut, accurate cut, fit (9:16 pad), caption
|
|
8
|
+
burn, overlay text, loudness measurement, silence listing, export (x preset),
|
|
9
|
+
look (contact sheet), plus color --to-sdr when the file is HDR and
|
|
10
|
+
audio --downmix when it has more than 2 channels.
|
|
11
|
+
|
|
12
|
+
Examples:
|
|
13
|
+
python3 verify.py ~/Footage/*.MOV ~/Footage/*.mp4
|
|
14
|
+
python3 verify.py fixtures/ --quick --report verify.md
|
|
15
|
+
python3 verify.py clip.mov --keep --out ./verify_out
|
|
16
|
+
"""
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Dict, List
|
|
26
|
+
|
|
27
|
+
from _common import add_common, apply_common, die, emit, info, probe
|
|
28
|
+
|
|
29
|
+
HERE = Path(__file__).resolve().parent
|
|
30
|
+
MEDIA_EXT = {".mp4", ".mov", ".m4v", ".mkv", ".webm", ".avi", ".mts", ".m2ts", ".mxf", ".wav", ".m4a", ".mp3", ".flac", ".aac"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def collect(paths: List[str]) -> List[Path]:
|
|
34
|
+
files: List[Path] = []
|
|
35
|
+
for p in paths:
|
|
36
|
+
pp = Path(p)
|
|
37
|
+
if pp.is_dir():
|
|
38
|
+
files += sorted(x for x in pp.rglob("*") if x.suffix.lower() in MEDIA_EXT and x.is_file())
|
|
39
|
+
elif pp.is_file():
|
|
40
|
+
files.append(pp)
|
|
41
|
+
else:
|
|
42
|
+
die(f"not found: {p}")
|
|
43
|
+
if not files:
|
|
44
|
+
die("no media files found")
|
|
45
|
+
return files
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def step(name: str, argv: List[str], timeout: float) -> Dict:
|
|
49
|
+
t0 = time.time()
|
|
50
|
+
try:
|
|
51
|
+
proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
|
|
52
|
+
ok = proc.returncode == 0
|
|
53
|
+
err = "" if ok else (proc.stderr.strip().splitlines() or ["?"])[-1][:200]
|
|
54
|
+
except subprocess.TimeoutExpired:
|
|
55
|
+
ok, err = False, f"timeout after {timeout:.0f}s"
|
|
56
|
+
return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
61
|
+
ap.add_argument("paths", nargs="+", help="media files and/or folders")
|
|
62
|
+
ap.add_argument("--out", help="directory for outputs (default: temp dir, deleted unless --keep)")
|
|
63
|
+
ap.add_argument("--keep", action="store_true", help="keep the outputs")
|
|
64
|
+
ap.add_argument("--quick", action="store_true", help="probe, copy cut, fit, caption, export only")
|
|
65
|
+
ap.add_argument("--seconds", type=float, default=6.0, help="length of the test cut taken from each file (default 6)")
|
|
66
|
+
ap.add_argument("--timeout", type=float, default=600.0, help="per-step timeout in seconds")
|
|
67
|
+
ap.add_argument("--report", help="write a Markdown report here")
|
|
68
|
+
add_common(ap)
|
|
69
|
+
args = ap.parse_args()
|
|
70
|
+
apply_common(args)
|
|
71
|
+
|
|
72
|
+
files = collect(args.paths)
|
|
73
|
+
tmp = None
|
|
74
|
+
if args.out:
|
|
75
|
+
outdir = Path(args.out)
|
|
76
|
+
outdir.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
else:
|
|
78
|
+
tmp = tempfile.TemporaryDirectory(prefix="ffskill_verify_")
|
|
79
|
+
outdir = Path(tmp.name)
|
|
80
|
+
|
|
81
|
+
results = []
|
|
82
|
+
for f in files:
|
|
83
|
+
info(f"=== {f}")
|
|
84
|
+
entry: Dict = {"file": str(f), "steps": []}
|
|
85
|
+
try:
|
|
86
|
+
meta = probe(str(f))
|
|
87
|
+
except SystemExit:
|
|
88
|
+
entry["steps"].append({"step": "probe", "ok": False, "seconds": 0, "error": "ffprobe failed"})
|
|
89
|
+
results.append(entry)
|
|
90
|
+
continue
|
|
91
|
+
entry["probe"] = {k: meta.get(k) for k in ("duration", "format")}
|
|
92
|
+
entry["probe"]["video"] = {k: (meta.get("video") or {}).get(k) for k in ("codec", "width", "height", "fps", "pix_fmt", "hdr_format", "rotation", "variable_frame_rate_suspected")}
|
|
93
|
+
entry["probe"]["audio"] = {k: (meta.get("audio") or {}).get(k) for k in ("codec", "channels", "sample_rate")}
|
|
94
|
+
entry["steps"].append({"step": "probe", "ok": True, "seconds": 0, "error": ""})
|
|
95
|
+
dur = meta.get("duration") or 0.0
|
|
96
|
+
has_v, has_a = bool(meta.get("video")), bool(meta.get("audio"))
|
|
97
|
+
stem = outdir / f.stem
|
|
98
|
+
cut = f"{stem}_cut.mp4"
|
|
99
|
+
seg_end = min(dur, args.seconds) if dur else args.seconds
|
|
100
|
+
fast = ["--fast"]
|
|
101
|
+
plan = []
|
|
102
|
+
plan.append(("cut copy", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "-o", cut]))
|
|
103
|
+
if has_v:
|
|
104
|
+
plan.append(("cut accurate", ["cut.py", str(f), "--start", "0", "--end", f"{seg_end:.2f}", "--accurate", "-o", f"{stem}_acc.mp4"] + fast))
|
|
105
|
+
plan.append(("fit 9:16", ["fit.py", cut, "--aspect", "9:16", "--width", "720", "-o", f"{stem}_fit.mp4"] + fast))
|
|
106
|
+
cues = outdir / f"{f.stem}_cues.txt"
|
|
107
|
+
cues.write_text("0:00-0:02 Verification caption\n0:02-0:04 Second | line\n", encoding="utf-8")
|
|
108
|
+
plan.append(("caption", ["caption.py", cut, "--text", str(cues), "--animate", "pop", "--karaoke", "-o", f"{stem}_cap.mp4"] + fast))
|
|
109
|
+
if not args.quick:
|
|
110
|
+
plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
|
|
111
|
+
plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
|
|
112
|
+
if (meta.get("video") or {}).get("hdr"):
|
|
113
|
+
plan.append(("color to-sdr", ["color.py", cut, "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
|
|
114
|
+
plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
|
|
115
|
+
plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
|
|
116
|
+
if has_a and not args.quick:
|
|
117
|
+
plan.append(("loudness measure", ["loudness.py", str(f), "--measure-only"]))
|
|
118
|
+
plan.append(("silence list", ["silence.py", cut, "--list"]))
|
|
119
|
+
if (meta.get("audio") or {}).get("channels", 0) > 2:
|
|
120
|
+
plan.append(("audio downmix", ["audio.py", cut, "--downmix", "-o", f"{stem}_st.mp4"]))
|
|
121
|
+
for name, argv in plan:
|
|
122
|
+
r = step(name, argv, args.timeout)
|
|
123
|
+
entry["steps"].append(r)
|
|
124
|
+
info(f" {'PASS' if r['ok'] else 'FAIL'} {name:16s} {r['seconds']:6.1f}s {r['error']}")
|
|
125
|
+
results.append(entry)
|
|
126
|
+
|
|
127
|
+
total = sum(len(e["steps"]) for e in results)
|
|
128
|
+
failed = sum(1 for e in results for s in e["steps"] if not s["ok"])
|
|
129
|
+
lines = ["# ffmpeg-skill verification", "", f"{len(files)} files, {total} steps, {failed} failed", ""]
|
|
130
|
+
for e in results:
|
|
131
|
+
p = e.get("probe", {})
|
|
132
|
+
v, a = p.get("video", {}), p.get("audio", {})
|
|
133
|
+
lines.append(f"## {e['file']}")
|
|
134
|
+
lines.append(f"{p.get('duration')}s, {v.get('codec')} {v.get('width')}x{v.get('height')} @ {v.get('fps')} {v.get('pix_fmt')}"
|
|
135
|
+
+ (f" [{v.get('hdr_format')}]" if v.get("hdr_format") else "") + (" [VFR?]" if v.get("variable_frame_rate_suspected") else "")
|
|
136
|
+
+ (f" rot {v.get('rotation')}" if v.get("rotation") else "") + f", audio {a.get('codec')} {a.get('channels')}ch")
|
|
137
|
+
lines.append("")
|
|
138
|
+
lines.append("| step | result | time | error |")
|
|
139
|
+
lines.append("|---|---|---|---|")
|
|
140
|
+
for s in e["steps"]:
|
|
141
|
+
lines.append(f"| {s['step']} | {'PASS' if s['ok'] else 'FAIL'} | {s['seconds']}s | {s['error']} |")
|
|
142
|
+
lines.append("")
|
|
143
|
+
report = "\n".join(lines)
|
|
144
|
+
if args.report:
|
|
145
|
+
Path(args.report).write_text(report, encoding="utf-8")
|
|
146
|
+
info(f"wrote {args.report}")
|
|
147
|
+
if args.json:
|
|
148
|
+
emit(None, report=args.report, files=results, failed=failed, total=total)
|
|
149
|
+
else:
|
|
150
|
+
print(report)
|
|
151
|
+
if tmp and not args.keep:
|
|
152
|
+
tmp.cleanup()
|
|
153
|
+
return 1 if failed else 0
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
if __name__ == "__main__":
|
|
157
|
+
sys.exit(main())
|