ffmpeg-skill 2.2.2 → 2.2.3

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/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.2.2`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.2.3`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.2.2", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.2.3", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -492,6 +492,12 @@ Per-tool keys added in 1.18.0, all additive:
492
492
  | `sources` | `sync.py` | `[{path, offset_s, confidence, drift_ppm}]`, one entry per SOURCE. Present for every run, including the original single-SOURCE shape (where it mirrors the top-level `second`/`offset_seconds`/`confidence` additively). With 2+ SOURCEs it is the *only* per-source shape: there is no top-level `second`/`offset_seconds` because there is no single pair to put there |
493
493
  | `switch_mode`, `min_shot` | `multicam.py --switch energy` | `"energy"` and the `--min-shot` value used (default 1.5s), alongside the existing `cuts` (`[[start, end, camera], ...]`) which already carries the camera index for `--edl`'s companion cut list |
494
494
 
495
+ Per-tool keys added after 2.2.2, all additive:
496
+
497
+ | key | tool | what it holds |
498
+ |---|---|---|
499
+ | `sdr_path`, `notes` | `color.py --to-sdr` | `sdr_path` is `"tonemap"` for PQ / HLG / Dolby Vision input (and `--force` on an untagged file) or `"gamut"` for BT.2020 primaries on an SDR transfer, which is converted to BT.709 without a tone map (`--tonemap`/`--peak`/`--desat` do not apply); `notes` says which path was taken and why |
500
+
495
501
  `check.py` also gains an informational `subtitles` row on **every** platform:
496
502
  `PASS` when every soft subtitle stream carries a language tag, `WARN` when one
497
503
  does not (or when there are none). Like `channels` and `chapters` it is never
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.2.2",
3
+ "version": "2.2.3",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -573,7 +573,11 @@ frame, rate or xfade offsets that rest on a pending input's unmeasured placehold
573
573
  Normalises every clip to one frame size, fps, `yuv420p`, 48 kHz and one
574
574
  channel layout (the widest clip's -- a 5.1 clip keeps 5.1 -- or `--channels`;
575
575
  silent track generated for clips without audio), then chains `xfade` +
576
- `acrossfade`. Output length = sum of clips − transition × (n−1). Clips must be
576
+ `acrossfade`. With a transition each clip has one length for picture and sound
577
+ -- the video stream's, or the audio's when it runs more than a frame longer --
578
+ and the shorter stream is padded (silence / last frame held), so every clip's
579
+ sound stays with its picture. Output length = sum of clip lengths −
580
+ transition × (n−1); `verified` checks the output's video-stream length against it. Clips must be
577
581
  longer than 2 × the transition. Use `--transition none` for a plain cut.
578
582
  A subtitle/data track in the source is not carried into the retimed/concatenated
579
583
  output; the result says so with `dropped_non_av_streams: true`.
@@ -283,6 +283,8 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
283
283
  "color_range": video.get("color_range"),
284
284
  "rotation": rotation,
285
285
  "nb_frames": _to_int(video.get("nb_frames")),
286
+ # the stream's own length (a container's also counts audio priming and a longer track)
287
+ "duration": _to_float(video.get("duration")),
286
288
  "bitrate": _to_int(video.get("bit_rate")),
287
289
  }
288
290
  if audio:
@@ -292,6 +294,7 @@ def probe(path: str, role: str = "input") -> Dict[str, Any]:
292
294
  "channel_layout": audio.get("channel_layout"),
293
295
  "sample_rate": _to_int(audio.get("sample_rate")),
294
296
  "bitrate": _to_int(audio.get("bit_rate")),
297
+ "duration": _to_float(audio.get("duration")),
295
298
  }
296
299
  # every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
297
300
  out["audio_streams"] = [{
@@ -443,6 +443,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
443
443
  extra = {"measured": {"type": "object", "description": "the loudnorm measurement of the input (input_i, input_tp, input_lra, input_thresh, target_offset); with --measure-only it is the whole result"},
444
444
  "targets": {"type": "object", "description": "the requested lufs / tp / lra"},
445
445
  "result": {"type": "object", "description": "the written file measured again (input_i, input_tp, input_lra, ...), plus tp_ceiling_used, audio_bitrate_used and encodes"}}
446
+ elif name == "color":
447
+ extra = {"dropped_non_av_streams": {"type": "boolean"},
448
+ "sdr_path": {"enum": ["tonemap", "gamut"], "description": "--to-sdr only: tonemap for PQ / HLG / Dolby Vision (and --force on an untagged file); gamut for BT.2020 primaries on an SDR transfer, converted to BT.709 without a tone map"},
449
+ "notes": {"type": "array", "items": {"type": "string"}, "description": "--to-sdr only: which path was taken and why"},
450
+ "measurements": {"type": "object", "description": "--correct only: signalstats levels of input and output"}}
446
451
  elif name == "cut":
447
452
  extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
448
453
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
package/scripts/color.py CHANGED
@@ -169,12 +169,36 @@ def hdr_to_sdr_chain(meta: dict, tonemap: str, peak: float, desat: float) -> str
169
169
  return ",".join(chain)
170
170
 
171
171
 
172
+ def bt2020_sdr_to_bt709_chain(meta: dict) -> str:
173
+ """Gamut conversion only, for BT.2020 primaries on an SDR transfer (probe: `bt2020_or_hdr`
174
+ true, `hdr_signal` false). Such a file is already display-referred SDR: linearising it with
175
+ its own transfer, mapping the primaries to BT.709 and re-applying the BT.709 transfer keeps
176
+ white at white and grey at grey. The tone map the HDR chain adds would treat 100 % SDR white
177
+ as a fraction of a 1000-nit peak and darken the picture (white Y 235 -> 151, grey 126 -> 90)."""
178
+ v = meta["video"]
179
+ trc = v.get("color_transfer") or "bt709"
180
+ space = v.get("color_space") or "bt2020nc"
181
+ return ",".join([
182
+ f"zscale=tin={trc}:pin=bt2020:min={space}:rin={v.get('color_range') or 'tv'}:t=linear",
183
+ "format=gbrpf32le",
184
+ "zscale=p=bt709",
185
+ "zscale=t=bt709:m=bt709:r=tv",
186
+ "format=yuv420p",
187
+ ])
188
+
189
+
190
+ def is_bt2020_sdr(v: dict) -> bool:
191
+ """BT.2020 primaries on a non-HDR transfer and no Dolby Vision: gamut-only --to-sdr."""
192
+ return bool(v.get("bt2020_or_hdr")) and not v.get("hdr_signal") and not v.get("dolby_vision") \
193
+ and v.get("color_transfer") not in ("smpte2084", "arib-std-b67")
194
+
195
+
172
196
  def main() -> int:
173
197
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
174
198
  ap.add_argument("input")
175
199
  ap.add_argument("-o", "--output", help="output file (default: <name>_sdr / _lut / _retag)")
176
200
  mode = ap.add_mutually_exclusive_group(required=True)
177
- mode.add_argument("--to-sdr", action="store_true", help="tone-map HDR (PQ/HLG/BT.2020) to SDR BT.709")
201
+ mode.add_argument("--to-sdr", action="store_true", help="convert to SDR BT.709: tone-map HDR (PQ/HLG/Dolby Vision); BT.2020 primaries on an SDR transfer get a gamut conversion only, no tone map")
178
202
  mode.add_argument("--lut", help=".cube LUT to apply (3D)")
179
203
  mode.add_argument("--retag", choices=["bt709", "bt2020-pq", "bt2020-hlg", "bt601"], help="rewrite colour tags only (no re-encode)")
180
204
  mode.add_argument("--strip-dovi", action="store_true", help="remove the Dolby Vision RPU (profile 8.4 iPhone clips) so players use the plain HLG/HDR10 base layer; stream copy")
@@ -183,7 +207,7 @@ def main() -> int:
183
207
  ap.add_argument("--peak", type=float, default=1000.0, help="source peak brightness in nits used for PQ (default 1000)")
184
208
  ap.add_argument("--desat", type=float, default=0.0, help="tonemap desaturation strength (default 0)")
185
209
  ap.add_argument("--lut-strength", type=float, default=1.0, help="blend LUT result with the original, 0..1 (default 1)")
186
- ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is not tagged as HDR (treat as PQ)")
210
+ ap.add_argument("--force", action="store_true", help="run --to-sdr even if the file is tagged neither HDR nor BT.2020 (tone-mapped as PQ; a BT.2020 SDR file never needs it)")
187
211
  ap.add_argument("--exposure", type=float, default=CORRECTION["exposure"][0], help="--correct: exposure in stops, -3..3 (default 0)")
188
212
  ap.add_argument("--contrast", type=float, default=CORRECTION["contrast"][0], help="--correct: contrast, 0..2, 1=unchanged (default 1)")
189
213
  ap.add_argument("--saturation", type=float, default=CORRECTION["saturation"][0], help="--correct: saturation, 0..2, 1=unchanged (default 1)")
@@ -278,10 +302,21 @@ def main() -> int:
278
302
  return 0
279
303
 
280
304
  measurements = None
305
+ sdr_extra: dict = {}
281
306
  if args.to_sdr:
282
307
  if not v.get("bt2020_or_hdr") and not args.force:
283
308
  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.")
284
- vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
309
+ if is_bt2020_sdr(v):
310
+ vf = bt2020_sdr_to_bt709_chain(meta)
311
+ sdr_path = "gamut"
312
+ note = (f"BT.2020 primaries on an SDR transfer ({v.get('color_transfer')}): converted the gamut to BT.709 "
313
+ f"without a tone map (--tonemap/--peak/--desat do not apply)")
314
+ else:
315
+ vf = hdr_to_sdr_chain(meta, args.tonemap, args.peak, args.desat)
316
+ sdr_path = "tonemap"
317
+ note = f"tone-mapped {v.get('hdr_format') or 'untagged input as PQ (--force)'} to SDR BT.709 with {args.tonemap}"
318
+ info(f"note: {note}")
319
+ sdr_extra = {"sdr_path": sdr_path, "notes": [note]}
285
320
  output = args.output or default_output(args.input, "sdr")
286
321
  tag = "sdr"
287
322
  elif args.correct:
@@ -323,7 +358,7 @@ def main() -> int:
323
358
  r = probe(output, role="output")
324
359
  info(f"wrote {output} ({fmt_secs(r['duration'])}, {r['video']['width']}x{r['video']['height']}, "
325
360
  f"{r['video']['color_transfer']}/{r['video']['color_primaries']}, {tag})")
326
- extra = {"dropped_non_av_streams": dropped_streams}
361
+ extra = {"dropped_non_av_streams": dropped_streams, **sdr_extra}
327
362
  if measurements is not None:
328
363
  measurements["output"] = analyze_levels(output)
329
364
  extra["measurements"] = measurements
package/scripts/join.py CHANGED
@@ -63,6 +63,20 @@ def unpending_length(durs: List[float], d: float) -> Optional[float]:
63
63
  return round(sum(durs) - d * (len(durs) - 1), 3)
64
64
 
65
65
 
66
+ def clip_length(meta: Dict[str, Any], fps: float) -> float:
67
+ """The one length a clip holds in a crossfaded join, for its picture and its sound alike: the
68
+ video stream's, or the audio stream's when the sound runs more than a frame past the picture
69
+ (narration is never cut; an AAC tail adds no frame). The container duration (audio priming,
70
+ a longer track) is only the fallback when a stream length is unknown (a pending input)."""
71
+ v = (meta.get("video") or {}).get("duration")
72
+ a = (meta.get("audio") or {}).get("duration")
73
+ if not v:
74
+ return meta.get("duration") or 0.0
75
+ if a and fps and a > v + 1.0 / fps:
76
+ return a
77
+ return v
78
+
79
+
66
80
  def expected_text(expected: Optional[float]) -> str:
67
81
  """unpending_length() as the stderr summary line puts it."""
68
82
  return f"expected ~{expected:.3f}s" if expected is not None else "expected length unknown until the pending inputs exist"
@@ -293,7 +307,10 @@ def main() -> int:
293
307
  w, h = w - (w % 2), h - (h % 2)
294
308
  durs = [m.get("duration") or 0.0 for m in metas]
295
309
  d = args.duration if args.transition != "none" else 0.0
296
- for p, dur in zip(args.inputs, durs):
310
+ # a crossfaded join gives each clip one length for both streams, so the offsets that place
311
+ # every picture also place its sound; the plain cut (concat) keeps the streams paired itself
312
+ lens = [clip_length(m, fps) for m in metas] if d else durs
313
+ for p, dur in zip(args.inputs, lens):
297
314
  if d and dur <= d * 2 and not STATE.dry_run:
298
315
  die(f"{p} is only {dur:.2f}s, too short for a {d:.2f}s transition; shorten --duration")
299
316
 
@@ -347,14 +364,22 @@ def main() -> int:
347
364
  chain = "".join(f"[v{i}][a{i}]" for i in range(n))
348
365
  parts.append(f"{chain}concat=n={n}:v=1:a=1[vout][aout]")
349
366
  else:
350
- vprev, aprev = "v0", "a0"
367
+ # pad the shorter stream to the clip's length: the audio with silence, the picture by
368
+ # holding its last frame, then cut both there -- each xfade and acrossfade then starts
369
+ # at the same clip boundary instead of the audio chain drifting by every clip's gap
370
+ for i in range(n):
371
+ vlen = (metas[i].get("video") or {}).get("duration") or lens[i]
372
+ hold = f"tpad=stop_mode=clone:stop_duration={lens[i] - vlen:.3f}," if lens[i] > vlen else ""
373
+ parts.append(f"[v{i}]{hold}trim=duration={lens[i]:.3f}[vp{i}]")
374
+ parts.append(f"[a{i}]apad=whole_dur={lens[i]:.3f},atrim=duration={lens[i]:.3f}[ap{i}]")
375
+ vprev, aprev = "vp0", "ap0"
351
376
  offset = 0.0
352
377
  for i in range(1, n):
353
- offset += durs[i - 1] - d
378
+ offset += lens[i - 1] - d
354
379
  vout = f"vx{i}" if i < n - 1 else "vout"
355
380
  aout = f"ax{i}" if i < n - 1 else "aout"
356
- parts.append(f"[{vprev}][v{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
357
- parts.append(f"[{aprev}][a{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
381
+ parts.append(f"[{vprev}][vp{i}]xfade=transition={args.transition}:duration={d:g}:offset={offset:.3f}[{vout}]")
382
+ parts.append(f"[{aprev}][ap{i}]acrossfade=d={d:g}:c1=tri:c2=tri[{aout}]")
358
383
  vprev, aprev = vout, aout
359
384
 
360
385
  # A pending input's stub is unmeasured (0x0, 0 fps, 0 s -- #77 keeps it honest rather than
@@ -367,12 +392,26 @@ def main() -> int:
367
392
  "by its measured length")
368
393
  output = args.output or default_output(args.inputs[0], "joined", "mp4")
369
394
  cmd += ["-filter_complex", ";".join(parts), "-map", "[vout]", "-map", "[aout]"]
395
+ if d:
396
+ # the fps filter already made the picture constant-rate and xfade places every frame: FFmpeg
397
+ # 5.1's default cfr output mode still duplicated the last one to meet the AAC tail (a 51-frame
398
+ # dissolve came out 52), which newer builds no longer do -- pass the frames through as built
399
+ cmd += ["-fps_mode", "passthrough"]
370
400
  cmd += video_args(hdr_meta or metas[0], args.crf, args.preset) + aac_args() + [output]
371
401
  run(cmd)
372
- expected = unpending_length(durs, d)
402
+ expected = unpending_length(lens, d)
373
403
  r = probe(output, role="output")
374
- info(f"wrote {output} ({fmt_secs(r['duration'])}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
404
+ # the picture is what the offsets place: measure the video stream, not the container (whose
405
+ # length also counts the AAC tail), against the length the clips add up to
406
+ measured = (r.get("video") or {}).get("duration") or r.get("duration")
407
+ verification = []
408
+ if expected is not None and measured is not None and not STATE.dry_run:
409
+ tol = max(1.5 / fps, 0.05)
410
+ verification.append({"step": "duration", "ok": abs(measured - expected) <= tol,
411
+ "measured": round(measured, 3), "expected": expected, "tolerance": round(tol, 3)})
412
+ info(f"wrote {output} ({fmt_secs(measured)}, {expected_text(expected)}, {w}x{h} @ {fps:g}fps, {n} clips, {args.transition})")
375
413
  emit(output, mode="video", **reported(), clips=n, transition=args.transition, expected_duration=expected,
414
+ verification=verification,
376
415
  dropped_non_av_streams=any(m.get("subtitle_streams") or m.get("data_streams") for m in metas))
377
416
  return 0
378
417