ffmpeg-skill 0.9.0 → 0.10.0
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/README.md +291 -122
- package/SKILL.md +39 -7
- package/bin/install.js +1 -1
- package/package.json +15 -3
- package/scripts/_common.py +47 -3
- package/scripts/_contract.py +221 -41
- package/scripts/audio.py +100 -7
- package/scripts/caption.py +6 -0
- package/scripts/check.py +21 -7
- package/scripts/color.py +68 -4
- package/scripts/cut.py +83 -9
- package/scripts/export.py +15 -6
- package/scripts/fit.py +17 -3
- package/scripts/join.py +74 -3
- package/scripts/multicam.py +10 -0
- package/scripts/overlay.py +5 -0
- package/scripts/render.py +13 -2
- package/scripts/scenes.py +15 -3
- package/scripts/sync.py +8 -0
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_common.cpython-311.pyc +0 -0
- package/scripts/__pycache__/_contract.cpython-311.pyc +0 -0
- package/scripts/__pycache__/audio.cpython-311.pyc +0 -0
- package/scripts/__pycache__/batch.cpython-311.pyc +0 -0
- package/scripts/__pycache__/caption.cpython-311.pyc +0 -0
- package/scripts/__pycache__/check.cpython-311.pyc +0 -0
- package/scripts/__pycache__/color.cpython-311.pyc +0 -0
- package/scripts/__pycache__/cut.cpython-311.pyc +0 -0
- package/scripts/__pycache__/export.cpython-311.pyc +0 -0
- package/scripts/__pycache__/fit.cpython-311.pyc +0 -0
- package/scripts/__pycache__/graphics.cpython-311.pyc +0 -0
- package/scripts/__pycache__/join.cpython-311.pyc +0 -0
- package/scripts/__pycache__/look.cpython-311.pyc +0 -0
- package/scripts/__pycache__/loudness.cpython-311.pyc +0 -0
- package/scripts/__pycache__/multicam.cpython-311.pyc +0 -0
- package/scripts/__pycache__/overlay.cpython-311.pyc +0 -0
- package/scripts/__pycache__/probe.cpython-311.pyc +0 -0
- package/scripts/__pycache__/render.cpython-311.pyc +0 -0
- package/scripts/__pycache__/report.cpython-311.pyc +0 -0
- package/scripts/__pycache__/scenes.cpython-311.pyc +0 -0
- package/scripts/__pycache__/silence.cpython-311.pyc +0 -0
- package/scripts/__pycache__/sync.cpython-311.pyc +0 -0
- package/scripts/__pycache__/verify.cpython-311.pyc +0 -0
package/package.json
CHANGED
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ffmpeg-skill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agent Skill that
|
|
5
|
-
"keywords": [
|
|
3
|
+
"version": "0.10.0",
|
|
4
|
+
"description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 21 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
|
+
"keywords": [
|
|
6
|
+
"ffmpeg",
|
|
7
|
+
"video",
|
|
8
|
+
"video-editing",
|
|
9
|
+
"video-processing",
|
|
10
|
+
"audio",
|
|
11
|
+
"agent-skill",
|
|
12
|
+
"claude-code",
|
|
13
|
+
"cursor",
|
|
14
|
+
"codex",
|
|
15
|
+
"mcp",
|
|
16
|
+
"skill"
|
|
17
|
+
],
|
|
6
18
|
"license": "MIT",
|
|
7
19
|
"author": "kajisho5",
|
|
8
20
|
"repository": {
|
package/scripts/_common.py
CHANGED
|
@@ -16,8 +16,18 @@ from fractions import Fraction
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Any, Dict, List, Optional, Sequence
|
|
18
18
|
|
|
19
|
+
# Every script prints paths, help text and reports that may contain non-ASCII (Japanese examples,
|
|
20
|
+
# arrows). On Windows the console streams default to a legacy code page and raise
|
|
21
|
+
# UnicodeEncodeError; make them UTF-8 with replacement so a --help never crashes on encoding.
|
|
22
|
+
for _stream in (sys.stdout, sys.stderr):
|
|
23
|
+
try:
|
|
24
|
+
if getattr(_stream, "encoding", "").lower().replace("-", "") != "utf8":
|
|
25
|
+
_stream.reconfigure(encoding="utf-8", errors="replace")
|
|
26
|
+
except (AttributeError, ValueError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
19
29
|
INSTALL_HINTS = {
|
|
20
|
-
"Darwin": " brew install ffmpeg",
|
|
30
|
+
"Darwin": " brew install ffmpeg-full (the plain ffmpeg formula lacks subtitles/drawtext/zscale)",
|
|
21
31
|
"Linux": (
|
|
22
32
|
" Debian/Ubuntu: sudo apt install ffmpeg\n"
|
|
23
33
|
" Fedora: sudo dnf install ffmpeg\n"
|
|
@@ -322,6 +332,16 @@ def probe(path: str) -> Dict[str, Any]:
|
|
|
322
332
|
"sample_rate": _to_int(audio.get("sample_rate")),
|
|
323
333
|
"bitrate": _to_int(audio.get("bit_rate")),
|
|
324
334
|
}
|
|
335
|
+
# every audio stream in file order: index n here is `-map 0:a:n` (audio.py --audio-stream n)
|
|
336
|
+
out["audio_streams"] = [{
|
|
337
|
+
"index": n,
|
|
338
|
+
"codec": a.get("codec_name"),
|
|
339
|
+
"channels": _to_int(a.get("channels")),
|
|
340
|
+
"channel_layout": a.get("channel_layout"),
|
|
341
|
+
"sample_rate": _to_int(a.get("sample_rate")),
|
|
342
|
+
"language": (a.get("tags") or {}).get("language"),
|
|
343
|
+
"title": (a.get("tags") or {}).get("title"),
|
|
344
|
+
} for n, a in enumerate(s for s in streams if s.get("codec_type") == "audio")]
|
|
325
345
|
return out
|
|
326
346
|
|
|
327
347
|
|
|
@@ -356,10 +376,21 @@ def fmt_srt_time(seconds: float) -> str:
|
|
|
356
376
|
|
|
357
377
|
|
|
358
378
|
def escape_filter_path(path: str) -> str:
|
|
359
|
-
"""Escape a path for use
|
|
379
|
+
"""Escape a file path for use as a filter option value (subtitles=, ass=, lut3d=file=, fontfile=, fontsdir=).
|
|
380
|
+
|
|
381
|
+
A filter option value is parsed twice: the graph parser splits filters on `,` / `;` and options
|
|
382
|
+
on `:`, then the filter's own option parser splits key=value pairs on `:` again. A character that
|
|
383
|
+
must survive both passes needs two levels of escaping, so a Windows drive letter `D:/x.srt` is
|
|
384
|
+
written `D\\\\:/x.srt`; with a single backslash the second pass still splits at the colon and
|
|
385
|
+
ffmpeg reads `/x.srt` as the next option (`Unable to parse "original_size" option value`).
|
|
386
|
+
Backslashes are turned into forward slashes first (ffmpeg accepts them on Windows), so a backslash
|
|
387
|
+
never has to be escaped itself; `'`, `,`, `;`, `[` and `]` are graph-level characters.
|
|
388
|
+
"""
|
|
360
389
|
p = str(Path(path))
|
|
361
390
|
p = p.replace("\\", "/")
|
|
362
|
-
p = p.replace(":", "
|
|
391
|
+
p = p.replace(":", "\\\\:")
|
|
392
|
+
for ch in ("'", ",", ";", "[", "]"):
|
|
393
|
+
p = p.replace(ch, "\\" + ch)
|
|
363
394
|
return p
|
|
364
395
|
|
|
365
396
|
|
|
@@ -436,6 +467,19 @@ def audio_codec_for(output_path: str, default_bitrate: str = "192k") -> List[str
|
|
|
436
467
|
return list(AUDIO_CODECS.get(ext, ["-c:a", "aac", "-b:a", default_bitrate]))
|
|
437
468
|
|
|
438
469
|
|
|
470
|
+
def is_audio_output(output_path: str) -> bool:
|
|
471
|
+
"""True when the output extension is an audio-only container (.wav, .flac, .mp3, .m4a, .aac, .ogg, .opus).
|
|
472
|
+
|
|
473
|
+
Such a file cannot hold a video stream and, for .wav, cannot hold compressed audio: scripts use
|
|
474
|
+
this to drop the picture (-vn) and to pick the codec from the extension instead of AAC.
|
|
475
|
+
"""
|
|
476
|
+
return os.path.splitext(output_path)[1].lower() in AUDIO_CODECS
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def db_to_linear(db: float) -> float:
|
|
480
|
+
return 10 ** (db / 20.0)
|
|
481
|
+
|
|
482
|
+
|
|
439
483
|
def analyze_levels(path: str, seconds: float = 20.0) -> Dict[str, Any]:
|
|
440
484
|
"""Sample luma/saturation statistics (signalstats) and guess whether the picture is Log-encoded.
|
|
441
485
|
|
package/scripts/_contract.py
CHANGED
|
@@ -21,6 +21,7 @@ import argparse
|
|
|
21
21
|
import importlib.util
|
|
22
22
|
import json
|
|
23
23
|
import os
|
|
24
|
+
import platform
|
|
24
25
|
import re
|
|
25
26
|
import shutil
|
|
26
27
|
import subprocess
|
|
@@ -63,8 +64,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
63
64
|
"probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
|
|
64
65
|
required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
|
|
65
66
|
video_required=False, audio_only=True, visual=False, verify=[], produces_artifact=False, idempotency="bit_exact", deterministic=True),
|
|
66
|
-
"cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family)"],
|
|
67
|
-
required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}],
|
|
67
|
+
"cut": dict(role="execution", inputs=["video or audio asset"], outputs=["cut video/audio artifact (same container family, or audio extracted when -o has an audio extension)"],
|
|
68
|
+
required=FF, optional=[{"capability": X264, "when": "re-encode: --accurate, VFR source, or a keyframe farther than --tolerance"}, HDR_X265, {"capability": AAC, "when": "re-encode of a video container"}] + AUDIO_OUT,
|
|
68
69
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
69
70
|
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
70
71
|
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
|
|
@@ -84,8 +85,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
84
85
|
"multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
|
|
85
86
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
86
87
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
87
|
-
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied)"],
|
|
88
|
-
required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"}
|
|
88
|
+
"audio": dict(role="execution", inputs=["video or audio asset", "music bed (--music) or replacement track (--replace)"], outputs=["artifact with the processed audio (video stream-copied, or dropped when -o has an audio extension)"],
|
|
89
|
+
required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
|
|
90
|
+
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"}] + AUDIO_OUT,
|
|
89
91
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
90
92
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
91
93
|
required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
|
|
@@ -93,12 +95,14 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
93
95
|
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
94
96
|
required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"}] + AUDIO_OUT,
|
|
95
97
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
96
|
-
"join": dict(role="execution", inputs=["two or more video assets"], outputs=["concatenated video artifact"],
|
|
97
|
-
required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265],
|
|
98
|
-
video_required=
|
|
98
|
+
"join": dict(role="execution", inputs=["two or more video assets, or two or more audio-only assets"], outputs=["concatenated video artifact", "concatenated audio artifact (audio-only inputs, audio output extension)"],
|
|
99
|
+
required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265] + AUDIO_OUT,
|
|
100
|
+
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
99
101
|
"color": dict(role="execution", inputs=["video asset", ".cube LUT (--lut)"], outputs=["video artifact with converted colour"],
|
|
100
|
-
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
101
|
-
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"}
|
|
102
|
+
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
103
|
+
{"capability": "filter:lut3d", "when": "--lut"}, {"capability": "bsf:filter_units", "when": "--strip-dovi"}, {"capability": X265, "when": "--lut on an HDR source"}, {"capability": AAC, "when": "re-encode"},
|
|
104
|
+
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
105
|
+
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
|
|
102
106
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
103
107
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
104
108
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
|
|
@@ -143,6 +147,38 @@ DRY_RUN_NOTES = {
|
|
|
143
147
|
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
144
148
|
}
|
|
145
149
|
|
|
150
|
+
# Whether a tool re-encodes each stream *when that stream is present in the input* -- not whether
|
|
151
|
+
# the tool touches the file at all. "always"/"never" are unconditional given that stream exists;
|
|
152
|
+
# "conditional" means it depends on flags or on how far a lossless attempt misses (see "note").
|
|
153
|
+
# Read from each script's actual encode/copy args, not from role or intent, since several tools
|
|
154
|
+
# (fit, caption, overlay, graphics, color, join, multicam, silence) always transcode audio to AAC
|
|
155
|
+
# alongside a video filter even though the audio itself is untouched content -- there is no
|
|
156
|
+
# "-c:a copy while re-encoding video" path in this codebase, so a soft-subtitle-style passthrough
|
|
157
|
+
# of the original audio codec never happens on those tools.
|
|
158
|
+
REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
159
|
+
"probe": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
160
|
+
"cut": dict(video="conditional", audio="conditional", note="lossless -c copy preferred; re-encodes on --accurate, a VFR source, or a keyframe snap past --tolerance (see cut.py --json: mode, keyframe_snapped)"),
|
|
161
|
+
"fit": dict(video="always", audio="always", note="always re-encodes to AAC when audio is present, even if only --fps or --aspect was asked for"),
|
|
162
|
+
"caption": dict(video="always", audio="always", note="burn-in only: no soft-subtitle mux path exists, so captions always cost a full re-encode of both streams"),
|
|
163
|
+
"overlay": dict(video="always", audio="always"),
|
|
164
|
+
"graphics": dict(video="always", audio="always"),
|
|
165
|
+
"sync": dict(video="never", audio="conditional", note="video is never touched; audio is copied or re-encoded depending on --trim-second / --replace-audio / --fix-drift"),
|
|
166
|
+
"multicam": dict(video="always", audio="always"),
|
|
167
|
+
"audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
|
|
168
|
+
"loudness": dict(video="never", audio="always"),
|
|
169
|
+
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
|
|
170
|
+
"join": dict(video="always", audio="always"),
|
|
171
|
+
"color": dict(video="always", audio="always"),
|
|
172
|
+
"export": dict(video="conditional", audio="conditional", note="--preset copy is -c:v copy -c:a copy (no re-encode); every other preset re-encodes both"),
|
|
173
|
+
"check": dict(video="never", audio="never", note="read-only, no artifact"),
|
|
174
|
+
"scenes": dict(video="never", audio="never", note="analysis only; --sheet renders a new contact-sheet PNG, not a re-encode of the source"),
|
|
175
|
+
"look": dict(video="never", audio="never", note="renders a new contact-sheet/frame PNG, not a re-encode of the source"),
|
|
176
|
+
"render": dict(video="conditional", audio="conditional", note="delegated: depends on which stages a project.json runs and how each one behaves"),
|
|
177
|
+
"batch": dict(video="conditional", audio="conditional", note="delegated: depends on which script each recipe step runs"),
|
|
178
|
+
"verify": dict(video="conditional", audio="conditional", note="delegated: runs cut/fit/caption/export/loudness/color internally as checks"),
|
|
179
|
+
"report": dict(video="never", audio="never", note="measures via look/check; produces an HTML report, not a re-encoded artifact"),
|
|
180
|
+
}
|
|
181
|
+
|
|
146
182
|
IDEMPOTENCY = {
|
|
147
183
|
"bit_exact": "same inputs and flags give byte-identical output",
|
|
148
184
|
"content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
|
|
@@ -259,6 +295,20 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
259
295
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
|
|
260
296
|
elif name == "loudness":
|
|
261
297
|
extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
|
|
298
|
+
elif name == "cut":
|
|
299
|
+
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
300
|
+
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
301
|
+
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
302
|
+
"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"},
|
|
303
|
+
"reencoded": {"type": "boolean"}}
|
|
304
|
+
elif name == "join":
|
|
305
|
+
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
|
|
306
|
+
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
|
307
|
+
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"}}
|
|
308
|
+
elif name == "audio":
|
|
309
|
+
extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
|
|
310
|
+
"audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
|
|
311
|
+
"dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"}}
|
|
262
312
|
props = dict(base)
|
|
263
313
|
props.update(extra)
|
|
264
314
|
required = ["status", "output", "dry_run", "commands"]
|
|
@@ -288,26 +338,67 @@ def public_tools() -> List[str]:
|
|
|
288
338
|
return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
|
|
289
339
|
|
|
290
340
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
341
|
+
# `ffmpeg -filters` rows: FFmpeg <= 7 prints three flag characters (`..C acompressor A->A ...`),
|
|
342
|
+
# FFmpeg 8 prints two (`T. acompressor A->A ...`). The row is recognised by its io-spec token
|
|
343
|
+
# (`A->A`, `|->V`, `N->N`, ...) so the flag width does not matter; a legend line never carries `->`.
|
|
344
|
+
_FILTER_ROW = re.compile(r"^\s*(?:[A-Z.]{1,6}\s+)?([A-Za-z0-9_]+)\s+(\S*->\S*)(?:\s|$)")
|
|
345
|
+
# `ffmpeg -encoders` rows follow a ` ------` separator: flags (six characters today; any width of
|
|
346
|
+
# letters and dots is accepted) then the encoder name. A legend line has `=` where the name would be.
|
|
347
|
+
_ENCODER_ROW = re.compile(r"^\s*[A-Z.]{2,10}\s+([A-Za-z0-9_-]+)(?:\s|$)")
|
|
348
|
+
_LIST_SEPARATOR = re.compile(r"^\s*-{3,}\s*$")
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _parse_ff_list(flag: str, text: str) -> List[str]:
|
|
352
|
+
"""Names in the stdout of `ffmpeg <flag>`; empty when no row was recognised."""
|
|
297
353
|
names: List[str] = []
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
354
|
+
if flag == "-filters":
|
|
355
|
+
for line in text.splitlines():
|
|
356
|
+
m = _FILTER_ROW.match(line)
|
|
357
|
+
if m:
|
|
358
|
+
names.append(m.group(1))
|
|
359
|
+
elif flag == "-encoders":
|
|
360
|
+
lines = text.splitlines()
|
|
361
|
+
sep = next((i for i, l in enumerate(lines) if _LIST_SEPARATOR.match(l)), None)
|
|
362
|
+
rows = lines[sep + 1:] if sep is not None else lines
|
|
363
|
+
for line in rows:
|
|
364
|
+
m = _ENCODER_ROW.match(line)
|
|
365
|
+
if m and m.group(1) != "=":
|
|
366
|
+
names.append(m.group(1))
|
|
367
|
+
elif flag == "-bsfs":
|
|
368
|
+
for line in text.splitlines():
|
|
369
|
+
parts = line.split()
|
|
304
370
|
if len(parts) == 1 and not parts[0].endswith(":"):
|
|
305
371
|
names.append(parts[0])
|
|
306
|
-
elif flags and len(parts) >= 2 and re.fullmatch(flags, parts[0]):
|
|
307
|
-
names.append(parts[1])
|
|
308
372
|
return names
|
|
309
373
|
|
|
310
374
|
|
|
375
|
+
def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
376
|
+
"""`{"names": [...], "status": parsed | unparsed | failed | missing, "detail": str}` for `ffmpeg <flag>`.
|
|
377
|
+
|
|
378
|
+
`parsed`: rows recognised. `unparsed`: ffmpeg ran but no row matched, so the capabilities it
|
|
379
|
+
covers are unknown, not absent. `failed`: ffmpeg exited non-zero. `missing`: no binary on PATH.
|
|
380
|
+
"""
|
|
381
|
+
exe = shutil.which(binary)
|
|
382
|
+
if not exe:
|
|
383
|
+
return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
|
|
384
|
+
try:
|
|
385
|
+
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
386
|
+
except OSError as e:
|
|
387
|
+
return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
|
|
388
|
+
if proc.returncode != 0:
|
|
389
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
390
|
+
return {"names": [], "status": "failed", "detail": f"{binary} {flag} exited {proc.returncode}: {tail}"}
|
|
391
|
+
names = _parse_ff_list(flag, proc.stdout)
|
|
392
|
+
if not names:
|
|
393
|
+
return {"names": [], "status": "unparsed", "detail": f"no row recognised in `{binary} {flag}` output ({len(proc.stdout.splitlines())} lines)"}
|
|
394
|
+
return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _ff_list(binary: str, flag: str) -> List[str]:
|
|
398
|
+
"""Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
|
|
399
|
+
return _ff_listing(binary, flag)["names"]
|
|
400
|
+
|
|
401
|
+
|
|
311
402
|
def _version_line(binary: str) -> Optional[str]:
|
|
312
403
|
exe = shutil.which(binary)
|
|
313
404
|
if not exe:
|
|
@@ -335,30 +426,51 @@ def required_capabilities() -> Dict[str, List[str]]:
|
|
|
335
426
|
|
|
336
427
|
|
|
337
428
|
def doctor() -> Dict[str, Any]:
|
|
338
|
-
"""Detect which declared capabilities this machine has. No secrets, no environment variables.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
429
|
+
"""Detect which declared capabilities this machine has. No secrets, no environment variables.
|
|
430
|
+
|
|
431
|
+
Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
|
|
432
|
+
that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
|
|
433
|
+
`missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
|
|
434
|
+
is not a pass). `ok` is true only when nothing required is missing or unknown.
|
|
435
|
+
"""
|
|
436
|
+
listings = {
|
|
437
|
+
"encoders": _ff_listing("ffmpeg", "-encoders"),
|
|
438
|
+
"filters": _ff_listing("ffmpeg", "-filters"),
|
|
439
|
+
"bsfs": _ff_listing("ffmpeg", "-bsfs"),
|
|
440
|
+
}
|
|
441
|
+
sets = {k: set(v["names"]) for k, v in listings.items()}
|
|
442
|
+
state: Dict[str, str] = {} # capability -> available | missing | unknown
|
|
343
443
|
wanted = required_capabilities()
|
|
444
|
+
|
|
445
|
+
def _from(kind: str, name: str) -> str:
|
|
446
|
+
lst = listings[kind]
|
|
447
|
+
if lst["status"] == "parsed":
|
|
448
|
+
return "available" if name in sets[kind] else "missing"
|
|
449
|
+
if lst["status"] == "missing":
|
|
450
|
+
return "missing" # no ffmpeg at all: nothing it provides is available
|
|
451
|
+
return "unknown"
|
|
452
|
+
|
|
344
453
|
for cap in wanted["required"] + wanted["optional"]:
|
|
345
454
|
if cap == "ffmpeg":
|
|
346
|
-
|
|
455
|
+
state[cap] = "available" if shutil.which("ffmpeg") else "missing"
|
|
347
456
|
elif cap == "ffprobe":
|
|
348
|
-
|
|
457
|
+
state[cap] = "available" if shutil.which("ffprobe") else "missing"
|
|
349
458
|
elif cap.startswith("encoder:"):
|
|
350
|
-
|
|
459
|
+
state[cap] = _from("encoders", cap[8:])
|
|
351
460
|
elif cap.startswith("filter:"):
|
|
352
|
-
|
|
461
|
+
state[cap] = _from("filters", cap[7:])
|
|
353
462
|
elif cap.startswith("bsf:"):
|
|
354
|
-
|
|
463
|
+
state[cap] = _from("bsfs", cap[4:])
|
|
355
464
|
elif cap == "external:whisper":
|
|
356
|
-
|
|
465
|
+
state[cap] = "available" if _whisper_available() else "missing"
|
|
357
466
|
else:
|
|
358
|
-
|
|
359
|
-
available = sorted(c for c,
|
|
360
|
-
missing_required = sorted(c for c in wanted["required"] if
|
|
361
|
-
missing_optional = sorted(c for c in wanted["optional"] if
|
|
467
|
+
state[cap] = "missing"
|
|
468
|
+
available = sorted(c for c, st in state.items() if st == "available")
|
|
469
|
+
missing_required = sorted(c for c in wanted["required"] if state[c] == "missing")
|
|
470
|
+
missing_optional = sorted(c for c in wanted["optional"] if state[c] == "missing")
|
|
471
|
+
unknown = sorted(c for c, st in state.items() if st == "unknown")
|
|
472
|
+
unknown_required = [c for c in unknown if c in wanted["required"]]
|
|
473
|
+
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
362
474
|
return {
|
|
363
475
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
364
476
|
"ffmpeg": _version_line("ffmpeg"),
|
|
@@ -366,10 +478,64 @@ def doctor() -> Dict[str, Any]:
|
|
|
366
478
|
"available": available,
|
|
367
479
|
"missing": missing_required,
|
|
368
480
|
"missing_optional": missing_optional,
|
|
369
|
-
"
|
|
481
|
+
"unknown": unknown,
|
|
482
|
+
"detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
|
|
483
|
+
"errors": errors,
|
|
484
|
+
"ok": not missing_required and not unknown_required,
|
|
485
|
+
"tools": _tool_usability(state),
|
|
370
486
|
}
|
|
371
487
|
|
|
372
488
|
|
|
489
|
+
def _capability_fix_hint(cap: str) -> str:
|
|
490
|
+
"""One-line, plain-language remedy for a single missing/unknown capability."""
|
|
491
|
+
if cap in ("ffmpeg", "ffprobe"):
|
|
492
|
+
from _common import INSTALL_HINTS
|
|
493
|
+
hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
|
|
494
|
+
return f"install ffmpeg: {hint}"
|
|
495
|
+
full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)" if platform.system() == "Darwin" else "install/build ffmpeg with it enabled"
|
|
496
|
+
if cap.startswith("encoder:"):
|
|
497
|
+
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
498
|
+
if cap.startswith("filter:"):
|
|
499
|
+
return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
|
|
500
|
+
if cap.startswith("bsf:"):
|
|
501
|
+
return f"this ffmpeg build has no {cap[4:]} bitstream filter; {full_hint}"
|
|
502
|
+
if cap == "external:whisper":
|
|
503
|
+
return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
|
|
504
|
+
return f"'{cap}' is not available; see docs/contract.md"
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def _tool_usability(state: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
|
|
508
|
+
"""Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
|
|
509
|
+
computed. Answers "can I run this tool on this machine today", not just "what capabilities
|
|
510
|
+
exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
|
|
511
|
+
own required-capability list by hand to answer that."""
|
|
512
|
+
tools: Dict[str, Dict[str, Any]] = {}
|
|
513
|
+
for name, meta in TOOL_META.items():
|
|
514
|
+
required = list(meta["required"])
|
|
515
|
+
missing = [c for c in required if state.get(c) == "missing"]
|
|
516
|
+
unknown = [c for c in required if state.get(c) == "unknown"]
|
|
517
|
+
entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
|
|
518
|
+
if missing:
|
|
519
|
+
entry["missing"] = missing
|
|
520
|
+
entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c) for c in missing))
|
|
521
|
+
if unknown:
|
|
522
|
+
entry["unknown"] = unknown
|
|
523
|
+
tools[name] = entry
|
|
524
|
+
return tools
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
# Cross-repository Capability ids (kajisho5/AI-video-production-OS docs/SPEC.md
|
|
528
|
+
# `CapabilityContract.provides`), matching the ids already assigned to these 21 tools in
|
|
529
|
+
# that project's own docs/CAPABILITY_MATRIX.md section 9: "ffmpeg-skill's 21 raw tools ...
|
|
530
|
+
# are Capabilities in their own right, independent of the higher-level Skills that
|
|
531
|
+
# delegate to them". Each tool's own id is `ffmpeg-skill/<tool>` (a slash, matching every
|
|
532
|
+
# ToolSpec.id here); the Capability id uses a dot - `ffmpeg-skill.<tool>` - the same
|
|
533
|
+
# `<domain>.<verb>`-shaped convention every other Skill's Capability ids use elsewhere in
|
|
534
|
+
# that project (`video.trim`, `audio.gain`, ...), with "ffmpeg-skill" as the domain.
|
|
535
|
+
def capability_provides() -> List[Dict[str, str]]:
|
|
536
|
+
return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
|
|
537
|
+
|
|
538
|
+
|
|
373
539
|
# ----------------------------------------------------------------------------- contract
|
|
374
540
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
375
541
|
if name not in TOOL_META:
|
|
@@ -412,6 +578,11 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
412
578
|
"mutates_input": False,
|
|
413
579
|
"produces_artifact": meta["produces_artifact"],
|
|
414
580
|
"verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
|
|
581
|
+
# a tool with no declared entry reports "conditional" rather than guessing "always" or
|
|
582
|
+
# "never" -- same "unknown is not missing" principle as doctor's capability detection
|
|
583
|
+
"reencodes_video": REENCODE_META.get(name, {}).get("video", "conditional"),
|
|
584
|
+
"reencodes_audio": REENCODE_META.get(name, {}).get("audio", "conditional"),
|
|
585
|
+
**({"reencode_note": REENCODE_META[name]["note"]} if REENCODE_META.get(name, {}).get("note") else {}),
|
|
415
586
|
"requires_visual_verification": meta["visual"],
|
|
416
587
|
"audio_only": meta["audio_only"],
|
|
417
588
|
"video_required": meta["video_required"],
|
|
@@ -496,7 +667,8 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
496
667
|
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
|
|
497
668
|
if detect:
|
|
498
669
|
d = doctor()
|
|
499
|
-
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
|
|
670
|
+
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
|
|
671
|
+
"unknown": d["unknown"], "detection": d["detection"], "detected_by": "doctor"})
|
|
500
672
|
return {
|
|
501
673
|
"contract_version": CONTRACT_VERSION,
|
|
502
674
|
"skill": {
|
|
@@ -550,6 +722,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
550
722
|
},
|
|
551
723
|
"capabilities": caps,
|
|
552
724
|
"tools": tools,
|
|
725
|
+
"provides": capability_provides(),
|
|
553
726
|
}
|
|
554
727
|
|
|
555
728
|
|
|
@@ -568,7 +741,14 @@ def main() -> int:
|
|
|
568
741
|
print(f"available: {', '.join(d['available'])}")
|
|
569
742
|
print(f"missing required: {', '.join(d['missing']) or 'none'}")
|
|
570
743
|
print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
|
|
571
|
-
|
|
744
|
+
if d["unknown"]:
|
|
745
|
+
print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
|
|
746
|
+
for err in d["errors"]:
|
|
747
|
+
print(f"detection error: {err}", file=sys.stderr)
|
|
748
|
+
if d["ok"]:
|
|
749
|
+
return 0
|
|
750
|
+
# 1: something required is missing; 2: nothing proven missing but a required capability is unknown
|
|
751
|
+
return 1 if d["missing"] else 2
|
|
572
752
|
print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
|
|
573
753
|
return 0
|
|
574
754
|
|