ffmpeg-skill 0.1.0 → 0.2.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 +20 -15
- package/SKILL.md +78 -27
- package/package.json +2 -2
- package/scripts/_common.py +21 -0
- package/scripts/audio.py +128 -0
- package/scripts/caption.py +95 -4
- package/scripts/color.py +117 -0
- package/scripts/cut.py +12 -8
- package/scripts/export.py +5 -1
- package/scripts/fit.py +12 -1
- package/scripts/overlay.py +2 -2
- package/scripts/sync.py +120 -25
package/README.md
CHANGED
|
@@ -17,9 +17,12 @@ 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
|
-
- **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file.
|
|
21
|
-
- **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.
|
|
22
|
-
- **
|
|
20
|
+
- **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 styles for short-form video.
|
|
21
|
+
- **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
|
+
- **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
|
+
- **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** — real HDR10/HLG → SDR BT.709 tone mapping, 3D LUT (.cube) for Log footage and looks, and metadata-only retagging.
|
|
25
|
+
- **Audio post** — voice clean-up chain (highpass, de-esser, FFT denoise, compressor), background music with sidechain ducking, fades, 5.1 → stereo downmix, track replacement.
|
|
23
26
|
- **Loudness** — two-pass EBU R128 normalisation to −14 LUFS (or any target) with true-peak ceiling.
|
|
24
27
|
- **Overlays** — logos, watermarks and titles with position, time range, opacity and fades.
|
|
25
28
|
- **Export presets** — YouTube, Instagram Reels/Shorts/TikTok, X, ProRes 422 HQ master, H.265, GIF — all tagged BT.709.
|
|
@@ -59,14 +62,14 @@ Once installed, just talk to your agent. Five things you can say to Claude Code:
|
|
|
59
62
|
|
|
60
63
|
1. **"Take `interview.mp4`, keep 0:45–3:10 and 5:00–6:30, and make it exactly 60 seconds for Reels."**
|
|
61
64
|
→ `probe.py` → `cut.py --segments 0:45-3:10,5:00-6:30` → `fit.py --duration 60 --aspect 9:16 --fit crop` → `export.py --preset reels` → `probe.py` to confirm 60.0 s at 1080×1920.
|
|
62
|
-
2. **"Burn these captions in,
|
|
63
|
-
→ `caption.py --text cues.txt --font "Noto Sans CJK JP" --
|
|
64
|
-
3. **"The lav mic recording is out of sync with the camera — fix it and normalise to −14 LUFS."**
|
|
65
|
-
→ `sync.py camera.mp4 lav.wav --replace-audio` → `loudness.py` → report the detected offset and final LUFS.
|
|
65
|
+
2. **"Burn these captions in TikTok style, words popping in with a yellow highlight, in Japanese."**
|
|
66
|
+
→ `caption.py --text cues.txt --font "Noto Sans CJK JP" --animate pop --karaoke --highlight-color FFD200`.
|
|
67
|
+
3. **"The lav mic recording is out of sync with the camera and drifts over the hour — fix it, clean up the hiss and normalise to −14 LUFS."**
|
|
68
|
+
→ `sync.py camera.mp4 lav.wav --fix-drift --replace-audio` → `audio.py --voice` → `loudness.py` → report the detected offset, drift ppm and final LUFS.
|
|
66
69
|
4. **"Put our logo in the top-right corner for the whole video at 80% opacity, and a title card for the first 4 seconds."**
|
|
67
70
|
→ `overlay.py --image logo.png --position top-right --scale 220 --opacity 0.8` → `overlay.py --text "…" --start 0 --end 4 --fade 0.4`.
|
|
68
|
-
5. **"
|
|
69
|
-
→ `export.py --preset
|
|
71
|
+
5. **"This iPhone HDR clip looks washed out on YouTube — fix it and give me a ProRes master too."**
|
|
72
|
+
→ `probe.py` (shows `hdr: true`) → `color.py --to-sdr` → `export.py --preset youtube` and `export.py --preset prores`.
|
|
70
73
|
|
|
71
74
|
The scripts also work on their own:
|
|
72
75
|
|
|
@@ -81,11 +84,13 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
|
|
|
81
84
|
|
|
82
85
|
| Script | What it does |
|
|
83
86
|
|--------|--------------|
|
|
84
|
-
| `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, colour space, audio channels as JSON |
|
|
87
|
+
| `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format, colour space, rotation, audio channels as JSON |
|
|
85
88
|
| `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
|
|
86
|
-
| `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text |
|
|
87
|
-
| `fit.py` | Fit to a duration (speed or trim) and/or aspect ratio (pad or crop), force constant fps |
|
|
88
|
-
| `sync.py` | Detect offset between two recordings by audio cross-correlation; output aligned video/audio |
|
|
89
|
+
| `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text; animated + karaoke ASS |
|
|
90
|
+
| `fit.py` | Fit to a duration (speed or trim, smooth slow-mo) and/or aspect ratio (pad or crop), force constant fps |
|
|
91
|
+
| `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 BT.709 tone mapping, 3D LUT application, colour-tag rewriting |
|
|
93
|
+
| `audio.py` | Denoise / voice chain, music bed with auto-ducking, fades, downmix, replace track |
|
|
89
94
|
| `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP (or custom), video stream-copied |
|
|
90
95
|
| `overlay.py` | Composite image/logo or drawtext title with position, time range, opacity, fade |
|
|
91
96
|
| `export.py` | Presets: `youtube`, `youtube4k`, `reels`, `x`, `prores`, `h265`, `gif` |
|
|
@@ -94,7 +99,7 @@ All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stder
|
|
|
94
99
|
|
|
95
100
|
## Requirements
|
|
96
101
|
|
|
97
|
-
- FFmpeg 5.0+ with `libx264`, `libx265`, `libass` and `
|
|
102
|
+
- FFmpeg 5.0+ with `libx264`, `libx265`, `libass`, `prores_ks` and `libzimg` (for `color.py --to-sdr`); the default builds from Homebrew, apt and gyan.dev include all of them
|
|
98
103
|
- Python 3.9+
|
|
99
104
|
- Node 16+ only for the `npx` installer
|
|
100
105
|
|
|
@@ -102,7 +107,7 @@ All scripts: Python 3.9+, standard library only, `--help`, non-zero exit + stder
|
|
|
102
107
|
|
|
103
108
|
```bash
|
|
104
109
|
bash examples/make_demo.sh # generates footage, runs every script, rebuilds assets/demo.gif
|
|
105
|
-
python3 tests/test_all.py # end-to-end tests (needs ffmpeg)
|
|
110
|
+
python3 tests/test_all.py # end-to-end tests incl. VFR, rotated, 5.1, 10-bit HDR10 and drifting sources (needs ffmpeg)
|
|
106
111
|
node bin/install.js --dir /tmp/skills # try the installer without touching ~/.claude
|
|
107
112
|
```
|
|
108
113
|
|
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, fit to duration/aspect, sync multicam audio, normalise loudness, overlay logos and export platform presets, all from Python stdlib scripts with no cloud or API keys.
|
|
3
|
+
description: Professional video editing with local FFmpeg — cut, caption (animated/karaoke), fit to duration/aspect, sync multicam audio with drift correction, HDR-to-SDR and LUT colour, denoise/duck/mix audio, normalise loudness, overlay logos and export platform presets, all from Python stdlib scripts with no cloud or API keys.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# ffmpeg-skill
|
|
@@ -23,8 +23,8 @@ 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. **Chain operations in a sensible order.**
|
|
27
|
-
sync
|
|
26
|
+
3. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
|
|
27
|
+
fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
|
|
28
28
|
text so captions are sized for the final frame. Re-encode as few times as
|
|
29
29
|
possible: if several re-encoding steps are needed, keep intermediates at
|
|
30
30
|
CRF 18 (the default) and only use `export.py` for the last step.
|
|
@@ -51,6 +51,17 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
|
|
|
51
51
|
| "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
|
|
52
52
|
| "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
|
|
53
53
|
| "make a GIF preview" | `export.py input.mp4 --preset gif` |
|
|
54
|
+
| "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
|
|
55
|
+
| "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
|
|
56
|
+
| "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (no re-encode) |
|
|
57
|
+
| "clean up the audio", "remove the hiss / room noise" | `audio.py input.mp4 --voice` (speech) or `--denoise` |
|
|
58
|
+
| "add background music under the talking" | `audio.py input.mp4 --music bed.mp3 --duck --fade-out 3` |
|
|
59
|
+
| "convert the 5.1 to stereo" | `audio.py input.mov --downmix` |
|
|
60
|
+
| "swap in the narration track" | `audio.py input.mp4 --replace narration.wav` |
|
|
61
|
+
| "the audio drifts out of sync over the hour" | `sync.py camera.mp4 recorder.wav --fix-drift --replace-audio` |
|
|
62
|
+
| "smooth slow motion", "half speed but fluid" | `fit.py input.mp4 --duration 2x --smooth interpolate` (slow) or `--smooth blend` |
|
|
63
|
+
| "TikTok-style captions with the words popping / highlighted" | `caption.py input.mp4 --text cues.txt --animate pop --karaoke` |
|
|
64
|
+
| "it's a phone video with variable frame rate" | nothing extra: every re-encoding script conforms VFR to constant fps automatically; `fit.py --fps 30` to pick the rate |
|
|
54
65
|
|
|
55
66
|
## Scripts
|
|
56
67
|
|
|
@@ -79,18 +90,27 @@ fit.py INPUT [--duration T --method speed|trim [--from-center] [--max-speed 4]]
|
|
|
79
90
|
[--fps N] [-o OUT]
|
|
80
91
|
```
|
|
81
92
|
`speed` retimes video and audio together (pitch-preserving `atempo`); it
|
|
82
|
-
refuses factors beyond `--max-speed`.
|
|
83
|
-
|
|
93
|
+
refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
|
|
94
|
+
(frame blending, fast) or `--smooth interpolate` (motion-compensated
|
|
95
|
+
`minterpolate`, fluid but roughly 10-20x slower than realtime). `trim` keeps
|
|
96
|
+
the head (or the middle with `--from-center`). `--fps` forces a constant frame
|
|
97
|
+
rate; VFR sources are conformed automatically even without it.
|
|
84
98
|
|
|
85
|
-
### caption.py — subtitles
|
|
99
|
+
### caption.py — subtitles (static, animated, karaoke)
|
|
86
100
|
```
|
|
87
101
|
caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
|
|
88
102
|
[--font NAME] [--fonts-dir DIR] [--size N] [--color RRGGBB] [--outline N] [--outline-color RRGGBB]
|
|
89
|
-
[--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
|
|
103
|
+
[--bold] [--box] [--position bottom|top|center|top-left|...] [--margin N]
|
|
104
|
+
[--animate none|fade|pop|slide] [--karaoke [--highlight-color RRGGBB]] [--write-ass OUT.ass] [-o OUT]
|
|
90
105
|
caption.py --text CUES.txt --write-srt OUT.srt # generate the SRT only
|
|
91
106
|
```
|
|
92
107
|
Text cue format, one per line: `0:00-0:03 Hello`, `00:00:03.500 --> 00:00:06 Two | lines`.
|
|
93
108
|
Lines without a time run for `--auto-seconds` (3 s) after the previous cue. `|` is a line break.
|
|
109
|
+
`--animate`/`--karaoke` generate a styled ASS (PlayRes = video size) from the
|
|
110
|
+
SRT/text cues: `pop` is the short-form "bouncy" entrance, `--karaoke` fills each
|
|
111
|
+
word from `--color` to `--highlight-color` evenly across the cue (word timing
|
|
112
|
+
is distributed, not transcribed). The ASS is kept next to the output so the
|
|
113
|
+
user can hand-tune timings and re-run with `--ass`.
|
|
94
114
|
|
|
95
115
|
### overlay.py — logo, image, title
|
|
96
116
|
```
|
|
@@ -99,17 +119,47 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
|
|
|
99
119
|
```
|
|
100
120
|
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps playing.
|
|
101
121
|
|
|
102
|
-
### sync.py — offset detection
|
|
122
|
+
### sync.py — offset detection, alignment, drift correction
|
|
103
123
|
```
|
|
104
|
-
sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120]
|
|
124
|
+
sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
|
|
105
125
|
[--replace-audio | --trim-second] [-o OUT]
|
|
106
126
|
```
|
|
107
|
-
Cross-correlates loudness envelopes
|
|
108
|
-
|
|
109
|
-
the
|
|
127
|
+
Cross-correlates loudness envelopes: coarse FFT search (20 ms), then a direct
|
|
128
|
+
1 ms refinement (pure Python, a 2-minute window takes ~1-3 s). Positive offset
|
|
129
|
+
= the second recording started later. `--replace-audio` writes the reference
|
|
130
|
+
video with the second file's audio aligned (video stream copied).
|
|
110
131
|
`--trim-second` writes the second file shifted to the reference timeline.
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
`--fix-drift` measures the offset again near the end of the overlap, reports
|
|
133
|
+
the clock difference in ppm, and resamples the second file so a 60-minute
|
|
134
|
+
take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
|
|
135
|
+
Use it whenever the recording is longer than ~10 minutes. Check `confidence`
|
|
136
|
+
(0–1); below ~0.3 the match is doubtful — use a window with a clear event.
|
|
137
|
+
|
|
138
|
+
### color.py — HDR to SDR, LUTs, colour tags
|
|
139
|
+
```
|
|
140
|
+
color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
|
|
141
|
+
color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
|
|
142
|
+
color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
|
|
143
|
+
```
|
|
144
|
+
`--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
|
|
145
|
+
(default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
|
|
146
|
+
broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
|
|
147
|
+
input is not HDR unless `--force`. `--lut` applies a 3D .cube with
|
|
148
|
+
tetrahedral interpolation (Log→709 conversions, creative looks); blend with
|
|
149
|
+
`--lut-strength`. Everything else in the skill assumes SDR BT.709, so run this
|
|
150
|
+
first on HDR or Log sources.
|
|
151
|
+
|
|
152
|
+
### audio.py — clean-up, music, ducking, layout
|
|
153
|
+
```
|
|
154
|
+
audio.py INPUT [--voice | --denoise [--denoise-strength 25]] [--gain dB]
|
|
155
|
+
[--music FILE [--music-volume -14] [--duck [--duck-amount 12]] [--music-loop]]
|
|
156
|
+
[--fade-in S] [--fade-out S] [--stereo | --mono | --downmix] [--replace FILE] [-o OUT]
|
|
157
|
+
```
|
|
158
|
+
`--voice` = highpass 80 Hz → de-esser → FFT denoise → gentle compressor, the
|
|
159
|
+
standard talking-head chain. `--duck` uses a sidechain compressor keyed by the
|
|
160
|
+
speech so music dips under dialogue and swells in pauses. `--downmix` uses the
|
|
161
|
+
ITU centre/LFE weights for 5.1/7.1 → stereo. Video is always stream-copied.
|
|
162
|
+
Run `loudness.py` after this for final levels.
|
|
113
163
|
|
|
114
164
|
### loudness.py — EBU R128 normalisation
|
|
115
165
|
```
|
|
@@ -131,21 +181,22 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
|
|
|
131
181
|
|
|
132
182
|
- **Variable frame rate (phone/screen recordings).** `probe.py` sets
|
|
133
183
|
`variable_frame_rate_suspected` when `r_frame_rate` and `avg_frame_rate`
|
|
134
|
-
disagree.
|
|
135
|
-
`
|
|
136
|
-
|
|
184
|
+
disagree. Every re-encoding script then adds `-fps_mode cfr` at the source's
|
|
185
|
+
average rate, and `cut.py` switches itself to `--accurate` (copy-cuts on VFR
|
|
186
|
+
are unreliable). Pick the rate explicitly with `fit.py --fps 30|60` when the
|
|
187
|
+
average is odd (e.g. 23.4 fps from dropped frames).
|
|
137
188
|
- **Audio drift / sync.** Don't mix files with different frame rates or sample
|
|
138
189
|
rates in one `cut.py --segments` join without re-encoding (`--accurate`).
|
|
139
|
-
After `sync.py`, verify by running it again on the output:
|
|
140
|
-
be ~0.
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
BT.
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
190
|
+
After `sync.py`, verify by running it again on the output: offset (and drift
|
|
191
|
+
ppm with `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from
|
|
192
|
+
separate devices: always use `--fix-drift`.
|
|
193
|
+
- **Colour.** All H.264/H.265 outputs are tagged BT.709 and `yuv420p`. When
|
|
194
|
+
`probe.py` reports `hdr: true` (`hdr_format` HDR10/PQ, HLG or BT.2020), run
|
|
195
|
+
`color.py --to-sdr` **first**; other scripts would tag the HDR picture as
|
|
196
|
+
BT.709 and it would look flat and desaturated (`export.py` warns about this).
|
|
197
|
+
For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
|
|
198
|
+
tagged SDR) apply the manufacturer's `.cube` with `color.py --lut`. Keep
|
|
199
|
+
ProRes masters at source colour: `export.py --preset prores` does not retag.
|
|
149
200
|
- **CJK and other non-Latin text.** libass and drawtext need a font that has
|
|
150
201
|
the glyphs. Check with `fc-list | grep -i cjk`. Then either name it
|
|
151
202
|
(`caption.py --font "Noto Sans CJK JP"`) or point at the file
|
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. No API keys, no cloud, no dependencies.",
|
|
3
|
+
"version": "0.2.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.",
|
|
5
5
|
"keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "kajisho5",
|
package/scripts/_common.py
CHANGED
|
@@ -128,6 +128,10 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
128
128
|
rotation = int(video["tags"]["rotate"])
|
|
129
129
|
except ValueError:
|
|
130
130
|
pass
|
|
131
|
+
pix = video.get("pix_fmt") or ""
|
|
132
|
+
trc = video.get("color_transfer") or ""
|
|
133
|
+
prim = video.get("color_primaries") or ""
|
|
134
|
+
hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
|
|
131
135
|
out["video"] = {
|
|
132
136
|
"codec": video.get("codec_name"),
|
|
133
137
|
"profile": video.get("profile"),
|
|
@@ -139,6 +143,9 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
139
143
|
"avg_frame_rate": video.get("avg_frame_rate"),
|
|
140
144
|
"variable_frame_rate_suspected": vfr,
|
|
141
145
|
"pix_fmt": video.get("pix_fmt"),
|
|
146
|
+
"bit_depth": 10 if "10" in pix else (12 if "12" in pix else 8),
|
|
147
|
+
"hdr": hdr,
|
|
148
|
+
"hdr_format": ("HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
|
|
142
149
|
"color_space": video.get("color_space"),
|
|
143
150
|
"color_primaries": video.get("color_primaries"),
|
|
144
151
|
"color_transfer": video.get("color_transfer"),
|
|
@@ -208,6 +215,20 @@ def escape_drawtext(text: str) -> str:
|
|
|
208
215
|
)
|
|
209
216
|
|
|
210
217
|
|
|
218
|
+
def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> List[str]:
|
|
219
|
+
"""Force a constant frame rate on output when the source looks VFR (or fps is given).
|
|
220
|
+
|
|
221
|
+
VFR sources (phone/screen recordings) drift against audio after cuts and joins,
|
|
222
|
+
so every re-encoding script passes this to conform them automatically.
|
|
223
|
+
"""
|
|
224
|
+
v = (meta or {}).get("video") or {}
|
|
225
|
+
if fps is None and not v.get("variable_frame_rate_suspected"):
|
|
226
|
+
return []
|
|
227
|
+
rate = fps or v.get("fps") or 30.0
|
|
228
|
+
rate = round(rate) if abs(rate - round(rate)) < 0.02 else rate
|
|
229
|
+
return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
|
|
230
|
+
|
|
231
|
+
|
|
211
232
|
def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) -> List[str]:
|
|
212
233
|
args = ["-c:v", "libx264", "-preset", preset, "-crf", str(crf), "-pix_fmt", "yuv420p", "-movflags", "+faststart"]
|
|
213
234
|
if keep_bt709:
|
package/scripts/audio.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Audio post: denoise, voice clean-up, background music with auto-ducking,
|
|
3
|
+
fades and stereo/mono handling. Video is stream-copied.
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
python3 audio.py interview.mp4 --denoise # FFT noise reduction
|
|
7
|
+
python3 audio.py interview.mp4 --voice # highpass + de-esser + compressor + denoise
|
|
8
|
+
python3 audio.py talk.mp4 --music bed.mp3 --duck # music under speech, auto-ducked
|
|
9
|
+
python3 audio.py talk.mp4 --music bed.mp3 --music-volume -18 --fade-out 3
|
|
10
|
+
python3 audio.py clip.mp4 --fade-in 0.5 --fade-out 1 --stereo
|
|
11
|
+
python3 audio.py surround.mov --downmix # 5.1 -> stereo with proper centre/LFE weights
|
|
12
|
+
python3 audio.py clip.mp4 --replace narration.wav # swap the audio track entirely
|
|
13
|
+
"""
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from _common import audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
|
|
19
|
+
|
|
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
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> int:
|
|
24
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
25
|
+
ap.add_argument("input")
|
|
26
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_audio.<ext>)")
|
|
27
|
+
clean = ap.add_argument_group("clean-up")
|
|
28
|
+
clean.add_argument("--denoise", action="store_true", help="FFT noise reduction (afftdn, adaptive)")
|
|
29
|
+
clean.add_argument("--denoise-strength", type=float, default=25.0, help="noise floor in dB to remove, 10..60 (default 25)")
|
|
30
|
+
clean.add_argument("--voice", action="store_true", help="speech preset: highpass 80 Hz, de-esser, denoise, gentle compression")
|
|
31
|
+
clean.add_argument("--gain", type=float, help="gain in dB applied to the main track")
|
|
32
|
+
music = ap.add_argument_group("music")
|
|
33
|
+
music.add_argument("--music", help="music file to mix underneath")
|
|
34
|
+
music.add_argument("--music-volume", type=float, default=-14.0, help="music level in dB relative to full scale (default -14)")
|
|
35
|
+
music.add_argument("--duck", action="store_true", help="auto-duck the music when the main track has speech (sidechain compressor)")
|
|
36
|
+
music.add_argument("--duck-amount", type=float, default=12.0, help="how many dB to duck (default 12)")
|
|
37
|
+
music.add_argument("--music-loop", action="store_true", help="loop the music if shorter than the video")
|
|
38
|
+
fades = ap.add_argument_group("fades / layout")
|
|
39
|
+
fades.add_argument("--fade-in", type=float, default=0.0, help="seconds")
|
|
40
|
+
fades.add_argument("--fade-out", type=float, default=0.0, help="seconds")
|
|
41
|
+
fades.add_argument("--stereo", action="store_true", help="force 2-channel output (mono is duplicated to both sides)")
|
|
42
|
+
fades.add_argument("--mono", action="store_true", help="force 1-channel output")
|
|
43
|
+
fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
|
|
44
|
+
fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
|
|
45
|
+
ap.add_argument("--bitrate", default="192k")
|
|
46
|
+
args = ap.parse_args()
|
|
47
|
+
|
|
48
|
+
meta = probe(args.input)
|
|
49
|
+
dur = meta.get("duration") or 0.0
|
|
50
|
+
has_video = bool(meta.get("video"))
|
|
51
|
+
if not meta.get("audio") and not args.replace:
|
|
52
|
+
die("input has no audio stream (use --replace to add one)")
|
|
53
|
+
output = args.output or default_output(args.input, "audio")
|
|
54
|
+
|
|
55
|
+
inputs: List[str] = ["-i", args.input]
|
|
56
|
+
main_src = "0:a:0"
|
|
57
|
+
idx = 1
|
|
58
|
+
if args.replace:
|
|
59
|
+
probe(args.replace)
|
|
60
|
+
inputs += ["-i", args.replace]
|
|
61
|
+
main_src = f"{idx}:a:0"
|
|
62
|
+
idx += 1
|
|
63
|
+
|
|
64
|
+
fx: List[str] = []
|
|
65
|
+
if args.downmix:
|
|
66
|
+
fx.append("pan=stereo|FL=0.707*FC+FL+0.5*BL+0.5*SL+0.5*LFE|FR=0.707*FC+FR+0.5*BR+0.5*SR+0.5*LFE")
|
|
67
|
+
if args.voice:
|
|
68
|
+
fx.append(VOICE_CHAIN)
|
|
69
|
+
elif args.denoise:
|
|
70
|
+
fx.append(f"afftdn=nf=-{args.denoise_strength:g}:tn=1")
|
|
71
|
+
if args.gain:
|
|
72
|
+
fx.append(f"volume={args.gain:g}dB")
|
|
73
|
+
if args.mono:
|
|
74
|
+
fx.append("pan=mono|c0=0.5*c0+0.5*c1")
|
|
75
|
+
elif args.stereo:
|
|
76
|
+
fx.append("aformat=channel_layouts=stereo")
|
|
77
|
+
|
|
78
|
+
graph: List[str] = []
|
|
79
|
+
graph.append(f"[{main_src}]{','.join(fx) if fx else 'anull'}[main]")
|
|
80
|
+
last = "main"
|
|
81
|
+
|
|
82
|
+
if args.music:
|
|
83
|
+
probe(args.music)
|
|
84
|
+
if args.music_loop:
|
|
85
|
+
inputs += ["-stream_loop", "-1", "-i", args.music]
|
|
86
|
+
else:
|
|
87
|
+
inputs += ["-i", args.music]
|
|
88
|
+
m = f"{idx}:a:0"
|
|
89
|
+
idx += 1
|
|
90
|
+
mfx = [f"volume={args.music_volume:g}dB", f"atrim=0:{dur:.3f}" if dur else "anull"]
|
|
91
|
+
if args.fade_out:
|
|
92
|
+
mfx.append(f"afade=t=out:st={max(0.0, dur - args.fade_out):.3f}:d={args.fade_out:g}")
|
|
93
|
+
graph.append(f"[{m}]{','.join(mfx)}[music]")
|
|
94
|
+
if args.duck:
|
|
95
|
+
graph.append("[main]asplit=2[mainA][sc]")
|
|
96
|
+
graph.append(
|
|
97
|
+
f"[music][sc]sidechaincompress=threshold=0.05:ratio={max(2.0, args.duck_amount / 3):.1f}:attack=20:release=400:makeup=1[ducked]"
|
|
98
|
+
)
|
|
99
|
+
graph.append("[mainA][ducked]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
100
|
+
else:
|
|
101
|
+
graph.append("[main][music]amix=inputs=2:duration=first:dropout_transition=2:normalize=0[mix]")
|
|
102
|
+
last = "mix"
|
|
103
|
+
|
|
104
|
+
post: List[str] = []
|
|
105
|
+
if args.fade_in:
|
|
106
|
+
post.append(f"afade=t=in:st=0:d={args.fade_in:g}")
|
|
107
|
+
if args.fade_out and dur:
|
|
108
|
+
post.append(f"afade=t=out:st={max(0.0, dur - args.fade_out):.3f}:d={args.fade_out:g}")
|
|
109
|
+
if args.replace and dur:
|
|
110
|
+
post.append(f"apad,atrim=0:{dur:.3f}")
|
|
111
|
+
if post:
|
|
112
|
+
graph.append(f"[{last}]{','.join(post)}[out]")
|
|
113
|
+
last = "out"
|
|
114
|
+
|
|
115
|
+
cmd = ffmpeg_base() + inputs + ["-filter_complex", ";".join(graph), "-map", f"[{last}]"]
|
|
116
|
+
if has_video:
|
|
117
|
+
cmd += ["-map", "0:v:0", "-c:v", "copy"]
|
|
118
|
+
cmd += audio_codec_for(output, args.bitrate) + ["-shortest", output]
|
|
119
|
+
run(cmd)
|
|
120
|
+
r = probe(output)
|
|
121
|
+
a = r["audio"]
|
|
122
|
+
info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz)")
|
|
123
|
+
print(output)
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
if __name__ == "__main__":
|
|
128
|
+
sys.exit(main())
|
package/scripts/caption.py
CHANGED
|
@@ -11,6 +11,7 @@ Text-to-SRT input format (one cue per line, blank lines ignored):
|
|
|
11
11
|
|
|
12
12
|
Examples:
|
|
13
13
|
python3 caption.py input.mp4 --srt subs.srt
|
|
14
|
+
python3 caption.py input.mp4 --text cues.txt --animate pop --karaoke # word-by-word highlight, TikTok style
|
|
14
15
|
python3 caption.py input.mp4 --srt subs.srt --font "Noto Sans CJK JP" --size 28 --position top
|
|
15
16
|
python3 caption.py --text cues.txt --write-srt cues.srt # only produce the SRT
|
|
16
17
|
python3 caption.py input.mp4 --text cues.txt # generate + burn in one go
|
|
@@ -21,7 +22,7 @@ import re
|
|
|
21
22
|
import sys
|
|
22
23
|
from typing import List, Tuple
|
|
23
24
|
|
|
24
|
-
from _common import aac_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
|
|
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
26
|
|
|
26
27
|
ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
|
|
27
28
|
|
|
@@ -58,12 +59,84 @@ def parse_text_cues(path: str, auto_seconds: float, gap: float) -> List[Tuple[fl
|
|
|
58
59
|
return cues
|
|
59
60
|
|
|
60
61
|
|
|
62
|
+
def parse_srt(path: str) -> List[Tuple[float, float, str]]:
|
|
63
|
+
cues: List[Tuple[float, float, str]] = []
|
|
64
|
+
block: List[str] = []
|
|
65
|
+
with open(path, encoding="utf-8-sig") as fh:
|
|
66
|
+
content = fh.read().replace("\r\n", "\n") + "\n\n"
|
|
67
|
+
for line in content.split("\n"):
|
|
68
|
+
if line.strip():
|
|
69
|
+
block.append(line)
|
|
70
|
+
continue
|
|
71
|
+
if block:
|
|
72
|
+
times = next((b for b in block if "-->" in b), None)
|
|
73
|
+
if times:
|
|
74
|
+
a, b = times.split("-->")
|
|
75
|
+
text = "\n".join(block[block.index(times) + 1:]).strip()
|
|
76
|
+
cues.append((parse_time(a), parse_time(b), text))
|
|
77
|
+
block = []
|
|
78
|
+
if not cues:
|
|
79
|
+
die(f"no cues found in {path}")
|
|
80
|
+
return cues
|
|
81
|
+
|
|
82
|
+
|
|
61
83
|
def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
|
|
62
84
|
with open(path, "w", encoding="utf-8") as fh:
|
|
63
85
|
for i, (s, e, t) in enumerate(cues, 1):
|
|
64
86
|
fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
|
|
65
87
|
|
|
66
88
|
|
|
89
|
+
def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int) -> None:
|
|
90
|
+
"""Write a styled ASS file with optional animation and word-by-word highlight."""
|
|
91
|
+
def t(sec: float) -> str:
|
|
92
|
+
cs = int(round(sec * 100))
|
|
93
|
+
h, rem = divmod(cs, 360000)
|
|
94
|
+
m, rem = divmod(rem, 6000)
|
|
95
|
+
s_, cs = divmod(rem, 100)
|
|
96
|
+
return f"{h}:{m:02d}:{s_:02d}.{cs:02d}"
|
|
97
|
+
|
|
98
|
+
scale = play_h / 288.0 # our --size is relative to a 288-line script like force_style
|
|
99
|
+
size = int(round(args.size * scale))
|
|
100
|
+
margin = int(round(args.margin * scale))
|
|
101
|
+
# karaoke: PrimaryColour is the "sung" colour, SecondaryColour the "not yet sung" one
|
|
102
|
+
primary = ass_color(args.highlight_color if args.karaoke else args.color)
|
|
103
|
+
secondary = ass_color(args.color)
|
|
104
|
+
outline = ass_color(args.outline_color)
|
|
105
|
+
back = ass_color(args.outline_color, 0x80)
|
|
106
|
+
header = [
|
|
107
|
+
"[Script Info]", "ScriptType: v4.00+", f"PlayResX: {play_w}", f"PlayResY: {play_h}", "WrapStyle: 0", "ScaledBorderAndShadow: yes", "",
|
|
108
|
+
"[V4+ Styles]",
|
|
109
|
+
"Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding",
|
|
110
|
+
f"Style: Default,{args.font},{size},{primary},{secondary},{outline},{back},{-1 if args.bold else 0},0,0,0,100,100,0,0,{3 if args.box else 1},{args.outline * scale:.1f},{args.shadow * scale:.1f},{ALIGN[args.position]},{margin},{margin},{margin},1",
|
|
111
|
+
"", "[Events]", "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text",
|
|
112
|
+
]
|
|
113
|
+
lines = []
|
|
114
|
+
for start, end, text in cues:
|
|
115
|
+
text = text.replace("\n", "\\N")
|
|
116
|
+
fx = ""
|
|
117
|
+
if args.animate == "fade":
|
|
118
|
+
fx = "{\\fad(200,200)}"
|
|
119
|
+
elif args.animate == "pop":
|
|
120
|
+
fx = "{\\fad(80,120)\\fscx60\\fscy60\\t(0,120,\\fscx110\\fscy110)\\t(120,200,\\fscx100\\fscy100)}"
|
|
121
|
+
elif args.animate == "slide":
|
|
122
|
+
fx = "{\\fad(150,150)\\move(%d,%d,%d,%d,0,250)}" % (play_w // 2, play_h - margin + int(30 * scale), play_w // 2, play_h - margin)
|
|
123
|
+
body = text
|
|
124
|
+
if args.karaoke:
|
|
125
|
+
# split each line into words and give every word an equal share of the cue (\k is in centiseconds)
|
|
126
|
+
dur_cs = max(1, int(round((end - start) * 100)))
|
|
127
|
+
segments = body.split("\\N")
|
|
128
|
+
words = [w for seg in segments for w in seg.split(" ") if w]
|
|
129
|
+
per = max(1, dur_cs // max(1, len(words)))
|
|
130
|
+
out_segments = []
|
|
131
|
+
for seg in segments:
|
|
132
|
+
ws = [w for w in seg.split(" ") if w]
|
|
133
|
+
out_segments.append(" ".join(f"{{\\kf{per}}}{w}" for w in ws))
|
|
134
|
+
body = "\\N".join(out_segments)
|
|
135
|
+
lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
|
|
136
|
+
with open(path, "w", encoding="utf-8-sig") as fh:
|
|
137
|
+
fh.write("\n".join(header + lines) + "\n")
|
|
138
|
+
|
|
139
|
+
|
|
67
140
|
def ass_color(hex_rgb: str, alpha: int = 0) -> str:
|
|
68
141
|
h = hex_rgb.lstrip("#")
|
|
69
142
|
if len(h) != 6:
|
|
@@ -95,6 +168,11 @@ def main() -> int:
|
|
|
95
168
|
sty.add_argument("--position", choices=sorted(ALIGN), default="bottom", help="on-screen placement (default bottom)")
|
|
96
169
|
sty.add_argument("--margin", type=int, default=30, help="vertical margin from the edge (default 30)")
|
|
97
170
|
sty.add_argument("--box", action="store_true", help="draw an opaque box behind text instead of an outline")
|
|
171
|
+
anim = ap.add_argument_group("animation (generates ASS; needs --text or --srt input)")
|
|
172
|
+
anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default="none", help="per-cue entrance animation")
|
|
173
|
+
anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
|
|
174
|
+
anim.add_argument("--highlight-color", default="FFD200", help="karaoke fill colour RRGGBB (default FFD200)")
|
|
175
|
+
anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
|
|
98
176
|
enc = ap.add_argument_group("encoding")
|
|
99
177
|
enc.add_argument("--crf", type=int, default=18)
|
|
100
178
|
enc.add_argument("--preset", default="medium")
|
|
@@ -115,7 +193,20 @@ def main() -> int:
|
|
|
115
193
|
|
|
116
194
|
if not args.input:
|
|
117
195
|
die("input video is required unless you only use --text/--write-srt")
|
|
118
|
-
probe(args.input)
|
|
196
|
+
meta = probe(args.input)
|
|
197
|
+
if not meta.get("video"):
|
|
198
|
+
die("input has no video stream")
|
|
199
|
+
|
|
200
|
+
output = args.output or default_output(args.input, "captioned")
|
|
201
|
+
if (args.animate != "none" or args.karaoke) and not args.ass:
|
|
202
|
+
cues_for_ass = cues if args.text else parse_srt(srt_path)
|
|
203
|
+
ass_path = args.write_ass or os.path.splitext(output)[0] + ".ass"
|
|
204
|
+
w, h = meta["video"]["width"], meta["video"]["height"]
|
|
205
|
+
if meta["video"].get("rotation") in (90, -90, 270, -270):
|
|
206
|
+
w, h = h, w
|
|
207
|
+
write_ass(cues_for_ass, ass_path, args, w, h)
|
|
208
|
+
info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
|
|
209
|
+
args.ass = ass_path
|
|
119
210
|
|
|
120
211
|
if args.ass:
|
|
121
212
|
if not os.path.exists(args.ass):
|
|
@@ -144,8 +235,8 @@ def main() -> int:
|
|
|
144
235
|
if args.fonts_dir:
|
|
145
236
|
vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
|
|
146
237
|
|
|
147
|
-
|
|
148
|
-
cmd
|
|
238
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
|
|
239
|
+
cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
|
|
149
240
|
run(cmd)
|
|
150
241
|
result = probe(output)
|
|
151
242
|
info(f"wrote {output} ({result.get('duration'):.3f}s)")
|
package/scripts/color.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Colour management: convert HDR (HDR10/PQ, HLG, BT.2020) to SDR BT.709 with
|
|
3
|
+
real tone mapping, apply a .cube LUT (Log footage, creative grades), or fix
|
|
4
|
+
wrong colour tags without re-encoding.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
python3 color.py iphone_hdr.mov --to-sdr # PQ/HLG -> BT.709 SDR, hable tonemap
|
|
8
|
+
python3 color.py iphone_hdr.mov --to-sdr --tonemap mobius --peak 1000
|
|
9
|
+
python3 color.py slog3.mp4 --lut SLog3_to_Rec709.cube # apply LUT (any Log -> 709 or a look)
|
|
10
|
+
python3 color.py clip.mp4 --lut look.cube --lut-strength 0.6
|
|
11
|
+
python3 color.py wrongly_tagged.mp4 --retag bt709 # metadata only, stream copy
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from typing import List
|
|
17
|
+
|
|
18
|
+
from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, info, probe, run, x264_args
|
|
19
|
+
|
|
20
|
+
TONEMAPS = ["hable", "mobius", "reinhard", "bt2390", "clip", "linear", "gamma"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str:
|
|
24
|
+
v = meta["video"]
|
|
25
|
+
trc = v.get("color_transfer") or "smpte2084"
|
|
26
|
+
prim = v.get("color_primaries") or "bt2020"
|
|
27
|
+
space = v.get("color_space") or "bt2020nc"
|
|
28
|
+
# zscale needs explicit input tags when the file lacks them
|
|
29
|
+
chain: List[str] = [
|
|
30
|
+
f"zscale=tin={trc}:pin={prim}:min={space}:rin={v.get('color_range') or 'tv'}:t=linear:npl={peak:g}",
|
|
31
|
+
"format=gbrpf32le",
|
|
32
|
+
"zscale=p=bt709",
|
|
33
|
+
f"tonemap=tonemap={tonemap}:desat={desat:g}" + (":peak=%g" % (peak / 100.0) if tonemap in ("bt2390",) else ""),
|
|
34
|
+
"zscale=t=bt709:m=bt709:r=tv",
|
|
35
|
+
"format=yuv420p",
|
|
36
|
+
]
|
|
37
|
+
return ",".join(chain)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> int:
|
|
41
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
42
|
+
ap.add_argument("input")
|
|
43
|
+
ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
|
|
44
|
+
mode = ap.add_mutually_exclusive_group(required=True)
|
|
45
|
+
mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
|
|
46
|
+
mode.add_argument("--lut", help=".cube LUT to apply (3D)")
|
|
47
|
+
mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
|
|
48
|
+
ap.add_argument("--tonemap", choices=TONEMAPS, default="hable", help="tone-mapping curve (default hable)")
|
|
49
|
+
ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
|
|
50
|
+
ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
|
|
51
|
+
ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
|
|
52
|
+
ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
|
|
53
|
+
ap.add_argument("--crf", type=int, default=18)
|
|
54
|
+
ap.add_argument("--preset", default="medium")
|
|
55
|
+
args = ap.parse_args()
|
|
56
|
+
|
|
57
|
+
meta = probe(args.input)
|
|
58
|
+
if not meta.get("video"):
|
|
59
|
+
die("input has no video stream")
|
|
60
|
+
v = meta["video"]
|
|
61
|
+
has_audio = bool(meta.get("audio"))
|
|
62
|
+
|
|
63
|
+
if args.retag:
|
|
64
|
+
tags = {
|
|
65
|
+
"bt709": ["bt709", "bt709", "bt709"],
|
|
66
|
+
"bt2020-pq": ["bt2020nc", "bt2020", "smpte2084"],
|
|
67
|
+
"bt2020-hlg": ["bt2020nc", "bt2020", "arib-std-b67"],
|
|
68
|
+
"bt601": ["smpte170m", "smpte170m", "smpte170m"],
|
|
69
|
+
}[args.retag]
|
|
70
|
+
output = args.output or default_output(args.input, "retag")
|
|
71
|
+
ext = os.path.splitext(output)[1].lower()
|
|
72
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0", "-c", "copy",
|
|
73
|
+
"-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]]
|
|
74
|
+
if ext in (".mp4", ".mov", ".m4v"):
|
|
75
|
+
cmd += ["-movflags", "+faststart"]
|
|
76
|
+
cmd.append(output)
|
|
77
|
+
proc = run(cmd, check=False)
|
|
78
|
+
if proc.returncode != 0:
|
|
79
|
+
# some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
|
|
80
|
+
info("stream copy could not rewrite tags, re-encoding")
|
|
81
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a?"] + x264_args(args.crf, args.preset, keep_bt709=False)
|
|
82
|
+
cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
|
|
83
|
+
run(cmd)
|
|
84
|
+
info(f"wrote {output} (tags -> {args.retag})")
|
|
85
|
+
print(output)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
if args.to_sdr:
|
|
89
|
+
if not v.get("hdr") and not args.force:
|
|
90
|
+
die(f"{args.input} is not tagged as HDR (transfer={v.get('color_transfer')}, primaries={v.get('color_primaries')}). Use --force to tone-map anyway.")
|
|
91
|
+
vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
|
|
92
|
+
output = args.output or default_output(args.input, "sdr")
|
|
93
|
+
tag = "sdr"
|
|
94
|
+
else:
|
|
95
|
+
if not os.path.exists(args.lut):
|
|
96
|
+
die(f"LUT not found: {args.lut}")
|
|
97
|
+
lut = f"lut3d=file={escape_filter_path(args.lut)}:interp=tetrahedral"
|
|
98
|
+
if 0 < args.lut_strength < 1:
|
|
99
|
+
# blend graded and original
|
|
100
|
+
vf = f"split[o][g];[g]{lut}[g2];[o][g2]blend=all_mode=normal:all_opacity={args.lut_strength:g},format=yuv420p"
|
|
101
|
+
else:
|
|
102
|
+
vf = f"{lut},format=yuv420p"
|
|
103
|
+
output = args.output or default_output(args.input, "lut")
|
|
104
|
+
tag = "lut"
|
|
105
|
+
|
|
106
|
+
cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a?"]
|
|
107
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
|
|
108
|
+
run(cmd)
|
|
109
|
+
r = probe(output)
|
|
110
|
+
info(f"wrote {output} ({r['duration']:.3f}s, {r['video']['width']}x{r['video']['height']}, "
|
|
111
|
+
f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
|
|
112
|
+
print(output)
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
if __name__ == "__main__":
|
|
117
|
+
sys.exit(main())
|
package/scripts/cut.py
CHANGED
|
@@ -16,7 +16,7 @@ import sys
|
|
|
16
16
|
import tempfile
|
|
17
17
|
from typing import List, Tuple
|
|
18
18
|
|
|
19
|
-
from _common import aac_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
|
+
from _common import aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
@@ -37,26 +37,27 @@ def parse_segments(spec: str) -> List[Tuple[float, float]]:
|
|
|
37
37
|
return segs
|
|
38
38
|
|
|
39
39
|
|
|
40
|
-
def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5) -> bool:
|
|
40
|
+
def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: int, preset: str, tolerance: float = 0.5, meta: dict = None) -> bool:
|
|
41
41
|
"""Cut one segment. Returns True if the result was re-encoded."""
|
|
42
42
|
dur = end - start
|
|
43
|
+
meta = meta or probe(src)
|
|
43
44
|
if reencode:
|
|
44
45
|
cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"]
|
|
45
|
-
cmd += x264_args(crf, preset) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
|
|
46
|
+
cmd += x264_args(crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
|
|
46
47
|
else:
|
|
47
48
|
cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
|
|
48
49
|
proc = run(cmd, check=False)
|
|
49
50
|
if proc.returncode != 0:
|
|
50
51
|
if not reencode:
|
|
51
52
|
info("stream copy failed, falling back to re-encode")
|
|
52
|
-
return cut_one(src, start, end, dst, True, crf, preset, tolerance)
|
|
53
|
+
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
53
54
|
die(f"ffmpeg failed:\n{proc.stderr.strip()}")
|
|
54
55
|
if not reencode and tolerance >= 0:
|
|
55
56
|
got = probe(dst).get("duration") or 0.0
|
|
56
57
|
if abs(got - dur) > tolerance:
|
|
57
58
|
info(f"stream copy landed on a keyframe {abs(got - dur):.2f}s away from the requested cut "
|
|
58
59
|
f"(> {tolerance:.2f}s tolerance); re-encoding this segment for accuracy")
|
|
59
|
-
return cut_one(src, start, end, dst, True, crf, preset, tolerance)
|
|
60
|
+
return cut_one(src, start, end, dst, True, crf, preset, tolerance, meta)
|
|
60
61
|
return reencode
|
|
61
62
|
|
|
62
63
|
|
|
@@ -77,6 +78,9 @@ def main() -> int:
|
|
|
77
78
|
|
|
78
79
|
meta = probe(args.input)
|
|
79
80
|
total = meta.get("duration") or 0.0
|
|
81
|
+
if meta.get("video", {}) and meta["video"].get("variable_frame_rate_suspected") and not args.accurate:
|
|
82
|
+
info("source looks variable-frame-rate; lossless cuts on VFR are unreliable, switching to --accurate")
|
|
83
|
+
args.accurate = True
|
|
80
84
|
|
|
81
85
|
if args.segments:
|
|
82
86
|
segments = parse_segments(args.segments)
|
|
@@ -104,13 +108,13 @@ def main() -> int:
|
|
|
104
108
|
|
|
105
109
|
reencoded = False
|
|
106
110
|
if len(segments) == 1:
|
|
107
|
-
reencoded = cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance)
|
|
111
|
+
reencoded = cut_one(args.input, segments[0][0], segments[0][1], output, args.accurate, args.crf, args.preset, args.tolerance, meta)
|
|
108
112
|
else:
|
|
109
113
|
with tempfile.TemporaryDirectory(prefix="ffskill_cut_") as tmp:
|
|
110
114
|
parts = []
|
|
111
115
|
for i, (s, e) in enumerate(segments):
|
|
112
116
|
part = os.path.join(tmp, f"part{i:03d}{ext}")
|
|
113
|
-
reencoded |= cut_one(args.input, s, e, part, args.accurate, args.crf, args.preset, args.tolerance)
|
|
117
|
+
reencoded |= cut_one(args.input, s, e, part, args.accurate, args.crf, args.preset, args.tolerance, meta)
|
|
114
118
|
parts.append(part)
|
|
115
119
|
listfile = os.path.join(tmp, "list.txt")
|
|
116
120
|
with open(listfile, "w", encoding="utf-8") as fh:
|
|
@@ -120,7 +124,7 @@ def main() -> int:
|
|
|
120
124
|
proc = run(cmd, check=False)
|
|
121
125
|
if proc.returncode != 0:
|
|
122
126
|
info("concat with stream copy failed, re-encoding the join")
|
|
123
|
-
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + x264_args(args.crf, args.preset) + aac_args() + [output]
|
|
127
|
+
cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + x264_args(args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
|
|
124
128
|
run(cmd)
|
|
125
129
|
|
|
126
130
|
result = probe(output)
|
package/scripts/export.py
CHANGED
|
@@ -22,7 +22,7 @@ import argparse
|
|
|
22
22
|
import sys
|
|
23
23
|
from typing import Dict, List
|
|
24
24
|
|
|
25
|
-
from _common import default_output, die, ffmpeg_base, info, probe, run
|
|
25
|
+
from _common import cfr_args, default_output, die, ffmpeg_base, info, probe, run
|
|
26
26
|
|
|
27
27
|
PRESETS: Dict[str, Dict] = {
|
|
28
28
|
"youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
|
|
@@ -61,6 +61,8 @@ def main() -> int:
|
|
|
61
61
|
meta = probe(args.input)
|
|
62
62
|
if not meta.get("video"):
|
|
63
63
|
die("input has no video stream")
|
|
64
|
+
if meta["video"].get("hdr") and args.preset != "prores":
|
|
65
|
+
info("warning: source is HDR (%s). This preset outputs SDR BT.709 tags without tone mapping; run color.py --to-sdr first for correct colours." % meta["video"].get("hdr_format"))
|
|
64
66
|
has_audio = bool(meta.get("audio"))
|
|
65
67
|
output = args.output or default_output(args.input, args.preset, p["ext"])
|
|
66
68
|
|
|
@@ -91,6 +93,8 @@ def main() -> int:
|
|
|
91
93
|
if args.crf is not None and "-crf" in video:
|
|
92
94
|
video[video.index("-crf") + 1] = str(args.crf)
|
|
93
95
|
cmd += video
|
|
96
|
+
if "-r" not in video:
|
|
97
|
+
cmd += cfr_args(meta)
|
|
94
98
|
if args.preset not in ("prores",):
|
|
95
99
|
cmd += BT709
|
|
96
100
|
if p["ext"] == "mp4":
|
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 aac_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
22
|
+
from _common import 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
|
|
|
@@ -63,6 +63,8 @@ def main() -> int:
|
|
|
63
63
|
d.add_argument("--method", choices=["speed", "trim"], default="speed", help="how to reach the duration (default speed)")
|
|
64
64
|
d.add_argument("--from-center", action="store_true", help="with --method trim, keep the middle instead of the start")
|
|
65
65
|
d.add_argument("--max-speed", type=float, default=4.0, help="refuse speed factors above this (default 4x)")
|
|
66
|
+
d.add_argument("--smooth", choices=["none", "blend", "interpolate"], default="none",
|
|
67
|
+
help="slow-motion quality: blend (frame blending) or interpolate (motion-compensated, slow but fluid). default none = duplicate frames")
|
|
66
68
|
a = ap.add_argument_group("aspect")
|
|
67
69
|
a.add_argument("--aspect", help="target aspect ratio, e.g. 16:9, 9:16, 1:1, 4:5")
|
|
68
70
|
a.add_argument("--fit", choices=["pad", "crop"], default="pad", help="pad (letterbox) or crop to reach the aspect (default pad)")
|
|
@@ -103,6 +105,12 @@ def main() -> int:
|
|
|
103
105
|
die(f"required speed factor {factor:.2f}x exceeds --max-speed {args.max_speed}x; use --method trim or raise the limit")
|
|
104
106
|
if abs(factor - 1.0) > 1e-4:
|
|
105
107
|
vf.append(f"setpts={1/factor:.8f}*PTS")
|
|
108
|
+
src_fps = meta["video"].get("fps") or 30.0
|
|
109
|
+
if factor < 1.0 and args.smooth == "interpolate":
|
|
110
|
+
vf.append(f"minterpolate=fps={src_fps:g}:mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1")
|
|
111
|
+
elif factor < 1.0 and args.smooth == "blend":
|
|
112
|
+
vf.append(f"fps={src_fps:g}")
|
|
113
|
+
vf.append("tblend=all_mode=average")
|
|
106
114
|
if has_audio:
|
|
107
115
|
af.append(atempo_chain(factor))
|
|
108
116
|
post += ["-t", f"{target:.3f}"]
|
|
@@ -133,6 +141,8 @@ def main() -> int:
|
|
|
133
141
|
|
|
134
142
|
if args.fps:
|
|
135
143
|
vf.append(f"fps={args.fps:g}")
|
|
144
|
+
elif meta["video"].get("variable_frame_rate_suspected"):
|
|
145
|
+
info("source looks variable-frame-rate; conforming to constant fps automatically")
|
|
136
146
|
|
|
137
147
|
output = args.output or default_output(args.input, "fit")
|
|
138
148
|
cmd = ffmpeg_base() + pre_input + ["-i", args.input]
|
|
@@ -141,6 +151,7 @@ def main() -> int:
|
|
|
141
151
|
if af:
|
|
142
152
|
cmd += ["-af", ",".join(af)]
|
|
143
153
|
cmd += x264_args(args.crf, args.preset)
|
|
154
|
+
cmd += cfr_args(meta, args.fps) if not args.fps else []
|
|
144
155
|
if has_audio:
|
|
145
156
|
cmd += aac_args()
|
|
146
157
|
else:
|
package/scripts/overlay.py
CHANGED
|
@@ -15,7 +15,7 @@ import argparse
|
|
|
15
15
|
import sys
|
|
16
16
|
from typing import List, Optional
|
|
17
17
|
|
|
18
|
-
from _common import aac_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
18
|
+
from _common import aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
|
|
19
19
|
|
|
20
20
|
POS = {
|
|
21
21
|
"top-left": ("{m}", "{m}"),
|
|
@@ -157,7 +157,7 @@ def main() -> int:
|
|
|
157
157
|
opts.append(f"enable='{enable}'")
|
|
158
158
|
cmd += ["-vf", "drawtext=" + ":".join(opts)]
|
|
159
159
|
|
|
160
|
-
cmd += x264_args(args.crf, args.preset)
|
|
160
|
+
cmd += x264_args(args.crf, args.preset) + cfr_args(meta)
|
|
161
161
|
cmd += aac_args() if meta.get("audio") else ["-an"]
|
|
162
162
|
cmd.append(output)
|
|
163
163
|
run(cmd)
|
package/scripts/sync.py
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Detect the time offset between two recordings by audio cross-correlation
|
|
3
|
-
and (optionally) write a synced output.
|
|
3
|
+
and (optionally) write a synced output, with optional clock-drift correction.
|
|
4
4
|
|
|
5
5
|
Pure standard library: both tracks are decoded by ffmpeg to mono 8 kHz PCM,
|
|
6
|
-
reduced to a
|
|
7
|
-
|
|
8
|
-
5 ms), which is plenty for lining up a lav mic or a second camera.
|
|
6
|
+
reduced to a loudness envelope, cross-correlated with an FFT implemented in
|
|
7
|
+
Python (coarse, 20 ms), then refined by direct correlation at 1 ms.
|
|
9
8
|
|
|
10
9
|
Offset semantics: a positive offset means the SECOND input starts LATER
|
|
11
10
|
than the reference, i.e. `second` must be shifted earlier by that amount.
|
|
@@ -15,6 +14,7 @@ Examples:
|
|
|
15
14
|
python3 sync.py camera.mp4 lavmic.wav --replace-audio -o synced.mp4
|
|
16
15
|
python3 sync.py camA.mp4 camB.mp4 --trim-second -o camB_synced.mp4
|
|
17
16
|
python3 sync.py cam.mp4 mic.wav --max-offset 60 --json
|
|
17
|
+
python3 sync.py cam.mp4 recorder.wav --fix-drift --replace-audio # long takes: fix clock drift too
|
|
18
18
|
"""
|
|
19
19
|
import argparse
|
|
20
20
|
import cmath
|
|
@@ -31,9 +31,9 @@ from _common import aac_args, audio_codec_for, default_output, die, ffmpeg_base,
|
|
|
31
31
|
SR = 8000 # decode sample rate
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def decode_mono(path: str, seconds: float) -> List[float]:
|
|
34
|
+
def decode_mono(path: str, seconds: float, start: float = 0.0) -> List[float]:
|
|
35
35
|
ffmpeg = require_tool("ffmpeg")
|
|
36
|
-
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-t", f"{seconds:.3f}",
|
|
36
|
+
cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", path, "-t", f"{seconds:.3f}",
|
|
37
37
|
"-vn", "-ac", "1", "-ar", str(SR), "-f", "s16le", "-"]
|
|
38
38
|
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
39
39
|
if proc.returncode != 0 or not proc.stdout:
|
|
@@ -110,6 +110,45 @@ def cross_correlate(ref: List[float], other: List[float], max_lag: int):
|
|
|
110
110
|
return best_lag, best_val / energy
|
|
111
111
|
|
|
112
112
|
|
|
113
|
+
def refine(ref_s: List[float], oth_s: List[float], coarse_offset: float, fine_step: int, window_s: float) -> float:
|
|
114
|
+
"""Direct correlation at fine resolution around a coarse estimate (+/- window_s)."""
|
|
115
|
+
ref_e = envelope(ref_s, fine_step)
|
|
116
|
+
oth_e = envelope(oth_s, fine_step)
|
|
117
|
+
centre = int(round(coarse_offset * SR / fine_step))
|
|
118
|
+
span = int(window_s * SR / fine_step)
|
|
119
|
+
best_lag, best_val = centre, -float("inf")
|
|
120
|
+
n = min(len(ref_e), len(oth_e))
|
|
121
|
+
for lag in range(centre - span, centre + span + 1):
|
|
122
|
+
# ref[i] ~ oth[i - lag]
|
|
123
|
+
lo, hi = max(0, lag), min(n, n + lag)
|
|
124
|
+
if hi - lo < 10:
|
|
125
|
+
continue
|
|
126
|
+
val = 0.0
|
|
127
|
+
for i in range(lo, hi):
|
|
128
|
+
val += ref_e[i] * oth_e[i - lag]
|
|
129
|
+
val /= (hi - lo)
|
|
130
|
+
if val > best_val:
|
|
131
|
+
best_val, best_lag = val, lag
|
|
132
|
+
return best_lag * fine_step / SR
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def measure_offset(ref_path: str, oth_path: str, start: float, seconds: float, step_ms: float, max_offset: float, fine_ms: float):
|
|
136
|
+
"""Return (offset_seconds, confidence) for a window starting at `start` in both files."""
|
|
137
|
+
ref_s = decode_mono(ref_path, seconds, start)
|
|
138
|
+
oth_s = decode_mono(oth_path, seconds, start)
|
|
139
|
+
step = max(1, int(SR * step_ms / 1000))
|
|
140
|
+
ref = envelope(ref_s, step)
|
|
141
|
+
oth = envelope(oth_s, step)
|
|
142
|
+
if len(ref) < 10 or len(oth) < 10:
|
|
143
|
+
die("not enough audio to analyse")
|
|
144
|
+
max_lag = int(max_offset * SR / step)
|
|
145
|
+
lag, score = cross_correlate(ref, oth, max_lag)
|
|
146
|
+
offset = lag * step / SR
|
|
147
|
+
if fine_ms and fine_ms < step_ms:
|
|
148
|
+
offset = refine(ref_s, oth_s, offset, max(1, int(SR * fine_ms / 1000)), step_ms / 1000 * 2)
|
|
149
|
+
return offset, max(0.0, min(1.0, score))
|
|
150
|
+
|
|
151
|
+
|
|
113
152
|
def main() -> int:
|
|
114
153
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
115
154
|
ap.add_argument("reference", help="reference recording (usually the camera video)")
|
|
@@ -117,7 +156,10 @@ def main() -> int:
|
|
|
117
156
|
ap.add_argument("-o", "--output", help="output file when writing a synced result")
|
|
118
157
|
ap.add_argument("--max-offset", type=float, default=30.0, help="largest offset to search in seconds (default 30)")
|
|
119
158
|
ap.add_argument("--analyze-seconds", type=float, default=120.0, help="how much audio to analyse from each file (default 120)")
|
|
120
|
-
ap.add_argument("--step-ms", type=float, default=
|
|
159
|
+
ap.add_argument("--step-ms", type=float, default=20.0, help="coarse envelope resolution in ms for the FFT search (default 20)")
|
|
160
|
+
ap.add_argument("--fine-ms", type=float, default=1.0, help="fine resolution in ms for the refinement pass, 0 to skip (default 1)")
|
|
161
|
+
ap.add_argument("--fix-drift", action="store_true", help="also measure the offset near the END and correct clock drift by resampling the second file")
|
|
162
|
+
ap.add_argument("--drift-window", type=float, default=60.0, help="seconds of audio analysed at each end for drift (default 60)")
|
|
121
163
|
ap.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
122
164
|
mode = ap.add_mutually_exclusive_group()
|
|
123
165
|
mode.add_argument("--replace-audio", action="store_true", help="write reference video with the second file's audio, aligned")
|
|
@@ -129,18 +171,47 @@ def main() -> int:
|
|
|
129
171
|
if not probe(p).get("audio"):
|
|
130
172
|
die(f"{p} has no audio stream to correlate")
|
|
131
173
|
|
|
132
|
-
|
|
133
|
-
ref = envelope(decode_mono(args.reference, args.analyze_seconds), step)
|
|
134
|
-
oth = envelope(decode_mono(args.second, args.analyze_seconds), step)
|
|
135
|
-
if len(ref) < 10 or len(oth) < 10:
|
|
136
|
-
die("not enough audio to analyse")
|
|
174
|
+
offset, score = measure_offset(args.reference, args.second, 0.0, args.analyze_seconds, args.step_ms, args.max_offset, args.fine_ms)
|
|
137
175
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
176
|
+
drift_ratio = 1.0
|
|
177
|
+
drift_info = None
|
|
178
|
+
if args.fix_drift:
|
|
179
|
+
ref_dur = probe(args.reference)["duration"] or 0.0
|
|
180
|
+
sec_dur = probe(args.second)["duration"] or 0.0
|
|
181
|
+
overlap_end = min(ref_dur, sec_dur + offset) # last reference time both files cover
|
|
182
|
+
head_len = min(args.analyze_seconds, overlap_end)
|
|
183
|
+
tail_start = overlap_end - args.drift_window
|
|
184
|
+
if tail_start <= head_len / 2 + 5:
|
|
185
|
+
info("warning: files too short to measure drift reliably; skipping drift correction")
|
|
186
|
+
else:
|
|
187
|
+
ref_start = tail_start
|
|
188
|
+
sec_start = tail_start - offset
|
|
189
|
+
if sec_start < 0:
|
|
190
|
+
ref_start -= sec_start
|
|
191
|
+
sec_start = 0.0
|
|
192
|
+
ref_s = decode_mono(args.reference, args.drift_window, ref_start)
|
|
193
|
+
oth_s = decode_mono(args.second, args.drift_window, sec_start)
|
|
194
|
+
step = max(1, int(SR * args.step_ms / 1000))
|
|
195
|
+
lag, end_score = cross_correlate(envelope(ref_s, step), envelope(oth_s, step), int(2.0 * SR / step))
|
|
196
|
+
residual = lag * step / SR
|
|
197
|
+
if args.fine_ms:
|
|
198
|
+
residual = refine(ref_s, oth_s, residual, max(1, int(SR * args.fine_ms / 1000)), args.step_ms / 1000 * 2)
|
|
199
|
+
# both measurements represent the offset at the centre of their windows
|
|
200
|
+
head_mid = head_len / 2
|
|
201
|
+
tail_mid = ref_start + args.drift_window / 2
|
|
202
|
+
elapsed = tail_mid - head_mid
|
|
203
|
+
if elapsed > 0 and end_score > 0.1:
|
|
204
|
+
# offset(T) = offset0 - (ratio - 1) * T, where ratio is how fast the second file's clock
|
|
205
|
+
# runs relative to the reference (ratio > 1 = the second file is too long / plays slow)
|
|
206
|
+
drift_ratio = 1.0 - residual / elapsed
|
|
207
|
+
offset = offset + (drift_ratio - 1.0) * head_mid # extrapolate back to T = 0
|
|
208
|
+
drift_info = {"residual_at_end_seconds": round(residual, 4), "measured_over_seconds": round(elapsed, 2),
|
|
209
|
+
"drift_ppm": round((drift_ratio - 1) * 1e6, 1),
|
|
210
|
+
"meaning": "second file runs %.1f ppm %s (%.3fs over %.0fs); it will be resampled to match" % (
|
|
211
|
+
abs(drift_ratio - 1) * 1e6, "long/slow" if drift_ratio > 1 else "short/fast", abs(residual), elapsed),
|
|
212
|
+
"confidence": round(end_score, 3)}
|
|
213
|
+
else:
|
|
214
|
+
info("warning: could not measure drift with confidence; skipping drift correction")
|
|
144
215
|
|
|
145
216
|
result = {
|
|
146
217
|
"reference": args.reference,
|
|
@@ -149,6 +220,8 @@ def main() -> int:
|
|
|
149
220
|
"confidence": round(max(0.0, min(1.0, score)), 3),
|
|
150
221
|
"meaning": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
|
|
151
222
|
}
|
|
223
|
+
if drift_info:
|
|
224
|
+
result["drift"] = drift_info
|
|
152
225
|
if result["confidence"] < 0.1:
|
|
153
226
|
info("warning: low correlation confidence; check that both files contain the same audio event")
|
|
154
227
|
|
|
@@ -160,14 +233,20 @@ def main() -> int:
|
|
|
160
233
|
head_trim = -offset if offset < 0 else 0.0
|
|
161
234
|
second_meta = probe(args.second)
|
|
162
235
|
has_video = bool(second_meta.get("video"))
|
|
236
|
+
sec_sr = (second_meta.get("audio") or {}).get("sample_rate") or 48000
|
|
237
|
+
drift_af: List[str] = []
|
|
238
|
+
if abs(drift_ratio - 1.0) > 1e-7:
|
|
239
|
+
# the second file runs long by drift_ratio -> play it faster by that ratio (pitch shift is ~ppm, inaudible)
|
|
240
|
+
drift_af = [f"asetrate={sec_sr * drift_ratio:.6f}", f"aresample={sec_sr}"]
|
|
163
241
|
|
|
164
242
|
if args.replace_audio:
|
|
165
243
|
cmd = ffmpeg_base() + ["-i", args.reference]
|
|
166
244
|
if head_trim > 0:
|
|
167
245
|
cmd += ["-ss", f"{head_trim:.4f}"]
|
|
168
246
|
cmd += ["-i", args.second, "-map", "0:v:0", "-map", "1:a:0", "-c:v", "copy"]
|
|
169
|
-
if delay_ms > 0
|
|
170
|
-
|
|
247
|
+
af_parts = drift_af + ([f"adelay={delay_ms}:all=1"] if delay_ms > 0 else [])
|
|
248
|
+
if af_parts:
|
|
249
|
+
cmd += ["-af", ",".join(af_parts)]
|
|
171
250
|
cmd += aac_args() + ["-shortest", output]
|
|
172
251
|
proc = run(cmd, check=False)
|
|
173
252
|
if proc.returncode != 0:
|
|
@@ -176,18 +255,32 @@ def main() -> int:
|
|
|
176
255
|
cmd = cmd[:-1] + x264_args(args.crf) + [output]
|
|
177
256
|
run(cmd)
|
|
178
257
|
else:
|
|
179
|
-
if head_trim > 0:
|
|
258
|
+
if head_trim > 0 and not drift_af:
|
|
180
259
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
|
|
181
260
|
proc = run(cmd, check=False)
|
|
182
261
|
if proc.returncode != 0:
|
|
183
262
|
cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (x264_args(args.crf) if has_video else []) + audio_codec_for(output) + [output]
|
|
184
263
|
run(cmd)
|
|
185
264
|
else:
|
|
186
|
-
|
|
187
|
-
|
|
265
|
+
cmd = ffmpeg_base()
|
|
266
|
+
if head_trim > 0:
|
|
267
|
+
cmd += ["-ss", f"{head_trim:.4f}"]
|
|
268
|
+
cmd += ["-i", args.second]
|
|
269
|
+
af_parts = list(drift_af)
|
|
270
|
+
if delay_ms > 0:
|
|
271
|
+
af_parts.append(f"adelay={delay_ms}:all=1")
|
|
188
272
|
if has_video:
|
|
189
|
-
|
|
190
|
-
|
|
273
|
+
vf = []
|
|
274
|
+
if delay_ms > 0:
|
|
275
|
+
vf.append(f"tpad=start_duration={offset:.4f}")
|
|
276
|
+
if drift_af:
|
|
277
|
+
vf.append(f"setpts=PTS/{drift_ratio:.9f}")
|
|
278
|
+
if vf:
|
|
279
|
+
cmd += ["-vf", ",".join(vf)]
|
|
280
|
+
cmd += x264_args(args.crf)
|
|
281
|
+
if af_parts:
|
|
282
|
+
cmd += ["-af", ",".join(af_parts)]
|
|
283
|
+
cmd += audio_codec_for(output) + [output]
|
|
191
284
|
run(cmd)
|
|
192
285
|
result["output"] = output
|
|
193
286
|
info(f"wrote {output}")
|
|
@@ -196,6 +289,8 @@ def main() -> int:
|
|
|
196
289
|
print(json.dumps(result, indent=2))
|
|
197
290
|
else:
|
|
198
291
|
print(f"offset: {result['offset_seconds']:+.3f}s ({result['meaning']}), confidence {result['confidence']:.2f}")
|
|
292
|
+
if drift_info:
|
|
293
|
+
print(f"drift: {drift_info['drift_ppm']:+.1f} ppm ({drift_info['residual_at_end_seconds']:+.3f}s over {drift_info['measured_over_seconds']:.0f}s), confidence {drift_info['confidence']:.2f}")
|
|
199
294
|
if "output" in result:
|
|
200
295
|
print(result["output"])
|
|
201
296
|
return 0
|