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 +1 -1
- package/docs/contract.md +2 -2
- package/package.json +1 -1
- package/references/scripts.md +11 -2
- package/scripts/_contract.py +7 -2
- package/scripts/cut.py +5 -1
- package/scripts/export.py +23 -1
- package/scripts/overlay.py +7 -7
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.
|
|
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.
|
|
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.
|
|
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",
|
package/references/scripts.md
CHANGED
|
@@ -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
|
```
|
package/scripts/_contract.py
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/scripts/overlay.py
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
196
|
-
chain.append(f"fade=t=out:st={
|
|
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}'")
|