ffmpeg-skill 0.4.0 → 0.4.1

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/SKILL.md CHANGED
@@ -263,10 +263,14 @@ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
263
263
  After `sync.py`, verify by running it again on the output: offset (and drift
264
264
  ppm with `--fix-drift`) should be ~0. Recordings longer than ~10 minutes from
265
265
  separate devices: always use `--fix-drift`.
266
- - **Colour.** All H.264/H.265 outputs are tagged BT.709 and `yuv420p`. When
267
- `probe.py` reports `hdr: true` (`hdr_format` HDR10/PQ, HLG or BT.2020), run
268
- `color.py --to-sdr` **first**; other scripts would tag the HDR picture as
269
- BT.709 and it would look flat and desaturated (`export.py` warns about this).
266
+ - **Colour.** SDR outputs are H.264 tagged BT.709 `yuv420p`. When `probe.py`
267
+ reports `hdr: true` (HDR10/PQ, HLG, Dolby Vision, BT.2020), every editing
268
+ script keeps the output HDR (HEVC Main10, source colour tags) so nothing is
269
+ silently flattened. Decide with the user: keep HDR (fine for YouTube/phones)
270
+ or run `color.py --to-sdr` first for SDR-only destinations, LUT work or
271
+ H.264 deliverables. `export.py` platform presets are SDR and warn on HDR
272
+ input. iPhone `.mov` files also carry timecode/metadata tracks; scripts map
273
+ only the first audio track, so extra tracks are dropped on re-encode.
270
274
  For Log footage (S-Log, V-Log, C-Log: looks grey and low-contrast but is
271
275
  tagged SDR) run `probe.py --analyze`; `looks_like_log: true` means apply the
272
276
  manufacturer's `.cube` with `color.py --lut` before anything else. Keep
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
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",
@@ -331,6 +331,25 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) ->
331
331
  return args
332
332
 
333
333
 
334
+ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
335
+ """Encoder args that preserve what the source is.
336
+
337
+ SDR sources -> H.264 8-bit tagged BT.709 (x264_args). HDR sources (HDR10/PQ, HLG,
338
+ Dolby Vision base layer, BT.2020) -> HEVC Main10 with the source's own colour tags,
339
+ so cutting/captioning/fitting an iPhone HDR clip stays HDR instead of becoming a
340
+ washed-out file mislabelled as BT.709. Use color.py --to-sdr when SDR is wanted.
341
+ """
342
+ v = (meta or {}).get("video") or {}
343
+ if not v.get("hdr"):
344
+ return x264_args(crf, preset)
345
+ cs = v.get("color_space") or "bt2020nc"
346
+ prim = v.get("color_primaries") or "bt2020"
347
+ trc = v.get("color_transfer") or "arib-std-b67"
348
+ x265 = f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}:range=limited:hdr10-opt=1" if trc == "smpte2084" else f"log-level=error:colorprim={prim}:transfer={trc}:colormatrix={cs}"
349
+ return ["-c:v", "libx265", "-preset", preset, "-crf", str(crf + 2), "-pix_fmt", "yuv420p10le", "-tag:v", "hvc1",
350
+ "-x265-params", x265, "-colorspace", cs, "-color_primaries", prim, "-color_trc", trc, "-movflags", "+faststart"]
351
+
352
+
334
353
  def aac_args(bitrate: str = "192k") -> List[str]:
335
354
  return ["-c:a", "aac", "-b:a", bitrate]
336
355
 
@@ -376,14 +395,20 @@ def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
376
395
  v = vals.get(k) or [0.0]
377
396
  return sum(v) / len(v)
378
397
  ymin, ymax, yavg, sat = min(vals.get("YMIN") or [0]), max(vals.get("YMAX") or [255]), mean("YAVG"), mean("SATAVG")
398
+ # signalstats reports in the source bit depth; normalise everything to an 8-bit scale
399
+ scale = 1.0
400
+ if ymax > 255 or yavg > 255:
401
+ scale = 1 / 4.0 if ymax <= 1023 else 1 / 16.0
402
+ ymin, ymax, yavg, sat = ymin * scale, ymax * scale, yavg * scale, sat * scale
379
403
  # 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])
404
+ lows = sorted(x * scale for x in (vals.get("YLOW") or vals.get("YMIN") or [0]))
405
+ highs = sorted(x * scale for x in (vals.get("YHIGH") or vals.get("YMAX") or [255]))
382
406
  p_low = lows[len(lows) // 20]
383
407
  p_high = highs[-1 - len(highs) // 20]
384
408
  looks_log = p_low >= 64 and p_high <= 235 and sat < 40
385
409
  return {
386
- "y_min": ymin, "y_max": ymax, "y_avg": round(yavg, 1), "y_low_p5": p_low, "y_high_p95": p_high,
410
+ "scale": "8-bit equivalent",
411
+ "y_min": round(ymin, 1), "y_max": round(ymax, 1), "y_avg": round(yavg, 1), "y_low_p5": round(p_low, 1), "y_high_p95": round(p_high, 1),
387
412
  "saturation_avg": round(sat, 1),
388
413
  "looks_like_log": looks_log,
389
414
  "note": ("flat, low-contrast, desaturated picture tagged as SDR: probably a Log profile (S-Log/V-Log/C-Log). "
@@ -22,7 +22,7 @@ import re
22
22
  import sys
23
23
  from typing import List, Tuple
24
24
 
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
25
+ from _common import video_args, 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
 
@@ -302,7 +302,7 @@ def main() -> int:
302
302
  if args.fonts_dir:
303
303
  vf += f":fontsdir={escape_filter_path(args.fonts_dir)}"
304
304
 
305
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + x264_args(args.crf, args.preset) + cfr_args(meta)
305
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf] + video_args(meta, args.crf, args.preset) + cfr_args(meta)
306
306
  cmd += (aac_args() if meta.get("audio") else ["-an"]) + [output]
307
307
  run(cmd)
308
308
  result = probe(output)
package/scripts/color.py CHANGED
@@ -99,7 +99,7 @@ def main() -> int:
99
99
  if proc.returncode != 0:
100
100
  # some codecs cannot carry retagged colour info without a bitstream filter; fall back to re-encode
101
101
  info("stream copy could not rewrite tags, re-encoding")
102
- cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a?"] + x264_args(args.crf, args.preset, keep_bt709=False)
102
+ cmd = ffmpeg_base() + ["-i", args.input, "-map", "0:v:0", "-map", "0:a:0?"] + x264_args(args.crf, args.preset, keep_bt709=False)
103
103
  cmd += ["-colorspace", tags[0], "-color_primaries", tags[1], "-color_trc", tags[2]] + (aac_args() if has_audio else []) + [output]
104
104
  run(cmd)
105
105
  info(f"wrote {output} (tags -> {args.retag})")
@@ -124,7 +124,7 @@ def main() -> int:
124
124
  output = args.output or default_output(args.input, "lut")
125
125
  tag = "lut"
126
126
 
127
- cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a?"]
127
+ cmd = ffmpeg_base() + ["-i", args.input, "-vf", vf, "-map", "0:v:0", "-map", "0:a:0?"]
128
128
  cmd += x264_args(args.crf, args.preset) + cfr_args(meta) + (aac_args() if has_audio else []) + [output]
129
129
  run(cmd)
130
130
  r = probe(output)
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 STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
19
+ from _common import video_args, STATE, add_common, apply_common, emit, 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]]:
@@ -43,7 +43,7 @@ def cut_one(src: str, start: float, end: float, dst: str, reencode: bool, crf: i
43
43
  meta = meta or probe(src)
44
44
  if reencode:
45
45
  cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}"]
46
- cmd += x264_args(crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
46
+ cmd += video_args(meta, crf, preset) + cfr_args(meta) + aac_args() + ["-avoid_negative_ts", "make_zero", dst]
47
47
  else:
48
48
  cmd = ffmpeg_base() + ["-ss", f"{start:.3f}", "-i", src, "-t", f"{dur:.3f}", "-c", "copy", "-avoid_negative_ts", "make_zero", dst]
49
49
  proc = run(cmd, check=False)
@@ -126,7 +126,7 @@ def main() -> int:
126
126
  proc = run(cmd, check=False)
127
127
  if proc.returncode != 0:
128
128
  info("concat with stream copy failed, re-encoding the join")
129
- cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + x264_args(args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
129
+ cmd = ffmpeg_base() + ["-f", "concat", "-safe", "0", "-i", listfile] + video_args(meta, args.crf, args.preset) + cfr_args(meta) + aac_args() + [output]
130
130
  run(cmd)
131
131
 
132
132
  result = probe(output)
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 STATE, add_common, apply_common, emit, aac_args, cfr_args, default_output, die, ffmpeg_base, info, parse_time, probe, run, x264_args
22
+ from _common import video_args, STATE, add_common, apply_common, emit, 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
 
@@ -153,7 +153,7 @@ def main() -> int:
153
153
  cmd += ["-vf", ",".join(vf)]
154
154
  if af:
155
155
  cmd += ["-af", ",".join(af)]
156
- cmd += x264_args(args.crf, args.preset)
156
+ cmd += video_args(meta, args.crf, args.preset)
157
157
  cmd += cfr_args(meta, args.fps) if not args.fps else []
158
158
  if has_audio:
159
159
  cmd += aac_args()
package/scripts/join.py CHANGED
@@ -14,7 +14,7 @@ import argparse
14
14
  import sys
15
15
  from typing import List
16
16
 
17
- from _common import aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
17
+ from _common import video_args, aac_args, add_common, apply_common, default_output, die, emit, ffmpeg_base, info, probe, run, x264_args
18
18
 
19
19
  TRANSITIONS = ["fade", "dissolve", "wipeleft", "wiperight", "wipeup", "wipedown", "slideleft", "slideright",
20
20
  "circleopen", "circleclose", "fadeblack", "fadewhite", "smoothleft", "smoothright", "radial", "none"]
@@ -78,8 +78,9 @@ def main() -> int:
78
78
  geo = f"scale={w}:{h}:force_original_aspect_ratio=increase,crop={w}:{h}"
79
79
  else:
80
80
  geo = f"scale={w}:{h}:force_original_aspect_ratio=decrease,pad={w}:{h}:(ow-iw)/2:(oh-ih)/2:color={args.pad_color}"
81
+ pixfmt = "yuv420p10le" if (metas[0].get("video") or {}).get("hdr") else "yuv420p"
81
82
  for i in range(n):
82
- parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format=yuv420p,settb=AVTB[v{i}]")
83
+ parts.append(f"[{i}:v]{geo},setsar=1,fps={fps:g},format={pixfmt},settb=AVTB[v{i}]")
83
84
  parts.append(f"[{audio_src[i]}]aformat=sample_rates=48000:channel_layouts=stereo,asetpts=PTS-STARTPTS[a{i}]")
84
85
 
85
86
  if args.transition == "none":
@@ -98,7 +99,7 @@ def main() -> int:
98
99
 
99
100
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
100
101
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
101
- cmd += x264_args(args.crf, args.preset) + aac_args() + [output]
102
+ cmd += video_args(metas[0], args.crf, args.preset) + aac_args() + [output]
102
103
  run(cmd)
103
104
  expected = sum(durs) - d * (n - 1)
104
105
  r = probe(output)
package/scripts/look.py CHANGED
@@ -44,6 +44,13 @@ def main() -> int:
44
44
  stem = Path(args.input).stem
45
45
  outdir = str(Path(args.output).parent) if args.output else str(Path(args.input).parent)
46
46
  tc = "" if args.no_timecode else "," + timecode_filter()
47
+ # HDR sources: tone-map for the PNG so the agent judges representative colours, not raw HLG/PQ
48
+ if meta["video"].get("hdr"):
49
+ v = meta["video"]
50
+ tm = (f"zscale=tin={v.get('color_transfer') or 'arib-std-b67'}:pin={v.get('color_primaries') or 'bt2020'}:min={v.get('color_space') or 'bt2020nc'}:rin=tv:t=linear:npl=1000,"
51
+ "format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable,zscale=t=bt709:m=bt709:r=tv,format=yuv420p,")
52
+ tc = "," + tm.rstrip(",") + tc
53
+ info("HDR source: frames are tone-mapped to SDR for display")
47
54
  outputs: List[str] = []
48
55
 
49
56
  if args.compare:
@@ -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)
@@ -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: