ffmpeg-skill 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +15 -4
  2. package/SKILL.md +80 -6
  3. package/package.json +2 -2
  4. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  5. package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
  6. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  7. package/scripts/__pycache__/color.cpython-311.pyc +0 -0
  8. package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
  9. package/scripts/__pycache__/export.cpython-311.pyc +0 -0
  10. package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
  11. package/scripts/__pycache__/join.cpython-311.pyc +0 -0
  12. package/scripts/__pycache__/look.cpython-311.pyc +0 -0
  13. package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
  14. package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
  15. package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
  16. package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
  17. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  20. package/scripts/_common.py +137 -3
  21. package/scripts/audio.py +4 -2
  22. package/scripts/caption.py +73 -6
  23. package/scripts/color.py +24 -3
  24. package/scripts/cut.py +5 -3
  25. package/scripts/export.py +5 -3
  26. package/scripts/fit.py +5 -2
  27. package/scripts/join.py +111 -0
  28. package/scripts/look.py +101 -0
  29. package/scripts/loudness.py +4 -2
  30. package/scripts/multicam.py +196 -0
  31. package/scripts/overlay.py +4 -2
  32. package/scripts/probe.py +10 -1
  33. package/scripts/silence.py +123 -0
  34. package/scripts/sync.py +4 -3
  35. package/scripts/verify.py +157 -0
package/README.md CHANGED
@@ -17,11 +17,17 @@ npx ffmpeg-skill
17
17
  - **Probe first, verify last** — the skill forces the agent to read real duration/fps/resolution before editing and to check the result after, so you get "final.mp4: 59.98 s, 1080×1920, 30 fps" instead of guesses.
18
18
  - **Lossless when possible** — cuts and joins use stream copy by default; re-encoding only happens when it must (frame-accurate cuts, filters, format changes).
19
19
  - **Cut & join** segments with `mm:ss` / `hh:mm:ss.ms` times.
20
- - **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.
20
+ - **Multicam** — align any number of cameras and recorders by audio (with drift correction) and cut between them from a switch list.
21
+ - **Real-footage verification kit** — run the whole toolchain on your own device files and get a PASS/FAIL report.
22
+ - **Silence removal / jump cuts** — detect dead air, keep a margin around speech, render frame-accurate in one pass; export the cut list for hand editing.
23
+ - **Join with transitions** — crossfade, wipes, fade-to-black between mismatched clips (any size, fps, audio layout).
24
+ - **Agent eyes** — contact sheets, single frames and before/after comparisons as PNG so the agent verifies caption placement, crops and colour visually.
25
+ - **Plan before render** — every script has `--dry-run` (print the ffmpeg commands), `--json` (structured result with a probe of the output), `--fast` (preview quality) and `--progress` (percent / ETA).
26
+ - **Captions** — burn SRT/ASS with font, size, colour, outline and position control; generate SRT from a plain timed-text file; animated (fade/pop/slide) and word-by-word karaoke highlight timed to the speech energy in the audio.
21
27
  - **Fit** to an exact duration (pitch-preserving speed change or trim) and to 16:9 / 9:16 / 1:1 / 4:5 by padding or cropping; motion-interpolated or blended slow motion.
22
28
  - **Real-world footage handling** — variable-frame-rate phone clips are conformed to constant fps automatically, rotation metadata is honoured, 10-bit HEVC and 5.1 sources are handled.
23
29
  - **Multicam / external-audio sync** — offset detection by cross-correlation implemented in pure Python (no numpy), 1 ms resolution, plus clock-drift correction for long takes.
24
- - **Colour management** — real HDR10/HLG → SDR BT.709 tone mapping, 3D LUT (.cube) for Log footage and looks, and metadata-only retagging.
30
+ - **Colour management** — HDR10 / HLG / Dolby Vision (iPhone) → SDR BT.709 tone mapping, Dolby Vision layer stripping, 3D LUT (.cube) for Log footage and looks, Log-footage detection, metadata-only retagging.
25
31
  - **Audio post** — voice clean-up chain (highpass, de-esser, FFT denoise, compressor), background music with sidechain ducking, fades, 5.1 → stereo downmix, track replacement.
26
32
  - **Loudness** — two-pass EBU R128 normalisation to −14 LUFS (or any target) with true-peak ceiling.
27
33
  - **Overlays** — logos, watermarks and titles with position, time range, opacity and fades.
@@ -84,12 +90,17 @@ More examples: [examples/README.md](examples/README.md). To see everything run e
84
90
 
85
91
  | Script | What it does |
86
92
  |--------|--------------|
87
- | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format, colour space, rotation, audio channels as JSON |
93
+ | `probe.py` | Duration, fps (+ VFR detection), resolution, codecs, bit depth, HDR format incl. Dolby Vision, colour space, rotation, audio channels as JSON; `--analyze` flags Log footage |
88
94
  | `cut.py` | In/out or multi-segment cuts, lossless `-c copy` first, re-encode fallback, `--accurate` for frame-exact |
95
+ | `multicam.py` | Align cameras/recorders by audio and switch between them from a time list |
96
+ | `verify.py` | Run the toolchain on real device files and report PASS/FAIL per step |
97
+ | `silence.py` | Detect and remove silences (jump cuts), list or export the cut list |
98
+ | `join.py` | Concatenate clips with xfade transitions, normalising size, fps and audio |
99
+ | `look.py` | Contact sheet, single frames, side-by-side comparison as PNG for visual checks |
89
100
  | `caption.py` | Burn SRT/ASS (font, size, colour, outline, position); build SRT from timed plain text; animated + karaoke ASS |
90
101
  | `fit.py` | Fit to a duration (speed or trim, smooth slow-mo) and/or aspect ratio (pad or crop), force constant fps |
91
102
  | `sync.py` | Detect offset between two recordings by audio cross-correlation (1 ms), correct clock drift; output aligned video/audio |
92
- | `color.py` | HDR10/HLG → SDR BT.709 tone mapping, 3D LUT application, colour-tag rewriting |
103
+ | `color.py` | HDR10/HLG/Dolby Vision → SDR tone mapping, DV layer stripping, 3D LUT application, colour-tag rewriting |
93
104
  | `audio.py` | Denoise / voice chain, music bed with auto-ducking, fades, downmix, replace track |
94
105
  | `loudness.py` | Two-pass EBU R128 `loudnorm` to −14 LUFS / −1 dBTP (or custom), video stream-copied |
95
106
  | `overlay.py` | Composite image/logo or drawtext title with position, time range, opacity, fade |
package/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: ffmpeg-skill
3
- description: Professional video editing with local FFmpeg — cut, caption (animated/karaoke), fit to duration/aspect, sync 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.
3
+ description: Professional video editing with local FFmpeg — cut, remove silences, join with transitions, multicam switching, caption (animated/karaoke timed to speech), fit to duration/aspect, sync audio with drift correction, HDR/HLG/Dolby Vision to SDR, LUTs and Log detection, denoise/duck/mix audio, loudness, overlays, platform exports, frame inspection and a real-footage verification kit; Python stdlib scripts, no cloud or API keys.
4
4
  ---
5
5
 
6
6
  # ffmpeg-skill
@@ -23,15 +23,21 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
23
23
  (plain cuts on keyframes, remuxing, audio-only changes), do not re-encode.
24
24
  `cut.py` and `loudness.py` stream-copy video by default; only pass
25
25
  `--accurate` to `cut.py` when the user needs frame-exact cuts.
26
- 3. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
26
+ 3. **Plan with `--dry-run --json`, then execute.** Every script accepts
27
+ `--dry-run` (prints the ffmpeg commands, runs nothing) and `--json`
28
+ (structured result: output path, probe of the output, commands run). Use
29
+ them to confirm a plan before long encodes and to report exact facts.
30
+ `--fast` gives a quick preview-quality render (x264 veryfast), `--progress`
31
+ prints percent and ETA on stderr for long encodes.
32
+ 4. **Chain operations in a sensible order.** Colour (HDR→SDR / LUT) → cut →
27
33
  fit → caption/overlay → sync → audio → loudness → export. Do the destructive/aspect changes before burning
28
34
  text so captions are sized for the final frame. Re-encode as few times as
29
35
  possible: if several re-encoding steps are needed, keep intermediates at
30
36
  CRF 18 (the default) and only use `export.py` for the last step.
31
- 4. **Verify the output.** Run `probe.py` on each result and confirm duration,
37
+ 5. **Verify the output.** Run `probe.py` on each result and confirm duration,
32
38
  resolution, fps and audio match what was requested. Report those numbers to
33
39
  the user (e.g. "final.mp4: 59.98 s, 1080x1920, 30 fps, AAC stereo").
34
- 5. **Keep the user's originals.** Never overwrite the source file. Write new
40
+ 6. **Keep the user's originals.** Never overwrite the source file. Write new
35
41
  files next to the input or where the user asked.
36
42
 
37
43
  ## Request → script
@@ -51,6 +57,15 @@ path on stdout, and defaults the output name to `<input>_<operation>.<ext>`.
51
57
  | "fix the audio levels", "normalise to -14 LUFS" | `loudness.py input.mp4` (`-I -16 --tp -1.5` for podcasts, `-I -23` for broadcast) |
52
58
  | "export for YouTube / Reels / X", "give me a ProRes master", "make it HEVC" | `export.py input.mp4 --preset youtube|reels|x|prores|h265` |
53
59
  | "make a GIF preview" | `export.py input.mp4 --preset gif` |
60
+ | "cut out the pauses / dead air", "tighten it up", "jump cuts" | `silence.py input.mp4 [--threshold -40 --min-silence 0.8]` |
61
+ | "stitch these clips together", "add a crossfade between them" | `join.py a.mp4 b.mp4 c.mp4 --transition fade --duration 0.5` |
62
+ | "show me what it looks like", "check the captions are readable" | `look.py output.mp4` then view the PNG |
63
+ | "what would you run?", "don't render yet" | any script with `--dry-run` |
64
+ | "three cameras, cut between them" | `multicam.py camA.mp4 camB.mp4 camC.mp4 --switch "0-20:0,20-40:1,40-60:2"` |
65
+ | "it's an iPhone Dolby Vision clip and players show it wrong" | `color.py clip.mov --to-sdr` or `color.py clip.mov --strip-dovi` (keep HDR, drop the DV layer) |
66
+ | "does it look like Log / S-Log / flat footage?" | `probe.py clip.mp4 --analyze` (`looks_like_log`) then `color.py --lut` |
67
+ | "test the tool on my real files" | `verify.py ~/Footage --report verify.md` |
68
+ | "show me progress", "quick preview first" | any encoding script with `--progress` and/or `--fast` |
54
69
  | "the colours look washed out / it's an iPhone HDR video" | `color.py input.mov --to-sdr` (probe shows `hdr: true`) |
55
70
  | "apply this LUT", "convert the S-Log / V-Log footage" | `color.py input.mp4 --lut grade.cube [--lut-strength 0.7]` |
56
71
  | "the colours are tagged wrong" | `color.py input.mp4 --retag bt709` (no re-encode) |
@@ -96,6 +111,59 @@ refuses factors beyond `--max-speed`. For slow motion add `--smooth blend`
96
111
  the head (or the middle with `--from-center`). `--fps` forces a constant frame
97
112
  rate; VFR sources are conformed automatically even without it.
98
113
 
114
+ ### silence.py — remove dead air / jump cuts
115
+ ```
116
+ silence.py INPUT [--threshold -35] [--min-silence 0.6] [--margin 0.15] [--min-keep 0.2] [--list] [--edl keep.txt] [-o OUT]
117
+ ```
118
+ Runs `silencedetect`, keeps `--margin` seconds of air around speech, drops
119
+ gaps shorter than `--min-silence`, and re-encodes once with `select`/`aselect`
120
+ (frame accurate). `--list` prints silences, kept ranges and seconds removed
121
+ without rendering; `--edl` saves the kept ranges in `cut.py --segments` format
122
+ so the user can edit the list by hand. Quiet rooms need `--threshold -40`
123
+ to `-45`; noisy ones `-30`. Always tell the user how many seconds were removed.
124
+
125
+ ### join.py — concatenate with transitions
126
+ ```
127
+ join.py CLIP1 CLIP2 [...] [--transition fade|dissolve|wipeleft|slideleft|fadeblack|fadewhite|circleopen|none]
128
+ [--duration 0.5] [--width W --height H] [--fps N] [--fit pad|crop] [-o OUT]
129
+ ```
130
+ Normalises every clip to one frame size, fps, `yuv420p` and 48 kHz stereo
131
+ (silent track generated for clips without audio), then chains `xfade` +
132
+ `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
133
+ longer than 2 × the transition. Use `--transition none` for a plain cut.
134
+
135
+ ### multicam.py — align several cameras and switch between them
136
+ ```
137
+ multicam.py REF CAM2 [CAM3 ...] [--switch "START-END:CAM,..."] | [--auto N] [--audio IDX] [--fix-drift]
138
+ [--offsets-only] [--width W --height H --fps N] [-o OUT]
139
+ ```
140
+ All inputs are aligned to the first one by audio (same engine as `sync.py`,
141
+ `--fix-drift` for long takes). `--switch` names which camera is on screen for
142
+ each range of the reference timeline (gaps fall back to camera 0), `--auto N`
143
+ simply alternates every N seconds. Audio comes from the reference unless
144
+ `--audio` picks another input, e.g. an external recorder that has no video.
145
+ `--offsets-only` reports offsets and confidence without rendering.
146
+
147
+ ### verify.py — real-footage verification kit
148
+ ```
149
+ verify.py FILES_OR_FOLDERS [--quick] [--report verify.md] [--out DIR --keep] [--seconds 6] [--json]
150
+ ```
151
+ Runs the toolchain on the user's own files (phone HDR, GoPro, OBS, Log, Zoom)
152
+ and prints a PASS/FAIL table per step (probe, copy cut, accurate cut, fit,
153
+ caption, overlay, look, export, loudness, silence, plus `color --to-sdr` for
154
+ HDR and `audio --downmix` for >2 channels). Exit code 1 if anything fails.
155
+ Run this first when a user hands over footage from a device you have not
156
+ seen before, and fix or report what fails.
157
+
158
+ ### look.py — see the result
159
+ ```
160
+ look.py INPUT [--tiles 4x3] [--width 1280] [-o sheet.png] # contact sheet with timecodes
161
+ look.py INPUT --at 2.5 [--at 7] [-o basename] # single frames -> basename_2.500s.png
162
+ look.py BEFORE --compare AFTER --at 4 [-o cmp.png] # side-by-side frame
163
+ ```
164
+ Outputs PNG. View it with the Read tool (or any image viewer) and judge the
165
+ frame like an editor would. Use `--compare` to show before/after to the user.
166
+
99
167
  ### caption.py — subtitles (static, animated, karaoke)
100
168
  ```
101
169
  caption.py INPUT --srt FILE | --ass FILE | --text CUES.txt [--write-srt OUT.srt]
@@ -135,12 +203,17 @@ take stays in sync (typical consumer devices drift 20-500 ppm = up to 1.8 s/h).
135
203
  Use it whenever the recording is longer than ~10 minutes. Check `confidence`
136
204
  (0–1); below ~0.3 the match is doubtful — use a window with a clear event.
137
205
 
138
- ### color.py — HDR to SDR, LUTs, colour tags
206
+ ### color.py — HDR to SDR, LUTs, colour tags, Dolby Vision
139
207
  ```
140
208
  color.py INPUT --to-sdr [--tonemap hable|mobius|reinhard|bt2390] [--peak 1000] [--desat 0] [-o OUT]
141
209
  color.py INPUT --lut grade.cube [--lut-strength 0..1] [-o OUT]
142
210
  color.py INPUT --retag bt709|bt2020-pq|bt2020-hlg|bt601 [-o OUT] # metadata only, stream copy
211
+ color.py INPUT --strip-dovi [-o OUT] # drop the Dolby Vision RPU, keep the HLG/HDR10 base layer (stream copy)
143
212
  ```
213
+ iPhone "HDR" video is Dolby Vision profile 8.4 on an HLG base layer:
214
+ `probe.py` reports `hdr_format: Dolby Vision profile 8` and `--to-sdr`
215
+ tone-maps it from the HLG base layer. When the user wants to keep HDR but
216
+ players mis-render the DV layer, `--strip-dovi` removes it losslessly.
144
217
  `--to-sdr` does a real conversion: linearise (zscale, PQ or HLG), tone-map
145
218
  (default `hable`, `mobius` keeps more highlight detail, `bt2390` is the
146
219
  broadcast standard), then BT.709 gamma + matrix. Refuses when probe says the
@@ -195,7 +268,8 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
195
268
  `color.py --to-sdr` **first**; other scripts would tag the HDR picture as
196
269
  BT.709 and it would look flat and desaturated (`export.py` warns about this).
197
270
  For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
198
- tagged SDR) apply the manufacturer's `.cube` with `color.py --lut`. Keep
271
+ tagged SDR) run `probe.py --analyze`; `looks_like_log: true` means apply the
272
+ manufacturer's `.cube` with `color.py --lut` before anything else. Keep
199
273
  ProRes masters at source colour: `export.py --preset prores` does not retag.
200
274
  - **CJK and other non-Latin text.** libass and drawtext need a font that has
201
275
  the glyphs. Check with `fc-list | grep -i cjk`. Then either name it
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.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.",
3
+ "version": "0.4.0",
4
+ "description": "Agent Skill that lets coding agents (Claude Code, Cursor, Codex) do professional video editing with local FFmpeg: cut, silence removal, transitions, multicam, captions, sync with drift correction, HDR to SDR, LUTs, audio clean-up and ducking, loudness, platform exports. No API keys, no cloud, no dependencies.",
5
5
  "keywords": ["ffmpeg", "video", "agent-skill", "claude-code", "cursor", "codex", "skill", "video-editing"],
6
6
  "license": "MIT",
7
7
  "author": "kajisho5",
@@ -55,10 +55,54 @@ def require_tool(name: str) -> str:
55
55
  return "" # unreachable
56
56
 
57
57
 
58
+ STATE: Dict[str, Any] = {"dry_run": False, "json": False, "commands": [], "progress": False, "fast": False, "duration_hint": None}
59
+
60
+
61
+ def add_common(ap: "argparse.ArgumentParser") -> None:
62
+ """Add the flags every script shares."""
63
+ g = ap.add_argument_group("agent options")
64
+ g.add_argument("--dry-run", action="store_true", help="print the ffmpeg commands that would run, run nothing")
65
+ g.add_argument("--json", action="store_true", help="print a JSON result (output, probe, commands) on stdout instead of the path")
66
+ g.add_argument("--progress", action="store_true", help="show percent / ETA on stderr while ffmpeg encodes")
67
+ g.add_argument("--fast", action="store_true", help="preview quality: x264 preset veryfast (overrides --preset) for quick iterations")
68
+
69
+
70
+ def apply_common(args: "argparse.Namespace") -> None:
71
+ STATE["dry_run"] = bool(getattr(args, "dry_run", False))
72
+ STATE["json"] = bool(getattr(args, "json", False))
73
+ STATE["progress"] = bool(getattr(args, "progress", False))
74
+ STATE["fast"] = bool(getattr(args, "fast", False))
75
+ if STATE["fast"] and hasattr(args, "preset"):
76
+ args.preset = "veryfast"
77
+
78
+
79
+ def emit(output: Optional[str], **extra: Any) -> None:
80
+ """Final stdout line: the output path, or a JSON document with --json."""
81
+ if STATE["json"]:
82
+ doc: Dict[str, Any] = {"output": output, "dry_run": STATE["dry_run"], "commands": list(STATE["commands"])}
83
+ if output and not STATE["dry_run"] and os.path.exists(output):
84
+ doc["probe"] = probe(output)
85
+ doc.update(extra)
86
+ print_json(doc)
87
+ elif output:
88
+ print(output)
89
+
90
+
58
91
  def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subprocess.CompletedProcess:
59
- """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True."""
92
+ """Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
93
+
94
+ With --dry-run, ffmpeg invocations are printed and skipped (ffprobe still runs so
95
+ scripts can plan); a fake successful CompletedProcess is returned.
96
+ """
97
+ is_ffmpeg = os.path.basename(cmd[0]).startswith("ffmpeg")
98
+ if is_ffmpeg:
99
+ STATE["commands"].append(" ".join(shell_quote(c) for c in cmd))
60
100
  if not quiet:
61
- info("$ " + " ".join(shell_quote(c) for c in cmd))
101
+ info(("[dry-run] $ " if STATE["dry_run"] and is_ffmpeg else "$ ") + " ".join(shell_quote(c) for c in cmd))
102
+ if STATE["dry_run"] and is_ffmpeg:
103
+ return subprocess.CompletedProcess(list(cmd), 0, "", "")
104
+ if STATE["progress"] and is_ffmpeg and cmd[-1] != "-":
105
+ return _run_with_progress(list(cmd), check)
62
106
  proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
63
107
  if check and proc.returncode != 0:
64
108
  tail = "\n".join(proc.stderr.strip().splitlines()[-15:])
@@ -66,6 +110,42 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True) -> subpr
66
110
  return proc
67
111
 
68
112
 
113
+ def _run_with_progress(cmd: List[str], check: bool) -> subprocess.CompletedProcess:
114
+ """Run ffmpeg with -progress on a pipe and print percent/ETA to stderr."""
115
+ import time
116
+ total = STATE.get("duration_hint") or 0.0
117
+ full = cmd[:1] + ["-progress", "pipe:1", "-nostats"] + cmd[1:]
118
+ t0 = time.time()
119
+ proc = subprocess.Popen(full, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
120
+ last = ""
121
+ assert proc.stdout is not None
122
+ for line in proc.stdout:
123
+ if line.startswith("out_time_us=") or line.startswith("out_time_ms="):
124
+ try:
125
+ done = int(line.split("=")[1]) / 1_000_000
126
+ except ValueError:
127
+ continue
128
+ if total > 0:
129
+ pct = min(99.9, done / total * 100)
130
+ elapsed = time.time() - t0
131
+ eta = (elapsed / pct * (100 - pct)) if pct > 0.5 else 0
132
+ msg = f"\r {pct:5.1f}% {done:7.1f}s / {total:.1f}s ETA {eta:4.0f}s"
133
+ else:
134
+ msg = f"\r {done:7.1f}s encoded"
135
+ if msg != last:
136
+ sys.stderr.write(msg)
137
+ sys.stderr.flush()
138
+ last = msg
139
+ _, err = proc.communicate()
140
+ if last:
141
+ sys.stderr.write("\r" + " " * len(last) + "\r")
142
+ result = subprocess.CompletedProcess(full, proc.returncode, "", err)
143
+ if check and proc.returncode != 0:
144
+ tail = "\n".join(err.strip().splitlines()[-15:])
145
+ die(f"command failed ({proc.returncode}): {cmd[0]}\n{tail}", code=proc.returncode or 1)
146
+ return result
147
+
148
+
69
149
  def shell_quote(s: str) -> str:
70
150
  if not s or any(ch in s for ch in " \t\"'\;|&<>()[]{}$*?"):
71
151
  return "'" + s.replace("'", "'\\''") + "'"
@@ -81,6 +161,11 @@ def ffmpeg_base(overwrite: bool = True) -> List[str]:
81
161
  def probe(path: str) -> Dict[str, Any]:
82
162
  """Return a compact, script-friendly description of a media file."""
83
163
  if not os.path.exists(path):
164
+ if STATE["dry_run"]:
165
+ return {"file": path, "dry_run": True, "format": None, "duration": 0.0, "size_bytes": 0, "bitrate": None,
166
+ "video": {"codec": None, "width": 0, "height": 0, "fps": None, "pix_fmt": None, "hdr": False,
167
+ "color_transfer": None, "color_primaries": None, "rotation": 0, "variable_frame_rate_suspected": False},
168
+ "audio": {"codec": None, "channels": 0, "sample_rate": 0}, "subtitle_streams": 0}
84
169
  die(f"input not found: {path}")
85
170
  ffprobe = require_tool("ffprobe")
86
171
  proc = run(
@@ -102,6 +187,8 @@ def probe(path: str) -> Dict[str, Any]:
102
187
  duration = _to_float(video.get("duration"))
103
188
  if duration is None and audio:
104
189
  duration = _to_float(audio.get("duration"))
190
+ if duration and STATE.get("duration_hint") is None:
191
+ STATE["duration_hint"] = duration
105
192
 
106
193
  out: Dict[str, Any] = {
107
194
  "file": path,
@@ -132,6 +219,12 @@ def probe(path: str) -> Dict[str, Any]:
132
219
  trc = video.get("color_transfer") or ""
133
220
  prim = video.get("color_primaries") or ""
134
221
  hdr = trc in ("smpte2084", "arib-std-b67") or prim == "bt2020"
222
+ dovi = None
223
+ for sd in video.get("side_data_list", []) or []:
224
+ if "dv_profile" in sd or "DOVI" in str(sd.get("side_data_type", "")):
225
+ dovi = {"profile": sd.get("dv_profile"), "level": sd.get("dv_level"), "bl_compatibility_id": sd.get("dv_bl_signal_compatibility_id")}
226
+ if dovi: # a Dolby Vision stream is HDR even when its base layer tags are missing
227
+ hdr = True
135
228
  out["video"] = {
136
229
  "codec": video.get("codec_name"),
137
230
  "profile": video.get("profile"),
@@ -145,7 +238,9 @@ def probe(path: str) -> Dict[str, Any]:
145
238
  "pix_fmt": video.get("pix_fmt"),
146
239
  "bit_depth": 10 if "10" in pix else (12 if "12" in pix else 8),
147
240
  "hdr": hdr,
148
- "hdr_format": ("HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
241
+ "hdr_format": (("Dolby Vision %s" % (("profile %s" % dovi["profile"]) if dovi and dovi.get("profile") is not None else "")).strip() if dovi else
242
+ "HDR10/PQ" if trc == "smpte2084" else "HLG" if trc == "arib-std-b67" else "BT.2020 SDR" if hdr else None),
243
+ "dolby_vision": dovi,
149
244
  "color_space": video.get("color_space"),
150
245
  "color_primaries": video.get("color_primaries"),
151
246
  "color_transfer": video.get("color_transfer"),
@@ -257,6 +352,45 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
257
352
  return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
258
353
 
259
354
 
355
+ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
356
+ """Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
357
+
358
+ Log gammas (S-Log3, V-Log, C-Log, HLG-looking flat profiles) put black around 90-95/255 and
359
+ white below ~235 with low saturation: the image looks grey and flat but is tagged as plain SDR.
360
+ """
361
+ ffmpeg = require_tool("ffmpeg")
362
+ cmd = [ffmpeg, "-hide_banner", "-nostdin", "-t", f"{seconds:.1f}", "-i", path, "-an",
363
+ "-vf", "fps=2,signalstats,metadata=print:file=-", "-f", "null", "-"]
364
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
365
+ vals: Dict[str, List[float]] = {}
366
+ for line in proc.stdout.splitlines():
367
+ if "lavfi.signalstats." in line and "=" in line:
368
+ key, val = line.split("lavfi.signalstats.", 1)[1].split("=", 1)
369
+ try:
370
+ vals.setdefault(key, []).append(float(val))
371
+ except ValueError:
372
+ pass
373
+ if not vals.get("YAVG"):
374
+ return {"error": "no frames analysed"}
375
+ def mean(k: str) -> float:
376
+ v = vals.get(k) or [0.0]
377
+ return sum(v) / len(v)
378
+ ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
379
+ # 5th/95th percentile of per-frame lows/highs is more robust than the absolute min/max
380
+ lows = sorted(vals.get("YLOW") or vals.get("YMIN") or [0])
381
+ highs = sorted(vals.get("YHIGH") or vals.get("YMAX") or [255])
382
+ p_low = lows[len(lows) // 20]
383
+ p_high = highs[-1 - len(highs) // 20]
384
+ looks_log = p_low >= 64 and p_high <= 235 and sat < 40
385
+ return {
386
+ "y_min": ymin, "y_max": ymax, "y_avg": round(yavg, 1), "y_low_p5": p_low, "y_high_p95": p_high,
387
+ "saturation_avg": round(sat, 1),
388
+ "looks_like_log": looks_log,
389
+ "note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
390
+ "Apply the camera's conversion LUT with color.py --lut" if looks_log else "contrast and saturation look like normal display-referred SDR"),
391
+ }
392
+
393
+
260
394
  def print_json(obj: Any) -> None:
261
395
  sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
262
396
 
package/scripts/audio.py CHANGED
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List
17
17
 
18
- from _common import audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
18
+ from _common import add_common, apply_common, emit, audio_codec_for, default_output, die, ffmpeg_base, info, probe, run
19
19
 
20
20
  VOICE_CHAIN = "highpass=f=80,deesser=i=0.4,afftdn=nf=-25:tn=1,acompressor=threshold=-18dB:ratio=3:attack=5:release=80:makeup=2"
21
21
 
@@ -43,7 +43,9 @@ def main() -> int:
43
43
  fades.add_argument("--downmix", action="store_true", help="downmix 5.1/7.1 to stereo using standard weights")
44
44
  fades.add_argument("--replace", help="replace the audio with this file (trimmed/padded to the video)")
45
45
  ap.add_argument("--bitrate", default="192k")
46
+ add_common(ap)
46
47
  args = ap.parse_args()
48
+ apply_common(args)
47
49
 
48
50
  meta = probe(args.input)
49
51
  dur = meta.get("duration") or 0.0
@@ -120,7 +122,7 @@ def main() -> int:
120
122
  r = probe(output)
121
123
  a = r["audio"]
122
124
  info(f"wrote {output} ({r['duration']:.3f}s, audio {a['codec']} {a['channels']}ch {a['sample_rate']}Hz)")
123
- print(output)
125
+ emit(output)
124
126
  return 0
125
127
 
126
128
 
@@ -22,7 +22,7 @@ import re
22
22
  import sys
23
23
  from typing import List, Tuple
24
24
 
25
- from _common import aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
25
+ from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_filter_path, ffmpeg_base, fmt_srt_time, info, parse_time, probe, run, x264_args
26
26
 
27
27
  ALIGN = {"bottom": 2, "top": 8, "center": 5, "bottom-left": 1, "bottom-right": 3, "top-left": 7, "top-right": 9}
28
28
 
@@ -86,7 +86,65 @@ def write_srt(cues: List[Tuple[float, float, str]], path: str) -> None:
86
86
  fh.write(f"{i}\n{fmt_srt_time(s)} --> {fmt_srt_time(e)}\n{t}\n\n")
87
87
 
88
88
 
89
- def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int) -> None:
89
+ def word_durations_from_audio(video: str, start: float, end: float, n_words: int) -> List[int]:
90
+ """Split a cue's time across n_words in proportion to speech energy (centiseconds each).
91
+
92
+ Decodes the cue window to 8 kHz mono, builds a 10 ms RMS envelope, removes the noise floor,
93
+ and cuts at equal cumulative-energy quantiles: pauses get no words, loud stretches get more time.
94
+ Falls back to an even split when the window is silent or too short.
95
+ """
96
+ import struct
97
+ import subprocess as sp
98
+ from _common import require_tool
99
+ total_cs = max(1, int(round((end - start) * 100)))
100
+ if n_words <= 1:
101
+ return [total_cs]
102
+ ffmpeg = require_tool("ffmpeg")
103
+ cmd = [ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-ss", f"{start:.3f}", "-i", video,
104
+ "-t", f"{end - start:.3f}", "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"]
105
+ proc = sp.run(cmd, stdout=sp.PIPE, stderr=sp.PIPE)
106
+ n = len(proc.stdout) // 2
107
+ if proc.returncode != 0 or n < 800:
108
+ per = total_cs // n_words
109
+ return [per] * (n_words - 1) + [total_cs - per * (n_words - 1)]
110
+ samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
111
+ step = 80 # 10 ms
112
+ env = []
113
+ for i in range(0, n - step + 1, step):
114
+ block = samples[i:i + step]
115
+ env.append((sum(x * x for x in block) / step) ** 0.5)
116
+ floor = sorted(env)[len(env) // 5] # 20th percentile ~ noise floor
117
+ energy = [max(0.0, e - floor) for e in env]
118
+ total_e = sum(energy)
119
+ if total_e <= 0:
120
+ per = total_cs // n_words
121
+ return [per] * (n_words - 1) + [total_cs - per * (n_words - 1)]
122
+ # boundaries at cumulative-energy quantiles 1/n .. (n-1)/n
123
+ bounds = []
124
+ acc = 0.0
125
+ k = 1
126
+ for idx, e in enumerate(energy):
127
+ acc += e
128
+ while k < n_words and acc >= total_e * k / n_words:
129
+ bounds.append(idx + 1)
130
+ k += 1
131
+ while len(bounds) < n_words - 1:
132
+ bounds.append(len(energy))
133
+ prev = 0
134
+ out = []
135
+ for b in bounds:
136
+ cs = max(5, int(round((b - prev) * 1.0))) # 10 ms blocks -> centiseconds
137
+ out.append(cs)
138
+ prev = b
139
+ out.append(max(5, total_cs - sum(out)))
140
+ # normalise to the exact cue length
141
+ scale = total_cs / max(1, sum(out))
142
+ out = [max(5, int(round(x * scale))) for x in out]
143
+ out[-1] += total_cs - sum(out)
144
+ return out
145
+
146
+
147
+ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int, play_h: int, video: str = None) -> None:
90
148
  """Write a styled ASS file with optional animation and word-by-word highlight."""
91
149
  def t(sec: float) -> str:
92
150
  cs = int(round(sec * 100))
@@ -126,11 +184,16 @@ def write_ass(cues: List[Tuple[float, float, str]], path: str, args, play_w: int
126
184
  dur_cs = max(1, int(round((end - start) * 100)))
127
185
  segments = body.split("\\N")
128
186
  words = [w for seg in segments for w in seg.split(" ") if w]
129
- per = max(1, dur_cs // max(1, len(words)))
187
+ if getattr(args, "karaoke_timing", "even") == "energy" and video:
188
+ durs = word_durations_from_audio(video, start, end, len(words))
189
+ else:
190
+ per = max(1, dur_cs // max(1, len(words)))
191
+ durs = [per] * len(words)
192
+ it = iter(durs)
130
193
  out_segments = []
131
194
  for seg in segments:
132
195
  ws = [w for w in seg.split(" ") if w]
133
- out_segments.append(" ".join(f"{{\\kf{per}}}{w}" for w in ws))
196
+ out_segments.append(" ".join(f"{{\\kf{next(it)}}}{w}" for w in ws))
134
197
  body = "\\N".join(out_segments)
135
198
  lines.append(f"Dialogue: 0,{t(start)},{t(end)},Default,,0,0,0,,{fx}{body}")
136
199
  with open(path, "w", encoding="utf-8-sig") as fh:
@@ -172,11 +235,15 @@ def main() -> int:
172
235
  anim.add_argument("--animate", choices=["none", "fade", "pop", "slide"], default="none", help="per-cue entrance animation")
173
236
  anim.add_argument("--karaoke", action="store_true", help="word-by-word highlight (fills from --color to --highlight-color across each cue)")
174
237
  anim.add_argument("--highlight-color", default="FFD200", help="karaoke fill colour RRGGBB (default FFD200)")
238
+ anim.add_argument("--karaoke-timing", choices=["even", "energy"], default="energy",
239
+ help="how words are timed inside a cue: 'energy' follows the speech loudness in the audio (default), 'even' splits time equally")
175
240
  anim.add_argument("--write-ass", help="where to save the generated ASS (default: next to the output)")
176
241
  enc = ap.add_argument_group("encoding")
177
242
  enc.add_argument("--crf", type=int, default=18)
178
243
  enc.add_argument("--preset", default="medium")
244
+ add_common(ap)
179
245
  args = ap.parse_args()
246
+ apply_common(args)
180
247
 
181
248
  if not (args.srt or args.ass or args.text):
182
249
  die("give one of --srt, --ass or --text")
@@ -204,7 +271,7 @@ def main() -> int:
204
271
  w, h = meta["video"]["width"], meta["video"]["height"]
205
272
  if meta["video"].get("rotation") in (90, -90, 270, -270):
206
273
  w, h = h, w
207
- write_ass(cues_for_ass, ass_path, args, w, h)
274
+ write_ass(cues_for_ass, ass_path, args, w, h, video=args.input if meta.get("audio") else None)
208
275
  info(f"wrote {ass_path} ({len(cues_for_ass)} cues, animate={args.animate}, karaoke={args.karaoke})")
209
276
  args.ass = ass_path
210
277
 
@@ -240,7 +307,7 @@ def main() -> int:
240
307
  run(cmd)
241
308
  result = probe(output)
242
309
  info(f"wrote {output} ({result.get('duration'):.3f}s)")
243
- print(output)
310
+ emit(output)
244
311
  return 0
245
312
 
246
313