ffmpeg-skill 1.5.1 → 1.5.2

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
@@ -239,7 +239,7 @@ commands work with `talk.wav` in place of `talk.mp4`. What changes:
239
239
 
240
240
  ## Report format
241
241
 
242
- Reply in the language the user wrote their request in — a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one.
242
+ Reply in the language the user wrote their request in — a Japanese request gets a Japanese report, English gets English, Chinese gets Chinese, and so on for any other language. Keep the shape below and the field labels (`Done:`, `Steps:`, `Check:`, `Look:`, `Notes:`) in English (they read like log fields, not prose, and stay recognisable across languages); the sentences around them, any question asked, and any explanation of a judgement call are in the user's language. Never default to English because the tool names and flags happen to be English. A mid-conversation language switch follows the user's latest message, not the first one. This holds for a one-command job too: a three-second audio trim answered with English labels, numbers and one Japanese word in `Notes:` is an English report; the `Done:` line's own description (what was cut, from where) and `Steps:` are written in the user's language even when the values are technical.
243
243
 
244
244
  Finish every job with this shape (numbers from `probe.py`/`check.py`, not memory):
245
245
 
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 (`1.5.1`) | any release |
24
+ | `skill.version` | the npm / package.json version (`1.5.2`) | 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`
@@ -83,7 +83,7 @@ on, the line says so.
83
83
  ```json
84
84
  {
85
85
  "contract_version": "1.0",
86
- "skill": {"id": "ffmpeg-skill", "version": "1.5.1", "execution_mode": "local", "kind": "execution",
86
+ "skill": {"id": "ffmpeg-skill", "version": "1.5.2", "execution_mode": "local", "kind": "execution",
87
87
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
88
88
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
89
89
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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",
@@ -55,7 +55,9 @@ keyframes, instant, lossless); if the snapped result deviates more than
55
55
  `--tolerance` (0.5 s) from the request, that segment is re-encoded automatically
56
56
  (x264 CRF 18). `--accurate` always re-encodes; `--tolerance -1` never does.
57
57
  Multiple segments are concatenated in the order given. stderr reports whether
58
- the result was "lossless stream copy" or "re-encoded".
58
+ the result was "lossless stream copy" or "re-encoded"; when the snap forced a
59
+ re-encode, the result's `lossless_alternative` names the nearest keyframe
60
+ `--start` that would stream-copy instead, so the trade can be offered.
59
61
 
60
62
  ### fit.py — target duration and/or aspect, rotate/flip
61
63
  ```
@@ -534,6 +536,9 @@ playing. `--video` composites a second video as a picture-in-picture layer
534
536
  input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
535
537
  (with `--video`) keys out that colour first for green-screen compositing.
536
538
 
539
+ `--fade S` fades the overlay in at `--start` (or 0); the fade-out happens
540
+ only at `--end`, so a logo with no `--end` stays to the last frame.
541
+
537
542
  ### sync.py — offset detection, alignment, drift correction
538
543
  ```
539
544
  sync.py REFERENCE SECOND [--json] [--max-offset 30] [--analyze-seconds 120] [--fix-drift [--drift-window 60]]
@@ -629,7 +634,11 @@ export.py INPUT --preset youtube|youtube4k|reels|x|prores|h265|gif [--fit pad|cr
629
634
  export.py --list
630
635
  ```
631
636
  Scales into the preset frame (pad by default), tags BT.709, sets `+faststart`,
632
- trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`.
637
+ trims to platform maximums (Reels 90 s, X 140 s) unless `--allow-long`. It
638
+ does not touch levels: for youtube / youtube4k / reels / x the written file is
639
+ measured and the result's `loudness` (and a `notes` line) says when it is
640
+ outside the platform's LUFS / true-peak spec, naming the `loudness.py` call
641
+ that fixes it -- plan that pass instead of discovering it from `check.py`.
633
642
 
634
643
  ### proxy.py — low-bitrate proxy for analysis/preview
635
644
  ```
@@ -181,7 +181,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
181
181
  "export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
182
182
  required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "preset youtube / youtube4k / reels / x / h265 (prores uses pcm_s16le, copy stream-copies, gif has no audio)"},
183
183
  {"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
184
- {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"}],
184
+ {"capability": "filter:palettegen", "when": "preset gif"}, {"capability": "encoder:gif", "when": "preset gif"}, {"capability": "filter:boxblur", "when": "--pad-fill blur"},
185
+ {"capability": "filter:loudnorm", "when": "preset youtube / youtube4k / reels / x with audio: the written file is measured against the platform's loudness target (result `loudness`)"}],
185
186
  video_required=True, audio_only=False, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
186
187
  "check": dict(role="verification", inputs=["media artifact"], outputs=["compliance rows JSON on stdout (no file)"],
187
188
  required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "loudness rows (default)"}, {"capability": "filter:loudnorm", "when": "loudness rows (default)"}],
@@ -392,6 +393,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
392
393
  extra = {"results": {"type": "array"}, "processed": {"type": "integer"}, "total": {"type": "integer"}}
393
394
  elif name == "report":
394
395
  extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
396
+ elif name == "export":
397
+ extra = {"loudness": {"type": "object", "description": "platform presets with audio: the written file's lufs/tp against the platform's target_lufs/target_tp, ok true when inside the spec"},
398
+ "notes": {"type": "array", "items": {"type": "string"}}}
395
399
  elif name == "loudness":
396
400
  extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
397
401
  elif name == "cut":
@@ -399,7 +403,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
399
403
  "duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
400
404
  "precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
401
405
  "description": "packet: stream copy on a packet/keyframe boundary; sample: decoded audio trimmed to the sample, lossless output; codec_frame: sample-trimmed then framed by a lossy encoder (priming delay adds to the length); frame: re-encoded video"},
402
- "reencoded": {"type": "boolean"}}
406
+ "reencoded": {"type": "boolean"},
407
+ "lossless_alternative": {"type": ["string", "null"], "description": "when a lossless cut re-encoded because of the keyframe snap: the --start that would stream-copy instead, and how far it is from the request"}}
403
408
  elif name == "join":
404
409
  extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
405
410
  "sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
package/scripts/cut.py CHANGED
@@ -245,7 +245,11 @@ def main() -> int:
245
245
  requested_duration=round(expected, 6), output_duration=round(got, 6) if got is not None else None,
246
246
  duration_delta_seconds=round(error_ms / 1000, 6) if error_ms is not None else None,
247
247
  mode=mode, keyframe_snapped=keyframe_snapped,
248
- nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None)
248
+ nearest_keyframes=sorted(NEAREST_KEYFRAMES) if NEAREST_KEYFRAMES else None,
249
+ # the trade the caller can offer instead of a re-encode (eval e02: "without losing quality")
250
+ lossless_alternative=(f"--start {min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])):.3f} lands on a keyframe: "
251
+ f"stream copy with no re-encode, {abs(min(NEAREST_KEYFRAMES, key=lambda k: abs(k - segments[0][0])) - segments[0][0]):.2f}s off the requested start")
252
+ if mode == "hybrid" and NEAREST_KEYFRAMES and len(segments) == 1 else None)
249
253
  return 0
250
254
 
251
255
 
package/scripts/export.py CHANGED
@@ -27,6 +27,7 @@ from pathlib import Path
27
27
  from typing import Dict, List
28
28
 
29
29
  from _common import STATE, add_common, apply_common, bt709_tag_args, emit, cfr_args, default_output, die, ffmpeg_base, info, probe, run, validate_color, pad_filters, add_pad_fill_args, fmt_secs
30
+ from check import SPECS as PLATFORMS, measure_loudness
30
31
  PRESETS: Dict[str, Dict] = {
31
32
  "youtube": {"w": 1920, "h": 1080, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "1080p H.264, AAC 192k"},
32
33
  "youtube4k": {"w": 3840, "h": 2160, "ext": "mp4", "video": ["-c:v", "libx264", "-preset", "slow", "-crf", "18", "-profile:v", "high", "-pix_fmt", "yuv420p"], "audio": ["-c:a", "aac", "-b:a", "192k", "-ar", "48000"], "max": None, "desc": "2160p H.264, AAC 192k"},
@@ -40,6 +41,10 @@ PRESETS: Dict[str, Dict] = {
40
41
 
41
42
 
42
43
 
44
+ # which check.py platform a preset targets (its loudness spec is measured after the write)
45
+ PLATFORM_OF = {"youtube": "youtube", "youtube4k": "youtube", "reels": "reels", "x": "x"}
46
+
47
+
43
48
  def main() -> int:
44
49
  ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
45
50
  ap.add_argument("input", nargs="?")
@@ -123,7 +128,24 @@ def main() -> int:
123
128
  result = probe(output, role="output")
124
129
  v = result["video"]
125
130
  info(f"wrote {output} ({fmt_secs(result['duration'])}, {v['width']}x{v['height']}, {v['codec']})")
126
- emit(output, **({"notes": notes} if notes else {}))
131
+ extra: Dict[str, object] = {}
132
+ platform = PLATFORM_OF.get(args.preset)
133
+ if platform and has_audio and not STATE.dry_run:
134
+ # Every eval run that exported for a platform then had to come back with loudness.py: the
135
+ # preset scales and tags but does not touch levels, and only check.py said so. Measure the
136
+ # written file here so the result names the gap and the caller plans one pass, not two.
137
+ spec = PLATFORMS[platform]
138
+ m = measure_loudness(output)
139
+ if m:
140
+ ok = abs(m["lufs"] - spec["lufs"]) <= spec["lufs_tol"] and m["tp"] <= spec["tp"]
141
+ extra["loudness"] = {"lufs": m["lufs"], "tp": m["tp"], "target_lufs": spec["lufs"], "target_tp": spec["tp"], "ok": ok}
142
+ if not ok:
143
+ notes.append(f"loudness {m['lufs']:.1f} LUFS / {m['tp']:+.1f} dBTP is outside {platform}'s {spec['lufs']:g} LUFS / {spec['tp']:g} dBTP; "
144
+ f"run loudness.py -I {spec['lufs']:g} --tp {spec['tp']:g} on this file (or before export)")
145
+ info("warning: " + notes[-1])
146
+ if notes:
147
+ extra["notes"] = notes
148
+ emit(output, **extra)
127
149
  return 0
128
150
 
129
151
 
@@ -103,7 +103,7 @@ def main() -> int:
103
103
  ap.add_argument("--margin", type=int, default=24, help="margin from the edges in px (default 24)")
104
104
  ap.add_argument("--start", help="show from this time (default: whole video)")
105
105
  ap.add_argument("--end", help="hide after this time")
106
- ap.add_argument("--fade", type=float, default=0.0, help="fade in/out duration in seconds")
106
+ ap.add_argument("--fade", type=float, default=0.0, help="fade-in duration in seconds (at --start or 0); the fade-out happens only at --end")
107
107
  ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
108
108
  img = ap.add_argument_group("image options")
109
109
  img.add_argument("--scale", type=int, help="scale the image to this width in px (keeps aspect)")
@@ -188,12 +188,13 @@ def main() -> int:
188
188
  if args.opacity < 1:
189
189
  chain.append(f"colorchannelmixer=aa={args.opacity:g}")
190
190
  if args.fade > 0:
191
- # no --start/--end: fade in at 0 and out at the end of the video
191
+ # no --start: fade in at 0. The fade-out only exists when --end names a moment the
192
+ # overlay leaves; without --end it stays to the last frame ("fade in at the start" is
193
+ # the common ask, and a matching fade-out at the very end surprised every eval run)
192
194
  s = start if start is not None else 0.0
193
- e = end if end is not None else (meta.get("duration") or 0.0)
194
195
  chain.append(f"fade=t=in:st={s:.3f}:d={args.fade:g}:alpha=1")
195
- if e > args.fade:
196
- chain.append(f"fade=t=out:st={e - args.fade:.3f}:d={args.fade:g}:alpha=1")
196
+ if end is not None and end > args.fade:
197
+ chain.append(f"fade=t=out:st={end - args.fade:.3f}:d={args.fade:g}:alpha=1")
197
198
  x, y = position_exprs(args.position, args.margin, text_mode=False)
198
199
  ov = f"overlay={x}:{y}:format=auto"
199
200
  if enable:
@@ -249,8 +250,7 @@ def main() -> int:
249
250
  opts.append(f"fontfile={escape_filter_path(args.font_file)}")
250
251
  else:
251
252
  opts.append(f"font='{escape_drawtext(args.font)}'")
252
- alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None),
253
- end if end is not None else ((meta.get("duration") or None) if args.fade > 0 else None), args.fade)
253
+ alpha = alpha_expr(args.opacity, start if start is not None else (0.0 if args.fade > 0 else None), end, args.fade)
254
254
  opts.append(f"fontcolor={args.font_color}")
255
255
  if alpha != "1":
256
256
  opts.append(f"alpha='{alpha}'")