ffmpeg-skill 2.10.5 → 2.10.6
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/bin/install.js +37 -12
- package/docs/contract.md +2 -2
- package/mcp/server.py +55 -7
- package/package.json +1 -1
- package/references/scripts.md +10 -5
- package/scripts/fit.py +1 -1
- package/scripts/overlay.py +6 -2
- package/scripts/render.py +7 -1
- package/scripts/speedramp.py +6 -1
- package/scripts/sync.py +12 -1
package/bin/install.js
CHANGED
|
@@ -41,21 +41,39 @@ if (has('--help') || has('-h')) {
|
|
|
41
41
|
process.exit(0);
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
44
|
+
// Windows Python installers commonly expose `python`/`py`, not `python3`. The Microsoft Store
|
|
45
|
+
// app-execution alias is a real python3.exe that spawns (no ENOENT) and exits non-zero, so a
|
|
46
|
+
// missing `error` is not proof an interpreter ran. Probe with a version check, then fall through.
|
|
47
|
+
const PYTHON_PROBE = ['-c', 'import sys; raise SystemExit(0 if sys.version_info >= (3, 9) else 1)'];
|
|
48
|
+
|
|
49
|
+
function pythonCandidates(platform) {
|
|
50
|
+
return platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function runWithPython(scriptArgs, platform, spawn) {
|
|
54
|
+
const candidates = pythonCandidates(platform);
|
|
55
|
+
const run = spawn || spawnSync;
|
|
56
|
+
let last = null;
|
|
51
57
|
for (const cmd of candidates) {
|
|
52
|
-
|
|
53
|
-
if (
|
|
58
|
+
const probe = run(cmd, PYTHON_PROBE, { stdio: 'ignore', timeout: 15000 });
|
|
59
|
+
if (probe.error || probe.status !== 0) {
|
|
60
|
+
last = probe;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
return run(cmd, scriptArgs, { stdio: 'inherit' });
|
|
54
64
|
}
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
return { missing: true, error: last && last.error, status: last && last.status, candidates };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function main() {
|
|
69
|
+
// `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
|
|
70
|
+
if (args[0] === 'contract' || args[0] === 'doctor') {
|
|
71
|
+
const py = runWithPython([path.join(ROOT, 'scripts', '_contract.py'), ...args], process.platform, spawnSync);
|
|
72
|
+
if (py.missing) {
|
|
73
|
+
console.error(`error: could not find a Python 3.9+ interpreter (tried: ${py.candidates.join(', ')}). Install Python 3.9+ and ensure it is on PATH.`);
|
|
74
|
+
process.exit(127);
|
|
57
75
|
}
|
|
58
|
-
process.exit(py.error ? 127 : py.status);
|
|
76
|
+
process.exit(py.error ? 127 : (py.status == null ? 1 : py.status));
|
|
59
77
|
}
|
|
60
78
|
|
|
61
79
|
const home = os.homedir();
|
|
@@ -159,3 +177,10 @@ if (!has('--uninstall')) {
|
|
|
159
177
|
console.log('\nDone. Ask your agent things like "make this video 60 seconds and add captions".');
|
|
160
178
|
}
|
|
161
179
|
process.exit(failed ? 1 : 0);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (require.main === module) {
|
|
183
|
+
main();
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = { pythonCandidates, runWithPython };
|
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.10.
|
|
24
|
+
| `skill.version` | the npm / package.json version (`2.10.6`) | 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.10.
|
|
122
|
+
"skill": {"id": "ffmpeg-skill", "version": "2.10.6", "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"},
|
package/mcp/server.py
CHANGED
|
@@ -21,7 +21,7 @@ import os
|
|
|
21
21
|
import subprocess
|
|
22
22
|
import sys
|
|
23
23
|
from pathlib import Path
|
|
24
|
-
from typing import Any, Dict, List
|
|
24
|
+
from typing import Any, Dict, List, Optional
|
|
25
25
|
|
|
26
26
|
HERE = Path(__file__).resolve().parent
|
|
27
27
|
SCRIPTS = HERE.parent / "scripts"
|
|
@@ -90,18 +90,66 @@ def build_argv(name: str, args: Dict[str, Any]) -> List[str]:
|
|
|
90
90
|
return argv
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
def _timeout_in_argv(argv: List[str]) -> Optional[float]:
|
|
94
|
+
"""The last `--timeout` / `--timeout=N` value in a raw argv list.
|
|
95
|
+
|
|
96
|
+
None when the flag is absent or its value is not a number (the child argparse then fails,
|
|
97
|
+
and the outer ceiling stays the environment default)."""
|
|
98
|
+
found: Optional[float] = None
|
|
99
|
+
seen = False
|
|
100
|
+
i = 0
|
|
101
|
+
n = len(argv)
|
|
102
|
+
while i < n:
|
|
103
|
+
token = argv[i]
|
|
104
|
+
if token == "--":
|
|
105
|
+
break
|
|
106
|
+
if token == "--timeout":
|
|
107
|
+
if i + 1 >= n:
|
|
108
|
+
return None
|
|
109
|
+
raw = argv[i + 1]
|
|
110
|
+
i += 2
|
|
111
|
+
elif token.startswith("--timeout="):
|
|
112
|
+
raw = token.split("=", 1)[1]
|
|
113
|
+
i += 1
|
|
114
|
+
else:
|
|
115
|
+
i += 1
|
|
116
|
+
continue
|
|
117
|
+
seen = True
|
|
118
|
+
try:
|
|
119
|
+
found = float(raw)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return None
|
|
122
|
+
return found if seen else None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def timeout_from_call(args: Dict[str, Any]) -> float:
|
|
126
|
+
"""Per-call seconds for the MCP outer ceiling.
|
|
127
|
+
|
|
128
|
+
Structured `timeout` is the canonical key. A raw `argv` list ignores every other key, so
|
|
129
|
+
`--timeout` has to be read from that list: `--timeout 0` otherwise left the ceiling at 4x
|
|
130
|
+
the 1800 s default while the child itself ran with no limit. A negative value is 0, matching
|
|
131
|
+
apply_common (0 means no limit)."""
|
|
132
|
+
if isinstance(args.get("argv"), list):
|
|
133
|
+
parsed = _timeout_in_argv([str(a) for a in args["argv"]])
|
|
134
|
+
if parsed is None:
|
|
135
|
+
return _common._env_timeout()
|
|
136
|
+
return max(0.0, parsed)
|
|
137
|
+
try:
|
|
138
|
+
return max(0.0, float(args.get("timeout", _common._env_timeout())))
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
return _common._env_timeout()
|
|
141
|
+
|
|
142
|
+
|
|
93
143
|
def call_tool(name: str, args: Dict[str, Any]) -> Dict[str, Any]:
|
|
94
144
|
script = SCRIPTS / f"{name}.py"
|
|
95
145
|
if name not in specs() or not script.exists():
|
|
96
146
|
return {"isError": True, "content": [{"type": "text", "text": f"unknown tool {name}"}]}
|
|
97
147
|
argv = build_argv(name, args or {})
|
|
98
148
|
# The child enforces --timeout on each ffmpeg call; this outer ceiling (4x that plus 60 s)
|
|
99
|
-
# is the only thing that ends a child hung for any other reason.
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
except (TypeError, ValueError):
|
|
104
|
-
per_call = _common._env_timeout()
|
|
149
|
+
# is the only thing that ends a child hung for any other reason. Structured `timeout` or
|
|
150
|
+
# `--timeout` inside a raw argv list sets both (0 = no limit); otherwise
|
|
151
|
+
# FFMPEG_SKILL_TIMEOUT / the 1800 s default.
|
|
152
|
+
per_call = timeout_from_call(args or {})
|
|
105
153
|
proc = _common.run_tool([str(script)] + argv, per_call=per_call)
|
|
106
154
|
stdout = proc.stdout.strip()
|
|
107
155
|
text = stdout
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "2.10.
|
|
3
|
+
"version": "2.10.6",
|
|
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
|
@@ -755,10 +755,13 @@ An `overlays[]` entry draws one of `"logo": true`, `"image"`, `"text"` or
|
|
|
755
755
|
`"video"` (#326: a picture-in-picture or green-screen layer, `overlay.py
|
|
756
756
|
--video`), with `position`, `scale`, `opacity`, `start`, `end`, `margin`,
|
|
757
757
|
`platform` (and `fade`, `box`, `font*` for the still/text kinds) mapping to
|
|
758
|
-
overlay.py's flags. `
|
|
759
|
-
`"
|
|
760
|
-
|
|
761
|
-
|
|
758
|
+
overlay.py's flags. `scale` is the layer width in px for a logo, image or
|
|
759
|
+
video (`"scale": 480` is 480 px of the delivered frame, because the stage
|
|
760
|
+
runs after `fit`). A text entry's size is `font_size`; `scale` on a text-only
|
|
761
|
+
entry is refused (`kind: input`) rather than drawn at the default font size.
|
|
762
|
+
`"chromakey": "0x00ff00"`, `"chromakey_similarity"` and
|
|
763
|
+
`"chromakey_blend"` key a colour out of the video layer. A template's
|
|
764
|
+
safe margins apply as they do to a logo. Also refused before the
|
|
762
765
|
first stage: `video` together with `logo`/`image`/`text` in one entry (a
|
|
763
766
|
logo, image and text together still draw the first of them, as before), a
|
|
764
767
|
`chromakey*` key without `video`, `chromakey_similarity`/`_blend` without
|
|
@@ -1464,7 +1467,9 @@ overlay.py INPUT --image PNG [--scale W | --scale-percent P] | --text "..." [--f
|
|
|
1464
1467
|
(`scripts/_platforms.py`), so a template's top-left logo clears TikTok's status bar instead of
|
|
1465
1468
|
sitting 24 px into it; an explicit `--margin` (or a brand `safe_margin`) wins.
|
|
1466
1469
|
Alpha in PNGs is respected. Fades apply to the overlay only; the video keeps
|
|
1467
|
-
playing. `--
|
|
1470
|
+
playing. `--scale` / `--scale-percent` set the width of an image, logo or
|
|
1471
|
+
`--video` layer. They are refused with `--text`: text size is `--font-size`.
|
|
1472
|
+
`--video` composites a second video as a picture-in-picture layer
|
|
1468
1473
|
(same position/scale/opacity/time-range knobs as `--image`); only the main
|
|
1469
1474
|
input's audio is kept, the PiP layer's own audio is dropped. `--chromakey`
|
|
1470
1475
|
(with `--video`) keys out that colour first for green-screen compositing.
|
package/scripts/fit.py
CHANGED
|
@@ -198,7 +198,7 @@ def main() -> int:
|
|
|
198
198
|
darken = 0.0 if meta["video"].get("bt2020_or_hdr") else BLUR_DARKEN
|
|
199
199
|
if not darken:
|
|
200
200
|
info("--fit blur: HDR source, so the blurred background is not dimmed "
|
|
201
|
-
"(an eq on PQ/HLG code values is not the -15
|
|
201
|
+
"(an eq on PQ/HLG code values is not the -15% dim it is on SDR); "
|
|
202
202
|
"run color.py --to-sdr first for the SDR look")
|
|
203
203
|
vf.append(pad_filters(out_w, out_h, "blur", args.pad_color, args.pad_blur, darken))
|
|
204
204
|
else:
|
package/scripts/overlay.py
CHANGED
|
@@ -116,8 +116,8 @@ def main() -> int:
|
|
|
116
116
|
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")
|
|
117
117
|
ap.add_argument("--opacity", type=float, default=1.0, help="0..1 (default 1)")
|
|
118
118
|
img = ap.add_argument_group("image options")
|
|
119
|
-
img.add_argument("--scale", type=int, help="
|
|
120
|
-
img.add_argument("--scale-percent", type=float, help="
|
|
119
|
+
img.add_argument("--scale", type=int, help="width in px for an image, logo or video layer (keeps aspect); text size is --font-size")
|
|
120
|
+
img.add_argument("--scale-percent", type=float, help="width as a %% of the video for an image, logo or video layer (keeps aspect); text size is --font-size")
|
|
121
121
|
txt = ap.add_argument_group("text options")
|
|
122
122
|
txt.add_argument("--font", default="DejaVu Sans", help="fontconfig font name")
|
|
123
123
|
txt.add_argument("--font-file", help="explicit .ttf/.otf/.ttc path (use this for CJK fonts)")
|
|
@@ -158,6 +158,10 @@ def main() -> int:
|
|
|
158
158
|
die("--text is blank -- give the words to draw", kind="input")
|
|
159
159
|
if not (args.image or args.text or args.video):
|
|
160
160
|
die("give --image, --text, --logo or --video")
|
|
161
|
+
# --scale is a layer width. On a text draw it used to be accepted and ignored (fontsize stayed
|
|
162
|
+
# at --font-size), so a project that asked for 200 px text completed with the 42 px default.
|
|
163
|
+
if args.text and not (args.image or args.video) and (args.scale is not None or args.scale_percent is not None):
|
|
164
|
+
die("--scale and --scale-percent size an image, logo or video layer; text size is --font-size", kind="input")
|
|
161
165
|
if args.brand:
|
|
162
166
|
if args.margin == ap.get_default("margin"):
|
|
163
167
|
args.margin = int(brand.get("safe_margin", args.margin))
|
package/scripts/render.py
CHANGED
|
@@ -40,7 +40,9 @@ skipped. "brand" points caption/graphics/overlay at a brand.json (fonts,
|
|
|
40
40
|
colours, logo, safe margin); {"logo": true} in overlays places the brand logo.
|
|
41
41
|
An overlay entry draws one of logo, image, text or video (a picture-in-picture
|
|
42
42
|
layer, overlay.py --video; "chromakey", "chromakey_similarity" and
|
|
43
|
-
"chromakey_blend" key a colour out of that video and need "video").
|
|
43
|
+
"chromakey_blend" key a colour out of that video and need "video"). "scale"
|
|
44
|
+
is that layer's width in px for a logo, image or video; a text entry's size
|
|
45
|
+
is "font_size", and "scale" on a text entry is refused.
|
|
44
46
|
|
|
45
47
|
"check" mirrors check.py's own exit code: a delivery-spec FAIL (or check.py
|
|
46
48
|
itself failing to run) exits 1, same as running check.py directly would --
|
|
@@ -436,6 +438,10 @@ def check_overlay(ov: Any, label: str) -> None:
|
|
|
436
438
|
tuned = [k for k in ("chromakey_similarity", "chromakey_blend") if ov.get(k) is not None]
|
|
437
439
|
if tuned:
|
|
438
440
|
die(f"{label}: {tuned[0]} tunes \"chromakey\", which this entry does not set")
|
|
441
|
+
# scale is a layer width (overlay.py --scale). A text entry that carries it used to complete
|
|
442
|
+
# with the default font size, because the text branch never reads the flag.
|
|
443
|
+
if ov.get("text") and not any(ov.get(k) for k in ("logo", "image", "video")) and ov.get("scale") is not None:
|
|
444
|
+
die(f"{label}: scale sizes an image, logo or video layer; text size is font_size", kind="input")
|
|
439
445
|
|
|
440
446
|
|
|
441
447
|
def require_object(obj: Any, label: str) -> None:
|
package/scripts/speedramp.py
CHANGED
|
@@ -84,9 +84,14 @@ def main() -> int:
|
|
|
84
84
|
output = args.output or default_output(args.input, "ramp")
|
|
85
85
|
|
|
86
86
|
vparts, aparts, labels = [], [], []
|
|
87
|
+
# The coverage check above allows the last end to sit up to 0.5 s off the probed duration.
|
|
88
|
+
# That slack is for a caller who rounded the duration, not a reason to redraw the tail at
|
|
89
|
+
# the last factor. The end bound is omitted only when the stated end is the file's end
|
|
90
|
+
# (within 50 ms): a trim a few milliseconds short of the last packet drops that packet.
|
|
91
|
+
last_runs_to_eof = abs(segments[-1][1] - dur) <= 0.05
|
|
87
92
|
for i, (start, end, factor) in enumerate(segments):
|
|
88
93
|
vlabel, alabel = f"v{i}", f"a{i}"
|
|
89
|
-
end_expr = f"{end:.3f}" if i
|
|
94
|
+
end_expr = f"{end:.3f}" if not (i == len(segments) - 1 and last_runs_to_eof) else None
|
|
90
95
|
trim = f"trim=start={start:.3f}" + (f":end={end_expr}" if end_expr else "")
|
|
91
96
|
vparts.append(f"[0:v]{trim},setpts=(PTS-STARTPTS)/{factor:.6f}[{vlabel}]")
|
|
92
97
|
labels.append(f"[{vlabel}]")
|
package/scripts/sync.py
CHANGED
|
@@ -36,6 +36,17 @@ from _common import video_args, add_common, apply_common, emit, aac_args, audio_
|
|
|
36
36
|
|
|
37
37
|
SR = 8000 # decode sample rate
|
|
38
38
|
|
|
39
|
+
|
|
40
|
+
def sync_meaning(offset: float) -> str:
|
|
41
|
+
"""The sentence a caller reads next to offset_seconds.
|
|
42
|
+
|
|
43
|
+
A zero offset used to take the else branch of `later if offset > 0` and say the second
|
|
44
|
+
file starts earlier. Values that print as 0.000 s (under half a millisecond) say the two
|
|
45
|
+
recordings start together; anything larger keeps later/earlier."""
|
|
46
|
+
if abs(offset) < 5e-4:
|
|
47
|
+
return "second starts together with the reference"
|
|
48
|
+
return "second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")
|
|
49
|
+
|
|
39
50
|
# Scoring constants for coarse alignment. Both come from tests/bench_sync.py on real dialogue and
|
|
40
51
|
# music with +/-30 s offsets, gain, noise and EQ (see evals/results and CHANGELOG 0.8.0):
|
|
41
52
|
#
|
|
@@ -307,7 +318,7 @@ def main() -> int:
|
|
|
307
318
|
"second": args.second,
|
|
308
319
|
"offset_seconds": offset,
|
|
309
320
|
"confidence": score,
|
|
310
|
-
"meaning": (
|
|
321
|
+
"meaning": sync_meaning(offset),
|
|
311
322
|
"sources": sources_block,
|
|
312
323
|
}
|
|
313
324
|
if drift_info:
|