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 +8 -2
- package/package.json +1 -1
- package/references/scripts.md +5 -1
- package/scripts/_common/probe.py +3 -0
- package/scripts/_contract.py +5 -0
- package/scripts/color.py +39 -4
- package/scripts/join.py +46 -7
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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`.
|
|
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`.
|
package/scripts/_common/probe.py
CHANGED
|
@@ -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"] = [{
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 +=
|
|
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}][
|
|
357
|
-
parts.append(f"[{aprev}][
|
|
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(
|
|
402
|
+
expected = unpending_length(lens, d)
|
|
373
403
|
r = probe(output, role="output")
|
|
374
|
-
|
|
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
|
|