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 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
- - **Multicam / external-audio sync** — offset detection by cross-correlation implemented in pure Python (no numpy).
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, white text with a black outline at the top, in Japanese."**
63
- → `caption.py --text cues.txt --font "Noto Sans CJK JP" --position top --outline 3`.
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. **"Give me a ProRes master and an H.265 copy of the final cut."**
69
- → `export.py --preset prores` and `export.py --preset h265`.
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 `prores_ks` (the default builds from Homebrew, apt and gyan.dev include all of them)
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.** Cutfitcaption/overlay
27
- sync/loudness → export. Do the destructive/aspect changes before burning
26
+ 3. **Chain operations in a sensible order.** Colour (HDRSDR / 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`. `trim` keeps the head (or the middle with
83
- `--from-center`). `--fps` forces constant frame rate use it on VFR sources.
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] [-o OUT]
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 and alignment
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 (pure Python FFT, ~5 ms resolution).
108
- Positive offset = the second recording started later. `--replace-audio` writes
109
- the reference video with the second file's audio aligned (video stream copied).
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
- Check `confidence` (0–1); below ~0.3 the match is doubtful try a longer
112
- `--analyze-seconds` or a window that contains a clear event (clap).
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. VFR causes audio drift and bad concat joins. Fix it early with
135
- `fit.py --fps 30` (or 60) before cutting with `--accurate`, captioning or
136
- syncing; add `--accurate` to `cut.py` because copy-cuts on VFR are unreliable.
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: the offset should
140
- be ~0. If dialogue drifts over a long recording, the clocks differ; align the
141
- start with `sync.py`, then fix drift with `fit.py --duration` on the audio-only
142
- track to match the reference length.
143
- - **Colour.** All H.264/H.265 outputs are tagged BT.709 (`-colorspace`,
144
- `-color_primaries`, `-color_trc`) and `yuv420p`. If `probe.py` shows a
145
- BT.2020/HDR source (`color_transfer` smpte2084 or arib-std-b67), tell the
146
- user the output will be tagged as SDR BT.709 without tone mapping, and add
147
- `-vf zscale=t=linear:npl=100,tonemap=hable,zscale=p=bt709:t=bt709:m=bt709`
148
- manually with ffmpeg if they need a proper SDR conversion.
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.1.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",
@@ -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:
@@ -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())
@@ -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
- output = args.output or default_output(args.input, "captioned")
148
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + x264_args(args.crf, args.preset) + aac_args() + [output]
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)")
@@ -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:
@@ -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 coarse loudness envelope, and cross-correlated with an FFT
7
- implemented in Python. Precision is roughly +/- one envelope step (default
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=5.0, help="envelope resolution in ms (default 5)")
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
- step = max(1, int(SR * args.step_ms / 1000))
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
- max_lag = int(args.max_offset * SR / step)
139
- lag, score = cross_correlate(ref, oth, max_lag)
140
- # With prod = FFT(ref) * conj(FFT(other)), the peak sits at lag k where ref[i] ~ other[i - k]:
141
- # the same event happens k steps later in the reference than in the second file, which
142
- # means the second recording STARTED k steps later. Positive offset = second starts later.
143
- offset = lag * step / SR
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
- cmd += ["-af", f"adelay={delay_ms}:all=1"]
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
- af = f"adelay={delay_ms}:all=1"
187
- cmd = ffmpeg_base() + ["-i", args.second]
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
- cmd += ["-vf", f"tpad=start_duration={offset:.4f}"] + x264_args(args.crf)
190
- cmd += ["-af", af] + audio_codec_for(output) + [output]
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