ffmpeg-skill 0.2.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 +15 -4
- package/SKILL.md +80 -6
- 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 +137 -3
- package/scripts/audio.py +4 -2
- package/scripts/caption.py +73 -6
- package/scripts/color.py +24 -3
- package/scripts/cut.py +5 -3
- package/scripts/export.py +5 -3
- package/scripts/fit.py +5 -2
- package/scripts/join.py +111 -0
- package/scripts/look.py +101 -0
- package/scripts/loudness.py +4 -2
- package/scripts/multicam.py +196 -0
- package/scripts/overlay.py +4 -2
- package/scripts/probe.py +10 -1
- package/scripts/silence.py +123 -0
- package/scripts/sync.py +4 -3
- package/scripts/verify.py +157 -0
package/README.md
CHANGED
|
@@ -17,11 +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
|
-
- **
|
|
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.
|
|
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.
|
|
23
|
+
- **Join with transitions** — crossfade, wipes, fade-to-black between mismatched clips (any size, fps, audio layout).
|
|
24
|
+
- **Agent eyes** — contact sheets, single frames and before/after comparisons as PNG so the agent verifies caption placement, crops and colour visually.
|
|
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.
|
|
21
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.
|
|
22
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.
|
|
23
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.
|
|
24
|
-
- **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.
|
|
25
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.
|
|
26
32
|
- **Loudness** — two-pass EBU R128 normalisation to −14 LUFS (or any target) with true-peak ceiling.
|
|
27
33
|
- **Overlays** — logos, watermarks and titles with position, time range, opacity and fades.
|
|
@@ -84,12 +90,17 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
84
90
|
|
|
85
91
|
| Script | What it does |
|
|
86
92
|
|--------|--------------|
|
|
87
|
-
| `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 |
|
|
88
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 |
|
|
97
|
+
| `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
|
|
98
|
+
| `join.py` | Concatenate clips with xfade transitions, normalising size, fps and audio |
|
|
99
|
+
| `look.py` | Contact sheet, single frames, side-by-side comparison as PNG for visual checks |
|
|
89
100
|
| `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text; animated + karaoke ASS |
|
|
90
101
|
| `fit.py` | Fit to a duration (speed or trim, smooth slow-mo) and/or aspect ratio (pad or crop), force constant fps |
|
|
91
102
|
| `sync.py` | Detect offset between two recordings by audio cross-correlation (1 ms), correct clock drift; output aligned video/audio |
|
|
92
|
-
| `color.py` | HDR10/HLG → SDR
|
|
103
|
+
| `color.py` | HDR10/HLG/Dolby Vision → SDR tone mapping, DV layer stripping, 3D LUT application, colour-tag rewriting |
|
|
93
104
|
| `audio.py` | Denoise / voice chain, music bed with auto-ducking, fades, downmix, replace track |
|
|
94
105
|
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP (or custom), video stream-copied |
|
|
95
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, 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
|
|
@@ -23,15 +23,21 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
23
23
|
(plain cuts on keyframes, remuxing, audio-only changes), do not re-encode.
|
|
24
24
|
`cut.py` and `loudness.py` stream-copy video by default; only pass
|
|
25
25
|
`--accurate` to `cut.py` when the user needs frame-exact cuts.
|
|
26
|
-
3. **
|
|
26
|
+
3. **Plan with `--dry-run --json`, then execute.** Every script accepts
|
|
27
|
+
`--dry-run` (prints the ffmpeg commands, runs nothing) and `--json`
|
|
28
|
+
(structured result: output path, probe of the output, commands run). Use
|
|
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.
|
|
32
|
+
4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
|
|
27
33
|
fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
|
|
28
34
|
text so captions are sized for the final frame. Re-encode as few times as
|
|
29
35
|
possible: if several re-encoding steps are needed, keep intermediates at
|
|
30
36
|
CRF 18 (the default) and only use `export.py` for the last step.
|
|
31
|
-
|
|
37
|
+
5. **Verify the output.** Run `probe.py` on each result and confirm duration,
|
|
32
38
|
resolution, fps and audio match what was requested. Report those numbers to
|
|
33
39
|
the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
|
|
34
|
-
|
|
40
|
+
6. **Keep the user's originals.** Never overwrite the source file. Write new
|
|
35
41
|
files next to the input or where the user asked.
|
|
36
42
|
|
|
37
43
|
## Request → script
|
|
@@ -51,6 +57,15 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
51
57
|
| "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
|
|
52
58
|
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
|
|
53
59
|
| "make a GIF preview" | `export.py input.mp4 --preset gif` |
|
|
60
|
+
| "cut out the pauses / dead air", "tighten it up", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
|
|
61
|
+
| "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
|
|
62
|
+
| "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
|
|
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` |
|
|
54
69
|
| "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
|
|
55
70
|
| "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
|
|
56
71
|
| "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (no re-encode) |
|
|
@@ -96,6 +111,59 @@ refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
|
|
|
96
111
|
the head (or the middle with `--from-center`). `--fps` forces a constant frame
|
|
97
112
|
rate; VFR sources are conformed automatically even without it.
|
|
98
113
|
|
|
114
|
+
### silence.py — remove dead air / jump cuts
|
|
115
|
+
```
|
|
116
|
+
silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
|
|
117
|
+
```
|
|
118
|
+
Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
|
|
119
|
+
gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
|
|
120
|
+
(frame accurate). `--list` prints silences, kept ranges and seconds removed
|
|
121
|
+
without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
|
|
122
|
+
so the user can edit the list by hand. Quiet rooms need `--threshold -40`
|
|
123
|
+
to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
|
|
124
|
+
|
|
125
|
+
### join.py — concatenate with transitions
|
|
126
|
+
```
|
|
127
|
+
join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
|
|
128
|
+
[--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
|
|
129
|
+
```
|
|
130
|
+
Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
|
|
131
|
+
(silent track generated for clips without audio), then chains `xfade` +
|
|
132
|
+
`acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
|
|
133
|
+
longer than 2 × the transition. Use `--transition none` for a plain cut.
|
|
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
|
+
|
|
158
|
+
### look.py — see the result
|
|
159
|
+
```
|
|
160
|
+
look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
|
|
161
|
+
look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
|
|
162
|
+
look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
|
|
163
|
+
```
|
|
164
|
+
Outputs PNG. View it with the Read tool (or any image viewer) and judge the
|
|
165
|
+
frame like an editor would. Use `--compare` to show before/after to the user.
|
|
166
|
+
|
|
99
167
|
### caption.py — subtitles (static, animated, karaoke)
|
|
100
168
|
```
|
|
101
169
|
caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
|
|
@@ -135,12 +203,17 @@ take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
|
|
|
135
203
|
Use it whenever the recording is longer than ~10 minutes. Check `confidence`
|
|
136
204
|
(0–1); below ~0.3 the match is doubtful — use a window with a clear event.
|
|
137
205
|
|
|
138
|
-
### color.py — HDR to SDR, LUTs, colour tags
|
|
206
|
+
### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
|
|
139
207
|
```
|
|
140
208
|
color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
|
|
141
209
|
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
142
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)
|
|
143
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.
|
|
144
217
|
`--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
|
|
145
218
|
(default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
|
|
146
219
|
broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
|
|
@@ -195,7 +268,8 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
|
195
268
|
`color.py --to-sdr` **first**; other scripts would tag the HDR picture as
|
|
196
269
|
BT.709 and it would look flat and desaturated (`export.py` warns about this).
|
|
197
270
|
For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
|
|
198
|
-
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
|
|
199
273
|
ProRes masters at source colour: `export.py --preset prores` does not retag.
|
|
200
274
|
- **CJK and other non-Latin text.** libass and drawtext need a font that has
|
|
201
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, 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,10 +55,54 @@ 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": [], "progress": False, "fast": False, "duration_hint": None}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def add_common(ap: "argparse.ArgumentParser") -> None:
|
|
62
|
+
"""Add the flags every script shares."""
|
|
63
|
+
g = ap.add_argument_group("agent options")
|
|
64
|
+
g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
|
|
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")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def apply_common(args: "argparse.Namespace") -> None:
|
|
71
|
+
STATE["dry_run"] = bool(getattr(args, "dry_run", False))
|
|
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"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def emit(output: Optional[str], **extra: Any) -> None:
|
|
80
|
+
"""Final stdout line: the output path, or a JSON document with --json."""
|
|
81
|
+
if STATE["json"]:
|
|
82
|
+
doc: Dict[str, Any] = {"output": output, "dry_run": STATE["dry_run"], "commands": list(STATE["commands"])}
|
|
83
|
+
if output and not STATE["dry_run"] and os.path.exists(output):
|
|
84
|
+
doc["probe"] = probe(output)
|
|
85
|
+
doc.update(extra)
|
|
86
|
+
print_json(doc)
|
|
87
|
+
elif output:
|
|
88
|
+
print(output)
|
|
89
|
+
|
|
90
|
+
|
|
58
91
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
|
|
59
|
-
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
92
|
+
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
93
|
+
|
|
94
|
+
With --dry-run, ffmpeg invocations are printed and skipped (ffprobe still runs so
|
|
95
|
+
scripts can plan); a fake successful CompletedProcess is returned.
|
|
96
|
+
"""
|
|
97
|
+
is_ffmpeg = os.path.basename(cmd[0]).startswith("ffmpeg")
|
|
98
|
+
if is_ffmpeg:
|
|
99
|
+
STATE["commands"].append(" ".join(shell_quote(c) for c in cmd))
|
|
60
100
|
if not quiet:
|
|
61
|
-
info("$ " + " ".join(shell_quote(c) for c in cmd))
|
|
101
|
+
info(("[dry-run] $ " if STATE["dry_run"] and is_ffmpeg else "$ ") + " ".join(shell_quote(c) for c in cmd))
|
|
102
|
+
if STATE["dry_run"] and is_ffmpeg:
|
|
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)
|
|
62
106
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
63
107
|
if check and proc.returncode != 0:
|
|
64
108
|
tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
|
|
@@ -66,6 +110,42 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
|
|
|
66
110
|
return proc
|
|
67
111
|
|
|
68
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
|
+
|
|
69
149
|
def shell_quote(s: str) -> str:
|
|
70
150
|
if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
|
|
71
151
|
return "'" + s.replace("'", "'\\''") + "'"
|
|
@@ -81,6 +161,11 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
|
|
|
81
161
|
def probe(path: str) -> Dict[str, Any]:
|
|
82
162
|
"""Return a compact, script-friendly description of a media file."""
|
|
83
163
|
if not os.path.exists(path):
|
|
164
|
+
if STATE["dry_run"]:
|
|
165
|
+
return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
|
|
166
|
+
"video": {"codec": None, "width": 0, "height": 0, "fps": None, "pix_fmt": None, "hdr": False,
|
|
167
|
+
"color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
|
|
168
|
+
"audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
|
|
84
169
|
die(f"input not found: {path}")
|
|
85
170
|
ffprobe = require_tool("ffprobe")
|
|
86
171
|
proc = run(
|
|
@@ -102,6 +187,8 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
102
187
|
duration = _to_float(video.get("duration"))
|
|
103
188
|
if duration is None and audio:
|
|
104
189
|
duration = _to_float(audio.get("duration"))
|
|
190
|
+
if duration and STATE.get("duration_hint") is None:
|
|
191
|
+
STATE["duration_hint"] = duration
|
|
105
192
|
|
|
106
193
|
out: Dict[str, Any] = {
|
|
107
194
|
"file": path,
|
|
@@ -132,6 +219,12 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
132
219
|
trc = video.get("color_transfer") or ""
|
|
133
220
|
prim = video.get("color_primaries") or ""
|
|
134
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
|
|
135
228
|
out["video"] = {
|
|
136
229
|
"codec": video.get("codec_name"),
|
|
137
230
|
"profile": video.get("profile"),
|
|
@@ -145,7 +238,9 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
145
238
|
"pix_fmt": video.get("pix_fmt"),
|
|
146
239
|
"bit_depth": 10 if "10" in pix else (12 if "12" in pix else 8),
|
|
147
240
|
"hdr": hdr,
|
|
148
|
-
"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,
|
|
149
244
|
"color_space": video.get("color_space"),
|
|
150
245
|
"color_primaries": video.get("color_primaries"),
|
|
151
246
|
"color_transfer": video.get("color_transfer"),
|
|
@@ -257,6 +352,45 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
|
|
|
257
352
|
return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
|
|
258
353
|
|
|
259
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
|
+
|
|
260
394
|
def print_json(obj: Any) -> None:
|
|
261
395
|
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
|
|
262
396
|
|
package/scripts/audio.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
from typing import List
|
|
17
17
|
|
|
18
|
-
from _common import audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
|
|
18
|
+
from _common import add_common, apply_common, emit, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
|
|
19
19
|
|
|
20
20
|
VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
|
|
21
21
|
|
|
@@ -43,7 +43,9 @@ def main() -> int:
|
|
|
43
43
|
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
44
44
|
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
45
45
|
ap.add_argument("--bitrate", default="192k")
|
|
46
|
+
add_common(ap)
|
|
46
47
|
args = ap.parse_args()
|
|
48
|
+
apply_common(args)
|
|
47
49
|
|
|
48
50
|
meta = probe(args.input)
|
|
49
51
|
dur = meta.get("duration") or 0.0
|
|
@@ -120,7 +122,7 @@ def main() -> int:
|
|
|
120
122
|
r = probe(output)
|
|
121
123
|
a = r["audio"]
|
|
122
124
|
info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz)")
|
|
123
|
-
|
|
125
|
+
emit(output)
|
|
124
126
|
return 0
|
|
125
127
|
|
|
126
128
|
|
package/scripts/caption.py
CHANGED
|
@@ -22,7 +22,7 @@ import re
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import List, Tuple
|
|
24
24
|
|
|
25
|
-
from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
|
|
25
|
+
from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
|
|
26
26
|
|
|
27
27
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
28
28
|
|
|
@@ -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,11 +235,15 @@ 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)
|
|
178
243
|
enc.add_argument("--preset", default="medium")
|
|
244
|
+
add_common(ap)
|
|
179
245
|
args = ap.parse_args()
|
|
246
|
+
apply_common(args)
|
|
180
247
|
|
|
181
248
|
if not (args.srt or args.ass or args.text):
|
|
182
249
|
die("give one of --srt, --ass or --text")
|
|
@@ -204,7 +271,7 @@ def main() -> int:
|
|
|
204
271
|
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
205
272
|
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
206
273
|
w, h = h, w
|
|
207
|
-
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)
|
|
208
275
|
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
209
276
|
args.ass = ass_path
|
|
210
277
|
|
|
@@ -240,7 +307,7 @@ def main() -> int:
|
|
|
240
307
|
run(cmd)
|
|
241
308
|
result = probe(output)
|
|
242
309
|
info(f"wrote {output} ({result.get('duration'):.3f}s)")
|
|
243
|
-
|
|
310
|
+
emit(output)
|
|
244
311
|
return 0
|
|
245
312
|
|
|
246
313
|
|