ffmpeg-skill 0.4.0 → 0.5.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 (38) hide show
  1. package/README.md +7 -0
  2. package/SKILL.md +56 -11
  3. package/package.json +2 -2
  4. package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
  5. package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
  6. package/scripts/__pycache__/check.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__/render.cpython-311.pyc +0 -0
  18. package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
  19. package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
  20. package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
  21. package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
  22. package/scripts/_common.py +31 -5
  23. package/scripts/caption.py +2 -2
  24. package/scripts/check.py +163 -0
  25. package/scripts/color.py +2 -2
  26. package/scripts/cut.py +3 -3
  27. package/scripts/export.py +3 -1
  28. package/scripts/fit.py +4 -2
  29. package/scripts/join.py +16 -8
  30. package/scripts/look.py +7 -0
  31. package/scripts/loudness.py +3 -1
  32. package/scripts/multicam.py +4 -3
  33. package/scripts/overlay.py +3 -3
  34. package/scripts/render.py +333 -0
  35. package/scripts/scenes.py +155 -0
  36. package/scripts/silence.py +2 -2
  37. package/scripts/sync.py +4 -4
  38. package/scripts/verify.py +10 -1
@@ -18,11 +18,13 @@ import os
18
18
  import re
19
19
  import sys
20
20
 
21
- from _common import add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
21
+ from _common import STATE, add_common, apply_common, emit, AUDIO_CODECS, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run
22
22
 
23
23
 
24
24
 
25
25
  def measure(path: str, I: float, tp: float, lra: float) -> dict:
26
+ if STATE["dry_run"]:
27
+ return {"input_i": "-20.0", "input_tp": "-3.0", "input_lra": "8.0", "input_thresh": "-30.0", "target_offset": "0.0"}
26
28
  ffmpeg = require_tool("ffmpeg")
27
29
  cmd = [ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-vn", "-af", f"loudnorm=I={I}:TP={tp}:LRA={lra}:print_format=json", "-f", "null", "-"]
28
30
  proc = run(cmd, check=False)
@@ -21,7 +21,7 @@ import argparse
21
21
  import sys
22
22
  from typing import List, Tuple
23
23
 
24
- from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
24
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, parse_time, probe, run, x264_args
25
25
  from sync import measure_offset
26
26
 
27
27
 
@@ -154,7 +154,8 @@ def main() -> int:
154
154
  w, h = h, w
155
155
  fps = args.fps or v0.get("fps") or 30.0
156
156
  fps = round(fps) if abs(fps - round(fps)) < 0.02 else fps
157
- geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format=yuv420p"
157
+ pixfmt = "yuv420p10le" if v0.get("hdr") else "yuv420p"
158
+ geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps:g},format={pixfmt}"
158
159
 
159
160
  cmd = ffmpeg_base()
160
161
  for p in args.inputs:
@@ -184,7 +185,7 @@ def main() -> int:
184
185
 
185
186
  output = args.output or default_output(args.inputs[0], "multicam", "mp4")
186
187
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
187
- cmd += x264_args(args.crf, args.preset) + aac_args() + ["-shortest", output]
188
+ cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + ["-shortest", output]
188
189
  run(cmd)
189
190
  r = probe(output)
190
191
  info(f"wrote {output} ({r['duration']:.3f}s, {len(filled)} cuts, audio from input {a})")
@@ -15,7 +15,7 @@ import argparse
15
15
  import sys
16
16
  from typing import List, Optional
17
17
 
18
- from _common import add_common, apply_common, emit, aac_args, cfr_args, default_output, die, escape_drawtext, escape_filter_path, ffmpeg_base, info, parse_time, probe, run, x264_args
18
+ from _common import video_args, add_common, apply_common, emit, 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}"),
@@ -140,7 +140,7 @@ def main() -> int:
140
140
  # -loop 1 turns the still into a timed stream so fade/enable expressions see real timestamps
141
141
  cmd = ffmpeg_base() + ["-i", args.input, "-loop", "1", "-i", args.image]
142
142
  fc = f"[1:v]{','.join(chain)},setpts=PTS-STARTPTS[ov];[0:v][ov]{ov}[out]"
143
- cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a?", "-shortest"]
143
+ cmd += ["-filter_complex", fc, "-map", "[out]", "-map", "0:a:0?", "-shortest"]
144
144
  else:
145
145
  x, y = position_exprs(args.position, args.margin, text_mode=True)
146
146
  opts = [f"text='{escape_drawtext(args.text)}'", f"fontsize={args.font_size}", f"x={x}", f"y={y}",
@@ -159,7 +159,7 @@ def main() -> int:
159
159
  opts.append(f"enable='{enable}'")
160
160
  cmd += ["-vf", "drawtext=" + ":".join(opts)]
161
161
 
162
- cmd += x264_args(args.crf, args.preset) + cfr_args(meta)
162
+ cmd += video_args(meta, args.crf, args.preset) + cfr_args(meta)
163
163
  cmd += aac_args() if meta.get("audio") else ["-an"]
164
164
  cmd.append(output)
165
165
  run(cmd)
@@ -0,0 +1,333 @@
1
+ #!/usr/bin/env python3
2
+ """Declarative edits: describe the whole edit in one project.json and render it
3
+ in one command. Change a number, re-render. Non-destructive: sources are never
4
+ touched, intermediates live in a work directory.
5
+
6
+ Project format (all keys optional except clips):
7
+ {
8
+ "output": "final.mp4",
9
+ "frame": {"aspect": "9:16", "width": 1080, "fps": 30},
10
+ "clips": [
11
+ {"src": "a.mp4", "in": "0:05", "out": "0:20"},
12
+ {"src": "b.mp4", "in": 3, "out": 12, "speed": 1.25},
13
+ {"src": "c.mp4"}
14
+ ],
15
+ "transition": {"type": "fade", "duration": 0.5},
16
+ "silence": {"threshold": -38, "min_silence": 0.8},
17
+ "captions": {"text": "cues.txt", "srt": null, "animate": "pop", "karaoke": true, "font": "Noto Sans CJK JP", "size": 28, "position": "bottom"},
18
+ "overlays": [
19
+ {"image": "logo.png", "position": "top-right", "scale": 160, "opacity": 0.9},
20
+ {"text": "Episode 12", "position": "bottom", "start": 1, "end": 5, "fade": 0.3, "box": true}
21
+ ],
22
+ "audio": {"voice": true, "music": "bed.mp3", "music_volume": -16, "duck": true, "fade_out": 2},
23
+ "loudness": {"lufs": -14, "tp": -1},
24
+ "fit": {"duration": 60},
25
+ "export": {"preset": "reels"},
26
+ "check": {"platform": "reels"}
27
+ }
28
+
29
+ Stages run in this order: clips (cut) → join → silence → fit → captions →
30
+ overlays → audio → loudness → export → check. Missing stages are skipped.
31
+
32
+ Examples:
33
+ python3 render.py --init project.json # write a commented starter project
34
+ python3 render.py project.json # render
35
+ python3 render.py project.json --dry-run # show every command without rendering
36
+ python3 render.py project.json --fast # preview quality
37
+ """
38
+ import argparse
39
+ import json
40
+ import os
41
+ import subprocess
42
+ import sys
43
+ from pathlib import Path
44
+ from typing import Any, Dict, List
45
+
46
+ from _common import STATE, add_common, apply_common, die, emit, info, probe
47
+
48
+ HERE = Path(__file__).resolve().parent
49
+
50
+ TEMPLATE = {
51
+ "output": "final.mp4",
52
+ "frame": {"aspect": "16:9", "width": 1920, "fps": 30},
53
+ "clips": [{"src": "REPLACE_ME.mp4", "in": "0:00", "out": "0:30"}],
54
+ "transition": {"type": "fade", "duration": 0.5},
55
+ "silence": None,
56
+ "captions": None,
57
+ "overlays": [],
58
+ "audio": None,
59
+ "loudness": {"lufs": -14, "tp": -1},
60
+ "fit": None,
61
+ "export": {"preset": "youtube"},
62
+ "check": {"platform": "youtube"},
63
+ }
64
+
65
+
66
+ def sh(script: str, *argv: Any, extra: List[str] = None) -> str:
67
+ """Run a sibling script, forwarding --fast / --dry-run, returning its printed output path."""
68
+ cmd = [sys.executable, str(HERE / script)] + [str(a) for a in argv] + (extra or [])
69
+ if STATE["fast"]:
70
+ cmd.append("--fast")
71
+ if STATE["dry_run"]:
72
+ cmd.append("--dry-run")
73
+ info("→ " + " ".join(os.path.basename(c) if i < 2 else c for i, c in enumerate(cmd)))
74
+ proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
75
+ for line in proc.stderr.splitlines():
76
+ if line.startswith("$ ") or line.startswith("[dry-run]"):
77
+ STATE["commands"].append(line[2:] if line.startswith("$ ") else line)
78
+ elif line.strip():
79
+ info(" " + line)
80
+ if proc.returncode != 0:
81
+ die(f"{script} failed")
82
+ out = proc.stdout.strip().splitlines()
83
+ return out[-1] if out else ""
84
+
85
+
86
+ def main() -> int:
87
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
88
+ ap.add_argument("project", nargs="?", help="project.json")
89
+ ap.add_argument("--init", metavar="FILE", help="write a starter project file and exit")
90
+ ap.add_argument("--work", help="work directory for intermediates (default: <output>_work)")
91
+ ap.add_argument("--keep", action="store_true", help="keep intermediates (default: kept only when --work is given)")
92
+ ap.add_argument("--stop-after", choices=["clips", "join", "silence", "fit", "captions", "overlays", "audio", "loudness", "export"], help="stop after this stage (for iterating)")
93
+ add_common(ap)
94
+ args = ap.parse_args()
95
+ apply_common(args)
96
+
97
+ if args.init:
98
+ Path(args.init).write_text(json.dumps(TEMPLATE, indent=2) + "\n", encoding="utf-8")
99
+ info(f"wrote {args.init}; edit clips/src and run: render.py {args.init}")
100
+ print(args.init)
101
+ return 0
102
+ if not args.project:
103
+ die("give a project.json (or --init FILE)")
104
+ try:
105
+ proj: Dict[str, Any] = json.loads(Path(args.project).read_text(encoding="utf-8"))
106
+ except (OSError, ValueError) as exc:
107
+ die(f"cannot read project: {exc}")
108
+ base = Path(args.project).resolve().parent
109
+
110
+ def rel(p: Any) -> str:
111
+ p = str(p)
112
+ return p if os.path.isabs(p) else str(base / p)
113
+
114
+ clips = proj.get("clips") or []
115
+ if not clips:
116
+ die("project.clips is empty")
117
+ output = rel(proj.get("output") or "final.mp4")
118
+ work = Path(args.work) if args.work else Path(str(Path(output).with_suffix("")) + "_work")
119
+ work.mkdir(parents=True, exist_ok=True)
120
+ frame = proj.get("frame") or {}
121
+ trans = proj.get("transition") or {}
122
+ stages_done: List[str] = []
123
+
124
+ # ---- clips
125
+ parts: List[str] = []
126
+ for i, c in enumerate(clips):
127
+ src = rel(c["src"])
128
+ if not STATE["dry_run"]:
129
+ probe(src)
130
+ needs_cut = c.get("in") is not None or c.get("out") is not None
131
+ part = str(work / f"clip{i:02d}.mp4")
132
+ if needs_cut:
133
+ argv: List[Any] = [src, "-o", part, "--accurate"]
134
+ if c.get("in") is not None:
135
+ argv += ["--start", c["in"]]
136
+ if c.get("out") is not None:
137
+ argv += ["--end", c["out"]]
138
+ sh("cut.py", *argv)
139
+ else:
140
+ part = src
141
+ if c.get("speed"):
142
+ spd = float(c["speed"])
143
+ dur = (probe(part).get("duration") or 0.0) if not STATE["dry_run"] else 10.0
144
+ fitted = str(work / f"clip{i:02d}_speed.mp4")
145
+ sh("fit.py", part, "--duration", f"{dur / spd:.3f}", "-o", fitted)
146
+ part = fitted
147
+ parts.append(part)
148
+ stages_done.append("clips")
149
+ current = parts[0]
150
+ if args.stop_after == "clips":
151
+ emit(current, stages=stages_done)
152
+ return 0
153
+
154
+ # ---- join
155
+ if len(parts) > 1:
156
+ current = str(work / "joined.mp4")
157
+ argv = list(parts) + ["-o", current, "--transition", trans.get("type", "fade"), "--duration", str(trans.get("duration", 0.5))]
158
+ if frame.get("width"):
159
+ argv += ["--width", str(frame["width"])]
160
+ if frame.get("height"):
161
+ argv += ["--height", str(frame["height"])]
162
+ if frame.get("fps"):
163
+ argv += ["--fps", str(frame["fps"])]
164
+ sh("join.py", *argv)
165
+ stages_done.append("join")
166
+ if args.stop_after == "join":
167
+ emit(current, stages=stages_done)
168
+ return 0
169
+
170
+ # ---- silence
171
+ sil = proj.get("silence")
172
+ if sil:
173
+ nxt = str(work / "tight.mp4")
174
+ argv = [current, "-o", nxt]
175
+ for k, flag in (("threshold", "--threshold"), ("min_silence", "--min-silence"), ("margin", "--margin")):
176
+ if sil.get(k) is not None:
177
+ argv += [flag, str(sil[k])]
178
+ sh("silence.py", *argv)
179
+ current = nxt
180
+ stages_done.append("silence")
181
+ if args.stop_after == "silence":
182
+ emit(current, stages=stages_done)
183
+ return 0
184
+
185
+ # ---- fit (duration and/or frame)
186
+ fit = dict(proj.get("fit") or {})
187
+ if frame.get("aspect"):
188
+ fit.setdefault("aspect", frame["aspect"])
189
+ if frame.get("width") and len(parts) == 1:
190
+ fit.setdefault("width", frame["width"])
191
+ if frame.get("fps") and len(parts) == 1:
192
+ fit.setdefault("fps", frame["fps"])
193
+ if fit:
194
+ nxt = str(work / "fit.mp4")
195
+ argv = [current, "-o", nxt]
196
+ for k, flag in (("duration", "--duration"), ("method", "--method"), ("aspect", "--aspect"), ("fit", "--fit"), ("width", "--width"), ("fps", "--fps"), ("smooth", "--smooth")):
197
+ if fit.get(k) is not None:
198
+ argv += [flag, str(fit[k])]
199
+ sh("fit.py", *argv)
200
+ current = nxt
201
+ stages_done.append("fit")
202
+ if args.stop_after == "fit":
203
+ emit(current, stages=stages_done)
204
+ return 0
205
+
206
+ # ---- captions
207
+ cap = proj.get("captions")
208
+ if cap:
209
+ nxt = str(work / "captioned.mp4")
210
+ argv = [current, "-o", nxt]
211
+ if cap.get("text"):
212
+ argv += ["--text", rel(cap["text"])]
213
+ elif cap.get("srt"):
214
+ argv += ["--srt", rel(cap["srt"])]
215
+ elif cap.get("ass"):
216
+ argv += ["--ass", rel(cap["ass"])]
217
+ else:
218
+ die("captions needs text, srt or ass")
219
+ for k, flag in (("font", "--font"), ("size", "--size"), ("color", "--color"), ("position", "--position"), ("margin", "--margin"), ("animate", "--animate"), ("highlight_color", "--highlight-color"), ("outline", "--outline")):
220
+ if cap.get(k) is not None:
221
+ argv += [flag, str(cap[k])]
222
+ for k, flag in (("karaoke", "--karaoke"), ("bold", "--bold"), ("box", "--box")):
223
+ if cap.get(k):
224
+ argv.append(flag)
225
+ sh("caption.py", *argv)
226
+ current = nxt
227
+ stages_done.append("captions")
228
+ if args.stop_after == "captions":
229
+ emit(current, stages=stages_done)
230
+ return 0
231
+
232
+ # ---- overlays
233
+ for i, ov in enumerate(proj.get("overlays") or []):
234
+ nxt = str(work / f"overlay{i:02d}.mp4")
235
+ argv = [current, "-o", nxt]
236
+ if ov.get("image"):
237
+ argv += ["--image", rel(ov["image"])]
238
+ elif ov.get("text"):
239
+ argv += ["--text", ov["text"]]
240
+ else:
241
+ die(f"overlays[{i}] needs image or text")
242
+ for k, flag in (("position", "--position"), ("start", "--start"), ("end", "--end"), ("fade", "--fade"), ("opacity", "--opacity"), ("scale", "--scale"), ("font_size", "--font-size"), ("font", "--font"), ("font_file", "--font-file"), ("margin", "--margin")):
243
+ if ov.get(k) is not None:
244
+ argv += [flag, str(ov[k])]
245
+ if ov.get("box"):
246
+ argv.append("--box")
247
+ sh("overlay.py", *argv)
248
+ current = nxt
249
+ if "overlays" not in stages_done:
250
+ stages_done.append("overlays")
251
+ if args.stop_after == "overlays":
252
+ emit(current, stages=stages_done)
253
+ return 0
254
+
255
+ # ---- audio
256
+ au = proj.get("audio")
257
+ if au:
258
+ nxt = str(work / "audio.mp4")
259
+ argv = [current, "-o", nxt]
260
+ for k, flag in (("music", "--music"), ("replace", "--replace")):
261
+ if au.get(k):
262
+ argv += [flag, rel(au[k])]
263
+ for k, flag in (("music_volume", "--music-volume"), ("fade_in", "--fade-in"), ("fade_out", "--fade-out"), ("gain", "--gain"), ("duck_amount", "--duck-amount")):
264
+ if au.get(k) is not None:
265
+ argv += [flag, str(au[k])]
266
+ for k, flag in (("voice", "--voice"), ("denoise", "--denoise"), ("duck", "--duck"), ("music_loop", "--music-loop"), ("stereo", "--stereo"), ("mono", "--mono"), ("downmix", "--downmix")):
267
+ if au.get(k):
268
+ argv.append(flag)
269
+ sh("audio.py", *argv)
270
+ current = nxt
271
+ stages_done.append("audio")
272
+ if args.stop_after == "audio":
273
+ emit(current, stages=stages_done)
274
+ return 0
275
+
276
+ # ---- loudness
277
+ ld = proj.get("loudness")
278
+ if ld:
279
+ nxt = str(work / "loudnorm.mp4")
280
+ argv = [current, "-o", nxt]
281
+ if ld.get("lufs") is not None:
282
+ argv += ["-I", str(ld["lufs"])]
283
+ if ld.get("tp") is not None:
284
+ argv += ["--tp", str(ld["tp"])]
285
+ sh("loudness.py", *argv)
286
+ current = nxt
287
+ stages_done.append("loudness")
288
+ if args.stop_after == "loudness":
289
+ emit(current, stages=stages_done)
290
+ return 0
291
+
292
+ # ---- export
293
+ ex = proj.get("export")
294
+ if ex and ex.get("preset"):
295
+ argv = [current, "--preset", ex["preset"], "-o", output]
296
+ if ex.get("fit"):
297
+ argv += ["--fit", ex["fit"]]
298
+ if ex.get("crf") is not None:
299
+ argv += ["--crf", str(ex["crf"])]
300
+ sh("export.py", *argv)
301
+ stages_done.append("export")
302
+ else:
303
+ if not STATE["dry_run"]:
304
+ import shutil
305
+ shutil.copyfile(current, output)
306
+ info(f"copied final stage to {output}")
307
+ current = output
308
+
309
+ # ---- check
310
+ ck = proj.get("check")
311
+ check_result = None
312
+ if ck and ck.get("platform") and not STATE["dry_run"]:
313
+ proc = subprocess.run([sys.executable, str(HERE / "check.py"), output, "--platform", ck["platform"], "--json"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
314
+ try:
315
+ check_result = json.loads(proc.stdout)
316
+ except ValueError:
317
+ check_result = {"error": proc.stderr.strip()[-300:]}
318
+ if check_result.get("failed"):
319
+ info(f"check: {check_result['failed']} FAIL — " + "; ".join(f"{r['check']}={r['value']} ({r['fix']})" for r in check_result["checks"] if r["status"] == "FAIL"))
320
+ else:
321
+ info(f"check: OK for {ck['platform']}")
322
+ stages_done.append("check")
323
+
324
+ if not args.keep and not args.work and not STATE["dry_run"]:
325
+ import shutil
326
+ shutil.rmtree(work, ignore_errors=True)
327
+ info(f"rendered {output} via {' → '.join(stages_done)}")
328
+ emit(output, stages=stages_done, check=check_result)
329
+ return 0
330
+
331
+
332
+ if __name__ == "__main__":
333
+ sys.exit(main())
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env python3
2
+ """Find scene changes and loud moments, and propose highlight candidates so the
3
+ agent can plan an edit or a digest without watching the whole file.
4
+
5
+ Scene cuts come from ffmpeg's scdet; energy peaks from a 0.5 s RMS envelope
6
+ of the audio. Highlight candidates are the scenes ranked by audio energy
7
+ (and, optionally, by motion).
8
+
9
+ Examples:
10
+ python3 scenes.py talk.mp4 # scenes + peaks, JSON
11
+ python3 scenes.py event.mp4 --highlights 5 --target 60 # 5 candidate ranges summing to ~60 s
12
+ python3 scenes.py event.mp4 --highlights 4 --edl picks.txt # cut.py --segments compatible list
13
+ python3 scenes.py event.mp4 --sheet scenes.png # one thumbnail per scene
14
+ """
15
+ import argparse
16
+ import math
17
+ import os
18
+ import re
19
+ import struct
20
+ import subprocess
21
+ import sys
22
+ from typing import Dict, List, Tuple
23
+
24
+ from _common import add_common, apply_common, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run
25
+
26
+ SCENE_RE = re.compile(r"lavfi\.scd\.time=([0-9.]+)")
27
+
28
+
29
+ def detect_scenes(path: str, threshold: float, min_len: float, duration: float) -> List[float]:
30
+ ffmpeg = require_tool("ffmpeg")
31
+ proc = subprocess.run([ffmpeg, "-hide_banner", "-nostdin", "-i", path, "-an", "-vf",
32
+ f"scale=320:-2,scdet=threshold={threshold}:sc_pass=1,metadata=print:file=-", "-f", "null", "-"],
33
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
34
+ times = [float(t) for t in SCENE_RE.findall(proc.stdout)]
35
+ cuts = [0.0]
36
+ for t in times:
37
+ if t - cuts[-1] >= min_len:
38
+ cuts.append(t)
39
+ if duration - cuts[-1] < min_len and len(cuts) > 1:
40
+ cuts.pop()
41
+ return cuts
42
+
43
+
44
+ def audio_envelope(path: str, step_s: float) -> List[float]:
45
+ ffmpeg = require_tool("ffmpeg")
46
+ proc = subprocess.run([ffmpeg, "-hide_banner", "-loglevel", "error", "-nostdin", "-i", path, "-vn", "-ac", "1", "-ar", "8000", "-f", "s16le", "-"],
47
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE)
48
+ n = len(proc.stdout) // 2
49
+ if n == 0:
50
+ return []
51
+ samples = struct.unpack(f"<{n}h", proc.stdout[: n * 2])
52
+ step = max(1, int(8000 * step_s))
53
+ env = []
54
+ for i in range(0, n, step):
55
+ block = samples[i:i + step]
56
+ env.append(math.sqrt(sum(x * x for x in block) / len(block)) / 32768.0)
57
+ return env
58
+
59
+
60
+ def main() -> int:
61
+ ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
62
+ ap.add_argument("input")
63
+ ap.add_argument("--threshold", type=float, default=10.0, help="scdet threshold 0-100 (default 10; lower = more cuts)")
64
+ ap.add_argument("--min-scene", type=float, default=1.0, help="ignore cuts closer than this in seconds (default 1)")
65
+ ap.add_argument("--highlights", type=int, default=0, help="number of highlight ranges to propose")
66
+ ap.add_argument("--target", type=float, help="with --highlights: total seconds the picks should add up to (trims long scenes)")
67
+ ap.add_argument("--max-scene", type=float, default=15.0, help="cap a highlight range at this many seconds (default 15)")
68
+ ap.add_argument("--edl", help="write highlight ranges as START-END lines (cut.py --segments format)")
69
+ ap.add_argument("--sheet", help="write a contact sheet PNG with the first frame of every scene")
70
+ add_common(ap)
71
+ args = ap.parse_args()
72
+ apply_common(args)
73
+
74
+ meta = probe(args.input)
75
+ if not meta.get("video"):
76
+ die("input has no video stream")
77
+ dur = meta.get("duration") or 0.0
78
+ cuts = detect_scenes(args.input, args.threshold, args.min_scene, dur)
79
+ bounds = cuts + [dur]
80
+ step_s = 0.5
81
+ env = audio_envelope(args.input, step_s) if meta.get("audio") else []
82
+
83
+ scenes = []
84
+ for i in range(len(bounds) - 1):
85
+ s, e = bounds[i], bounds[i + 1]
86
+ if e - s <= 0.05:
87
+ continue
88
+ seg = env[int(s / step_s): max(int(s / step_s) + 1, int(e / step_s))] if env else []
89
+ energy = (sum(seg) / len(seg)) if seg else 0.0
90
+ peak = max(seg) if seg else 0.0
91
+ scenes.append({"index": len(scenes), "start": round(s, 3), "end": round(e, 3), "duration": round(e - s, 3),
92
+ "audio_rms": round(energy, 4), "audio_peak": round(peak, 4)})
93
+ peaks = []
94
+ if env:
95
+ thr = sorted(env)[int(len(env) * 0.9)] if len(env) > 10 else max(env)
96
+ for i, val in enumerate(env):
97
+ if val >= thr and val > 0.02 and (i == 0 or env[i - 1] < val) and (i == len(env) - 1 or env[i + 1] <= val):
98
+ peaks.append({"time": round(i * step_s, 2), "rms": round(val, 4)})
99
+ peaks = sorted(peaks, key=lambda p: -p["rms"])[:20]
100
+ peaks.sort(key=lambda p: p["time"])
101
+
102
+ result: Dict = {"file": args.input, "duration": round(dur, 3), "scene_count": len(scenes), "scenes": scenes, "audio_peaks": peaks}
103
+ info(f"{len(scenes)} scenes, {len(peaks)} audio peaks over {dur:.1f}s")
104
+
105
+ if args.highlights:
106
+ ranked = sorted(scenes, key=lambda sc: (-sc["audio_rms"], sc["start"]))[: args.highlights]
107
+ picks: List[Tuple[float, float]] = []
108
+ budget = args.target if args.target else None
109
+ per = (budget / max(1, len(ranked))) if budget else args.max_scene
110
+ for sc in ranked:
111
+ length = min(sc["duration"], per, args.max_scene)
112
+ # take the loudest window inside the scene
113
+ best_s = sc["start"]
114
+ if env and length < sc["duration"]:
115
+ best, best_s = -1.0, sc["start"]
116
+ win = max(1, int(length / step_s))
117
+ lo, hi = int(sc["start"] / step_s), max(int(sc["start"] / step_s) + 1, int(sc["end"] / step_s) - win)
118
+ for i in range(lo, hi + 1):
119
+ val = sum(env[i:i + win])
120
+ if val > best:
121
+ best, best_s = val, i * step_s
122
+ picks.append((round(best_s, 2), round(min(sc["end"], best_s + length), 2)))
123
+ picks.sort()
124
+ result["highlights"] = [{"start": s, "end": e, "duration": round(e - s, 2)} for s, e in picks]
125
+ result["highlights_total"] = round(sum(e - s for s, e in picks), 2)
126
+ info(f"proposed {len(picks)} highlight ranges totalling {result['highlights_total']:.1f}s")
127
+ if args.edl:
128
+ with open(args.edl, "w", encoding="utf-8") as fh:
129
+ for s, e in picks:
130
+ fh.write(f"{s:.2f}-{e:.2f}\n")
131
+ info(f"wrote {args.edl}")
132
+
133
+ if args.sheet:
134
+ n = len(scenes)
135
+ cols = min(4, max(1, n))
136
+ rows = max(1, math.ceil(n / cols))
137
+ tile_w = 1280 // cols // 2 * 2
138
+ # exactly one frame per scene: the frame index at the scene start
139
+ fps = meta["video"].get("fps") or 30.0
140
+ expr = "+".join(f"eq(n\\,{int(round(sc['start'] * fps))})" for sc in scenes)
141
+ vf = (f"select='{expr}',scale={tile_w}:-2,drawtext=text='%{{pts\\:hms}}':fontcolor=white:fontsize=h/14:box=1:boxcolor=black@0.55:boxborderw=4:x=6:y=6,"
142
+ f"tile={cols}x{rows}:padding=2:margin=2:color=0x202020")
143
+ run(ffmpeg_base() + ["-i", args.input, "-vf", vf, "-frames:v", "1", "-fps_mode", "vfr", args.sheet])
144
+ info(f"wrote {args.sheet}")
145
+ result["sheet"] = args.sheet
146
+
147
+ if args.json:
148
+ emit(None, **result)
149
+ else:
150
+ print_json(result)
151
+ return 0
152
+
153
+
154
+ if __name__ == "__main__":
155
+ sys.exit(main())
@@ -16,7 +16,7 @@ import re
16
16
  import sys
17
17
  from typing import List, Tuple
18
18
 
19
- from _common import aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
19
+ from _common import video_args, aac_args, add_common, apply_common, cfr_args, default_output, die, emit, ffmpeg_base, info, print_json, probe, require_tool, run, x264_args
20
20
 
21
21
  SIL_RE = re.compile(r"silence_(start|end): ([0-9.]+)")
22
22
 
@@ -110,7 +110,7 @@ def main() -> int:
110
110
  af = f"aselect='{expr}',asetpts=N/SR/TB"
111
111
  cmd = ffmpeg_base() + ["-i", args.input]
112
112
  if meta.get("video"):
113
- cmd += ["-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
113
+ cmd += ["-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
114
114
  cmd += ["-af", af] + aac_args() + [output]
115
115
  run(cmd)
116
116
  r = probe(output)
package/scripts/sync.py CHANGED
@@ -26,7 +26,7 @@ import subprocess
26
26
  import sys
27
27
  from typing import List
28
28
 
29
- from _common import add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
29
+ from _common import video_args, add_common, apply_common, emit, aac_args, audio_codec_for, default_output, die, ffmpeg_base, info, probe, require_tool, run, x264_args
30
30
 
31
31
  SR = 8000 # decode sample rate
32
32
 
@@ -253,14 +253,14 @@ def main() -> int:
253
253
  if proc.returncode != 0:
254
254
  cmd = [c for c in cmd if c != "copy"]
255
255
  idx = cmd.index("-c:v"); del cmd[idx]
256
- cmd = cmd[:-1] + x264_args(args.crf) + [output]
256
+ cmd = cmd[:-1] + video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) + [output]
257
257
  run(cmd)
258
258
  else:
259
259
  if head_trim > 0 and not drift_af:
260
260
  cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second, "-c", "copy", "-avoid_negative_ts", "make_zero", output]
261
261
  proc = run(cmd, check=False)
262
262
  if proc.returncode != 0:
263
- cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (x264_args(args.crf) if has_video else []) + audio_codec_for(output) + [output]
263
+ cmd = ffmpeg_base() + ["-ss", f"{head_trim:.4f}", "-i", args.second] + (video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf) if has_video else []) + audio_codec_for(output) + [output]
264
264
  run(cmd)
265
265
  else:
266
266
  cmd = ffmpeg_base()
@@ -278,7 +278,7 @@ def main() -> int:
278
278
  vf.append(f"setpts=PTS/{drift_ratio:.9f}")
279
279
  if vf:
280
280
  cmd += ["-vf", ",".join(vf)]
281
- cmd += x264_args(args.crf)
281
+ cmd += video_args(probe(args.reference) if args.replace_audio else probe(args.second), args.crf)
282
282
  if af_parts:
283
283
  cmd += ["-af", ",".join(af_parts)]
284
284
  cmd += audio_codec_for(output) + [output]
package/scripts/verify.py CHANGED
@@ -47,6 +47,14 @@ def collect(paths: List[str]) -> List[Path]:
47
47
 
48
48
  def step(name: str, argv: List[str], timeout: float) -> Dict:
49
49
  t0 = time.time()
50
+ if argv[0] == "__check_hdr__":
51
+ try:
52
+ v = probe(argv[1]).get("video") or {}
53
+ ok = bool(v.get("hdr")) and v.get("bit_depth", 8) >= 10
54
+ err = "" if ok else f"re-encode lost HDR: {v.get('color_transfer')}/{v.get('pix_fmt')}"
55
+ except SystemExit:
56
+ ok, err = False, "output missing"
57
+ return {"step": name, "ok": ok, "seconds": round(time.time() - t0, 1), "error": err}
50
58
  try:
51
59
  proc = subprocess.run([sys.executable, str(HERE / argv[0])] + argv[1:], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=timeout)
52
60
  ok = proc.returncode == 0
@@ -110,7 +118,8 @@ def main() -> int:
110
118
  plan.append(("overlay text", ["overlay.py", cut, "--text", "verify", "--position", "top-left", "-o", f"{stem}_ovl.mp4"] + fast))
111
119
  plan.append(("look sheet", ["look.py", cut, "-o", f"{stem}_sheet.png"]))
112
120
  if (meta.get("video") or {}).get("hdr"):
113
- plan.append(("color to-sdr", ["color.py", cut, "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
121
+ plan.append(("color to-sdr", ["color.py", str(f), "--to-sdr", "-o", f"{stem}_sdr.mp4"] + fast))
122
+ plan.append(("hdr preserved", ["__check_hdr__", f"{stem}_acc.mp4"]))
114
123
  plan.append(("probe analyze", ["probe.py", cut, "--analyze"]))
115
124
  plan.append(("export x", ["export.py", cut, "--preset", "x", "-o", f"{stem}_x.mp4"]))
116
125
  if has_a and not args.quick: