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 CHANGED
@@ -41,21 +41,39 @@ if (has('--help') || has('-h')) {
41
41
  process.exit(0);
42
42
  }
43
43
 
44
- // `contract` / `doctor` are answered by scripts/_contract.py; everything else installs.
45
- if (args[0] === 'contract' || args[0] === 'doctor') {
46
- // Windows Python installers commonly expose `python`/`py`, not `python3` (only the
47
- // Microsoft Store package does); try python3 first (macOS/Linux convention), then fall
48
- // back so `npx ffmpeg-skill doctor` doesn't silently fail with ENOENT on Windows.
49
- const candidates = process.platform === 'win32' ? ['python3', 'python', 'py'] : ['python3'];
50
- let py;
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
- py = spawnSync(cmd, [path.join(ROOT, 'scripts', '_contract.py'), ...args], { stdio: 'inherit' });
53
- if (!py.error) break;
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
- if (py.error) {
56
- console.error(`error: could not find a Python interpreter (tried: ${candidates.join(', ')}). Install Python 3.9+ and ensure it is on PATH.`);
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.5`) | any release |
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.5", "execution_mode": "local", "kind": "execution",
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. The caller's own `timeout`
100
- # argument sets both; otherwise FFMPEG_SKILL_TIMEOUT / the 1800 s default.
101
- try:
102
- per_call = float((args or {}).get("timeout", _common._env_timeout()))
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.5",
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",
@@ -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. `"chromakey": "0x00ff00"`, `"chromakey_similarity"` and
759
- `"chromakey_blend"` key a colour out of the video layer. The stage runs after
760
- `fit`, so `"scale": 480` is 480 px of the delivered frame, and a template's
761
- safe margins apply as they do to a logo. Refused (`kind: input`) before the
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. `--video` composites a second video as a picture-in-picture layer
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%% dim it is on SDR); "
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:
@@ -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="scale the image to this width in px (keeps aspect)")
120
- img.add_argument("--scale-percent", type=float, help="scale the image to this %% of the video width")
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:
@@ -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 < len(segments) - 1 else None
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": ("second starts %.3fs %s than reference" % (abs(offset), "later" if offset > 0 else "earlier")),
321
+ "meaning": sync_meaning(offset),
311
322
  "sources": sources_block,
312
323
  }
313
324
  if drift_info: