ffmpeg-skill 0.9.0 → 0.12.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 +315 -122
- package/SKILL.md +115 -18
- package/bin/install.js +16 -2
- package/mcp/server.py +2 -0
- package/package.json +15 -3
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +122 -11
- package/scripts/_common.py +247 -15
- package/scripts/_contract.py +420 -48
- package/scripts/audio.py +101 -8
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -18
- package/scripts/check.py +21 -7
- package/scripts/color.py +104 -13
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +85 -11
- package/scripts/export.py +16 -7
- package/scripts/fit.py +76 -12
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +88 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +11 -1
- package/scripts/overlay.py +64 -5
- package/scripts/proxy.py +82 -0
- package/scripts/render.py +13 -2
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +15 -3
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -0
- package/scripts/sync.py +9 -1
- 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/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
|
|
@@ -32,6 +33,12 @@ HERE = Path(__file__).resolve().parent
|
|
|
32
33
|
ROOT = HERE.parent
|
|
33
34
|
SKILL_ID = "ffmpeg-skill"
|
|
34
35
|
CONTRACT_VERSION = "1.0"
|
|
36
|
+
# `doctor`'s own introspection calls (-filters/-encoders/-bsfs/-version) are meant to be fast,
|
|
37
|
+
# bounded, non-media operations; a hang here would silently freeze the one tool meant to report
|
|
38
|
+
# whether the machine is broken. Media-processing scripts (cut, fit, ...) are NOT bounded this
|
|
39
|
+
# way -- a legitimate --accurate re-encode of a long file can take a long time, so no timeout is
|
|
40
|
+
# applied there (see README, "Development").
|
|
41
|
+
_DETECT_TIMEOUT = 10
|
|
35
42
|
|
|
36
43
|
ROLES = {
|
|
37
44
|
"analysis": "reads media and reports measurements; writes no media",
|
|
@@ -63,17 +70,36 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
63
70
|
"probe": dict(role="analysis", inputs=["media (video or audio, any container ffprobe reads)"], outputs=["measurement JSON on stdout (no file)"],
|
|
64
71
|
required=["ffprobe"], optional=[{"capability": "ffmpeg", "when": "--analyze"}, {"capability": "filter:signalstats", "when": "--analyze"}],
|
|
65
72
|
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"}],
|
|
73
|
+
"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)"],
|
|
74
|
+
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
75
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
69
76
|
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
70
77
|
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
|
|
71
78
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
72
|
-
"
|
|
73
|
-
|
|
79
|
+
"crop": dict(role="execution", inputs=["video asset"], outputs=["video artifact cropped to the given pixel rectangle"],
|
|
80
|
+
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
81
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
82
|
+
"insert": dict(role="execution", inputs=["still image"], outputs=["silent video artifact of the requested duration / frame size / fps"],
|
|
83
|
+
required=FF + [X264], optional=[{"capability": "filter:zoompan", "when": "--zoom / --pan"}],
|
|
84
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
85
|
+
"background": dict(role="execution", inputs=[], outputs=["generated solid-colour or gradient video artifact"],
|
|
86
|
+
required=FF + [X264], optional=[{"capability": "filter:gradients", "when": "--gradient"}],
|
|
87
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="bit_exact", deterministic=True),
|
|
88
|
+
"reverse": dict(role="execution", inputs=["video asset"], outputs=["reversed video artifact"],
|
|
89
|
+
required=FF + [X264, "filter:reverse"], optional=[HDR_X265, {"capability": "filter:areverse", "when": "the input has audio and --no-audio is not given"}, {"capability": AAC, "when": "the input has audio and --no-audio is not given"}],
|
|
90
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
91
|
+
"stabilize": dict(role="execution", inputs=["video asset"], outputs=["motion-stabilised video artifact"],
|
|
92
|
+
required=FF + [X264, "filter:vidstabdetect", "filter:vidstabtransform"], optional=[HDR_X265, {"capability": AAC, "when": "the input has audio"}],
|
|
93
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
94
|
+
"sequence": dict(role="execution", inputs=["a directory of numbered/globbed still images"], outputs=["video artifact built from the frame sequence"],
|
|
95
|
+
required=FF + [X264], optional=[],
|
|
96
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
97
|
+
"caption": dict(role="execution", inputs=["video asset", "SRT/ASS file or timed text (--text)"], outputs=["video artifact with burnt-in captions (--mode burn)", "video artifact with an added soft subtitle stream (--mode mux)", "generated .srt / .ass sidecar"],
|
|
98
|
+
required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"},
|
|
99
|
+
{"capability": "encoder:mov_text", "when": "--mode mux with a .mp4/.m4v/.mov output"}, {"capability": "encoder:webvtt", "when": "--mode mux with a .webm output"}, {"capability": "encoder:srt", "when": "--mode mux with a .mkv output"}],
|
|
74
100
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
75
|
-
"overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo)
|
|
76
|
-
required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, HDR_X265],
|
|
101
|
+
"overlay": dict(role="execution", inputs=["video asset", "image (--image / --logo), text (--text), or a second video (--video) to composite"], outputs=["video artifact with the overlay composited"],
|
|
102
|
+
required=FF + [X264, AAC], optional=[{"capability": "filter:drawtext", "when": "--text"}, {"capability": "filter:chromakey", "when": "--chromakey"}, HDR_X265],
|
|
77
103
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
78
104
|
"graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
|
|
79
105
|
required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
|
|
@@ -84,8 +110,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
84
110
|
"multicam": dict(role="execution", inputs=["reference camera", "other cameras / recorders"], outputs=["switched multicam video artifact"],
|
|
85
111
|
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
86
112
|
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"}
|
|
113
|
+
"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)"],
|
|
114
|
+
required=FF + [AAC], optional=[{"capability": "filter:afftdn", "when": "--denoise / --voice"}, {"capability": "filter:sidechaincompress", "when": "--duck"},
|
|
115
|
+
{"capability": "filter:acompressor", "when": "--compress / --voice"}, {"capability": "filter:alimiter", "when": "--limit"}, {"capability": "filter:agate", "when": "--gate"}] + AUDIO_OUT,
|
|
89
116
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
90
117
|
"loudness": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["loudness measurement JSON (--measure-only)", "normalised artifact (video stream-copied)"],
|
|
91
118
|
required=FF + ["filter:loudnorm", AAC], optional=AUDIO_OUT,
|
|
@@ -93,12 +120,17 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
93
120
|
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
94
121
|
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
122
|
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=
|
|
123
|
+
"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)"],
|
|
124
|
+
required=FF + [X264, AAC, "filter:xfade", "filter:acrossfade"], optional=[HDR_X265] + AUDIO_OUT,
|
|
125
|
+
video_required=False, audio_only=True, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
99
126
|
"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"}
|
|
127
|
+
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
128
|
+
{"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"},
|
|
129
|
+
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
130
|
+
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
|
|
131
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
132
|
+
"proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
|
|
133
|
+
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
102
134
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
103
135
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
104
136
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
|
|
@@ -143,6 +175,45 @@ DRY_RUN_NOTES = {
|
|
|
143
175
|
"verify": "not supported: the flag is accepted but the steps run and outputs are written",
|
|
144
176
|
}
|
|
145
177
|
|
|
178
|
+
# Whether a tool re-encodes each stream *when that stream is present in the input* -- not whether
|
|
179
|
+
# the tool touches the file at all. "always"/"never" are unconditional given that stream exists;
|
|
180
|
+
# "conditional" means it depends on flags or on how far a lossless attempt misses (see "note").
|
|
181
|
+
# Read from each script's actual encode/copy args, not from role or intent, since several tools
|
|
182
|
+
# (fit, caption, overlay, graphics, color, join, multicam, silence) always transcode audio to AAC
|
|
183
|
+
# alongside a video filter even though the audio itself is untouched content -- there is no
|
|
184
|
+
# "-c:a copy while re-encoding video" path in this codebase, so a soft-subtitle-style passthrough
|
|
185
|
+
# of the original audio codec never happens on those tools.
|
|
186
|
+
REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
187
|
+
"probe": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
188
|
+
"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)"),
|
|
189
|
+
"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"),
|
|
190
|
+
"crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
|
|
191
|
+
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
192
|
+
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
193
|
+
"reverse": dict(video="always", audio="conditional", note="video always re-encodes (reverse buffers and re-emits every frame); audio re-encodes to AAC when present and not dropped by --no-audio"),
|
|
194
|
+
"stabilize": dict(video="always", audio="conditional", note="video always re-encodes (two-pass vidstab); audio is re-encoded to AAC when present, never touched by the stabilization filters themselves"),
|
|
195
|
+
"sequence": dict(video="always", audio="never", note="always encodes a fresh clip from the frame sequence; there is no audio stream"),
|
|
196
|
+
"caption": dict(video="conditional", audio="conditional", note="--mode burn (default) always re-encodes both streams to render pixels; --mode mux copies video and audio untouched and only adds a subtitle stream"),
|
|
197
|
+
"overlay": dict(video="always", audio="always"),
|
|
198
|
+
"graphics": dict(video="always", audio="always"),
|
|
199
|
+
"sync": dict(video="conditional", audio="conditional", note="video is -c:v copy only for --trim-second when the second file started earlier (offset<0) and the copy succeeds; it is re-encoded whenever --replace-audio's stream copy fails, or in --trim-second when the second file started later (offset>=0, the common case) or --fix-drift is used"),
|
|
200
|
+
"multicam": dict(video="always", audio="always"),
|
|
201
|
+
"audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
|
|
202
|
+
"loudness": dict(video="never", audio="always"),
|
|
203
|
+
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
|
|
204
|
+
"join": dict(video="always", audio="always"),
|
|
205
|
+
"color": dict(video="conditional", audio="conditional", note="--strip-dovi and --retag (when the stream copy succeeds) are -c copy of both streams; --retag falls back to re-encoding only if the copy attempt fails; --to-sdr / --lut / --correct always re-encode both"),
|
|
206
|
+
"proxy": dict(video="always", audio="conditional", note="video is always re-encoded at proxy-grade quality; audio is re-encoded when present, dropped entirely with --no-audio or when the source has none"),
|
|
207
|
+
"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"),
|
|
208
|
+
"check": dict(video="never", audio="never", note="read-only, no artifact"),
|
|
209
|
+
"scenes": dict(video="never", audio="never", note="analysis only; --sheet renders a new contact-sheet PNG, not a re-encode of the source"),
|
|
210
|
+
"look": dict(video="never", audio="never", note="renders a new contact-sheet/frame PNG, not a re-encode of the source"),
|
|
211
|
+
"render": dict(video="conditional", audio="conditional", note="delegated: depends on which stages a project.json runs and how each one behaves"),
|
|
212
|
+
"batch": dict(video="conditional", audio="conditional", note="delegated: depends on which script each recipe step runs"),
|
|
213
|
+
"verify": dict(video="conditional", audio="conditional", note="delegated: runs cut/fit/caption/export/loudness/color internally as checks"),
|
|
214
|
+
"report": dict(video="never", audio="never", note="measures via look/check; produces an HTML report, not a re-encoded artifact"),
|
|
215
|
+
}
|
|
216
|
+
|
|
146
217
|
IDEMPOTENCY = {
|
|
147
218
|
"bit_exact": "same inputs and flags give byte-identical output",
|
|
148
219
|
"content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
|
|
@@ -259,6 +330,20 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
259
330
|
extra = {"report": {"type": "string"}, "check": {"type": ["object", "null"]}}
|
|
260
331
|
elif name == "loudness":
|
|
261
332
|
extra = {"measured": {"type": "object", "description": "--measure-only prints the loudnorm measurement instead (input_i, input_tp, input_lra, input_thresh, target_offset)"}}
|
|
333
|
+
elif name == "cut":
|
|
334
|
+
extra = {"expected_duration": {"type": "number", "description": "seconds requested"},
|
|
335
|
+
"duration_error_ms": {"type": ["number", "null"], "description": "written minus requested, measured by ffprobe (null under --dry-run)"},
|
|
336
|
+
"precision": {"enum": ["packet", "sample", "codec_frame", "frame"],
|
|
337
|
+
"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"},
|
|
338
|
+
"reencoded": {"type": "boolean"}}
|
|
339
|
+
elif name == "join":
|
|
340
|
+
extra = {"mode": {"enum": ["video", "audio"]}, "clips": {"type": "integer"}, "transition": {"type": "string"}, "expected_duration": {"type": "number"},
|
|
341
|
+
"sample_rate": {"type": "integer", "description": "audio mode only"}, "channels": {"type": "integer", "description": "audio mode only"},
|
|
342
|
+
"video": {"type": "boolean", "description": "false in audio mode: the output has no video stream"}}
|
|
343
|
+
elif name == "audio":
|
|
344
|
+
extra = {"video": {"type": "boolean", "description": "true when the input's video stream was copied; false for an audio output extension (extraction)"},
|
|
345
|
+
"audio_stream": {"type": "integer", "description": "which input audio stream was processed (--audio-stream)"},
|
|
346
|
+
"dynamics": {"type": "array", "items": {"enum": ["agate", "acompressor", "alimiter"]}, "description": "typed dynamics filters applied, in graph order"}}
|
|
262
347
|
props = dict(base)
|
|
263
348
|
props.update(extra)
|
|
264
349
|
required = ["status", "output", "dry_run", "commands"]
|
|
@@ -288,31 +373,99 @@ def public_tools() -> List[str]:
|
|
|
288
373
|
return sorted(p.stem for p in HERE.glob("*.py") if not p.name.startswith("_"))
|
|
289
374
|
|
|
290
375
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
376
|
+
# `ffmpeg -filters` rows: FFmpeg <= 7 prints three flag characters (`..C acompressor A->A ...`),
|
|
377
|
+
# FFmpeg 8 prints two (`T. acompressor A->A ...`). The row is recognised by its io-spec token
|
|
378
|
+
# (`A->A`, `|->V`, `N->N`, ...) so the flag width does not matter; a legend line never carries `->`.
|
|
379
|
+
_FILTER_ROW = re.compile(r"^\s*(?:[A-Z.]{1,6}\s+)?([A-Za-z0-9_]+)\s+(\S*->\S*)(?:\s|$)")
|
|
380
|
+
# `ffmpeg -encoders` rows follow a ` ------` separator: flags (six characters today; any width of
|
|
381
|
+
# letters and dots is accepted) then the encoder name. A legend line has `=` where the name would be.
|
|
382
|
+
_ENCODER_ROW = re.compile(r"^\s*[A-Z.]{2,10}\s+([A-Za-z0-9_-]+)(?:\s|$)")
|
|
383
|
+
_LIST_SEPARATOR = re.compile(r"^\s*-{3,}\s*$")
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _parse_ff_list(flag: str, text: str) -> List[str]:
|
|
387
|
+
"""Names in the stdout of `ffmpeg <flag>`; empty when no row was recognised."""
|
|
297
388
|
names: List[str] = []
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
389
|
+
if flag == "-filters":
|
|
390
|
+
for line in text.splitlines():
|
|
391
|
+
m = _FILTER_ROW.match(line)
|
|
392
|
+
if m:
|
|
393
|
+
names.append(m.group(1))
|
|
394
|
+
elif flag == "-encoders":
|
|
395
|
+
lines = text.splitlines()
|
|
396
|
+
sep = next((i for i, l in enumerate(lines) if _LIST_SEPARATOR.match(l)), None)
|
|
397
|
+
rows = lines[sep + 1:] if sep is not None else lines
|
|
398
|
+
for line in rows:
|
|
399
|
+
m = _ENCODER_ROW.match(line)
|
|
400
|
+
if m and m.group(1) != "=":
|
|
401
|
+
names.append(m.group(1))
|
|
402
|
+
elif flag == "-bsfs":
|
|
403
|
+
for line in text.splitlines():
|
|
404
|
+
parts = line.split()
|
|
304
405
|
if len(parts) == 1 and not parts[0].endswith(":"):
|
|
305
406
|
names.append(parts[0])
|
|
306
|
-
elif flags and len(parts) >= 2 and re.fullmatch(flags, parts[0]):
|
|
307
|
-
names.append(parts[1])
|
|
308
407
|
return names
|
|
309
408
|
|
|
310
409
|
|
|
410
|
+
def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
411
|
+
"""`{"names": [...], "status": parsed | unparsed | failed | missing, "detail": str}` for `ffmpeg <flag>`.
|
|
412
|
+
|
|
413
|
+
`parsed`: rows recognised. `unparsed`: ffmpeg ran but no row matched, so the capabilities it
|
|
414
|
+
covers are unknown, not absent. `failed`: ffmpeg exited non-zero. `missing`: no binary on PATH.
|
|
415
|
+
"""
|
|
416
|
+
exe = shutil.which(binary)
|
|
417
|
+
if not exe:
|
|
418
|
+
return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
|
|
419
|
+
try:
|
|
420
|
+
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
|
|
421
|
+
except subprocess.TimeoutExpired:
|
|
422
|
+
return {"names": [], "status": "failed", "detail": f"{binary} {flag} did not exit within {_DETECT_TIMEOUT}s"}
|
|
423
|
+
except OSError as e:
|
|
424
|
+
return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
|
|
425
|
+
if proc.returncode != 0:
|
|
426
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
427
|
+
return {"names": [], "status": "failed", "detail": f"{binary} {flag} exited {proc.returncode}: {tail}"}
|
|
428
|
+
names = _parse_ff_list(flag, proc.stdout)
|
|
429
|
+
if not names:
|
|
430
|
+
return {"names": [], "status": "unparsed", "detail": f"no row recognised in `{binary} {flag}` output ({len(proc.stdout.splitlines())} lines)"}
|
|
431
|
+
return {"names": names, "status": "parsed", "detail": f"{len(names)} entries"}
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _ff_list(binary: str, flag: str) -> List[str]:
|
|
435
|
+
"""Names from `ffmpeg -encoders` / `-filters` / `-bsfs` (empty list when ffmpeg is missing or unparsed)."""
|
|
436
|
+
return _ff_listing(binary, flag)["names"]
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
# GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
|
|
440
|
+
# hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
|
|
441
|
+
# new codec in a future ffmpeg build needs no change here.
|
|
442
|
+
_GPU_ENCODER_SUFFIXES = ("_nvenc", "_videotoolbox", "_qsv", "_vaapi", "_amf")
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _gpu_encoders(encoders_listing: Dict[str, Any]) -> Dict[str, Any]:
|
|
446
|
+
"""GPU-backed encoders this ffmpeg BUILD was compiled with, read from `-encoders` alone.
|
|
447
|
+
|
|
448
|
+
This proves the build carries e.g. h264_nvenc; it does NOT prove the GPU/driver on this
|
|
449
|
+
machine will accept a job -- that would require actually running an encode, which doctor's
|
|
450
|
+
introspection deliberately never does beyond listing/-version (see doctor()'s own docstring).
|
|
451
|
+
A caller that needs to know "will a GPU encode actually work here" has to try one; this only
|
|
452
|
+
answers "did this ffmpeg build even ship the capability."
|
|
453
|
+
"""
|
|
454
|
+
status = encoders_listing["status"]
|
|
455
|
+
if status != "parsed":
|
|
456
|
+
return {"status": status, "detail": encoders_listing["detail"], "present": []}
|
|
457
|
+
present = sorted(n for n in encoders_listing["names"] if n.endswith(_GPU_ENCODER_SUFFIXES))
|
|
458
|
+
return {"status": "parsed", "present": present}
|
|
459
|
+
|
|
460
|
+
|
|
311
461
|
def _version_line(binary: str) -> Optional[str]:
|
|
312
462
|
exe = shutil.which(binary)
|
|
313
463
|
if not exe:
|
|
314
464
|
return None
|
|
315
|
-
|
|
465
|
+
try:
|
|
466
|
+
proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
|
|
467
|
+
except subprocess.TimeoutExpired:
|
|
468
|
+
return None
|
|
316
469
|
first = (proc.stdout or proc.stderr).splitlines()[:1]
|
|
317
470
|
m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
|
|
318
471
|
return m.group(1) if m else (first[0] if first else "unknown")
|
|
@@ -324,6 +477,44 @@ def _whisper_available() -> bool:
|
|
|
324
477
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
325
478
|
|
|
326
479
|
|
|
480
|
+
def _default_font() -> str:
|
|
481
|
+
from _common import BRAND_DEFAULTS
|
|
482
|
+
return str(BRAND_DEFAULTS["font"])
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _font_available(font_name: str) -> Dict[str, Any]:
|
|
486
|
+
"""Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
|
|
487
|
+
installed, distinct from silently resolving to a substitute.
|
|
488
|
+
|
|
489
|
+
This cannot be answered by running drawtext and checking its exit code: fontconfig substitutes
|
|
490
|
+
the closest match for ANY name, known or not, so `ffmpeg -vf drawtext=font='<garbage>'` still
|
|
491
|
+
exits 0 (verified against this repo's own sandbox ffmpeg -- a deliberately bogus family name
|
|
492
|
+
produces the same success exit code as "DejaVu Sans"). That is exactly the "false success" this
|
|
493
|
+
capability exists to catch (see issue #66): a missing font never fails the encode, it just
|
|
494
|
+
silently renders with a different typeface. `fc-match` is queried instead, since it reports the
|
|
495
|
+
family fontconfig actually resolved to; that only equals the request when the font is installed.
|
|
496
|
+
"""
|
|
497
|
+
exe = shutil.which("fc-match")
|
|
498
|
+
if not exe:
|
|
499
|
+
return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
|
|
500
|
+
try:
|
|
501
|
+
proc = subprocess.run([exe, "--format=%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
|
|
502
|
+
except subprocess.TimeoutExpired:
|
|
503
|
+
return {"status": "unknown", "detail": f"fc-match did not exit within {_DETECT_TIMEOUT}s"}
|
|
504
|
+
except OSError as e:
|
|
505
|
+
return {"status": "unknown", "detail": f"fc-match: {e}"}
|
|
506
|
+
if proc.returncode != 0:
|
|
507
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
508
|
+
return {"status": "unknown", "detail": f"fc-match exited {proc.returncode}: {tail}"}
|
|
509
|
+
lines = [l for l in proc.stdout.splitlines() if l.strip()]
|
|
510
|
+
resolved = lines[0].strip() if lines else ""
|
|
511
|
+
if not resolved:
|
|
512
|
+
return {"status": "unknown", "detail": "fc-match produced no output"}
|
|
513
|
+
if resolved.lower() == font_name.lower():
|
|
514
|
+
return {"status": "available", "detail": f"fc-match resolves '{font_name}' to itself"}
|
|
515
|
+
return {"status": "missing", "detail": f"fc-match substitutes '{resolved}' for '{font_name}' -- '{font_name}' is not installed"}
|
|
516
|
+
|
|
517
|
+
|
|
327
518
|
def required_capabilities() -> Dict[str, List[str]]:
|
|
328
519
|
req: set = set()
|
|
329
520
|
opt: set = set()
|
|
@@ -335,41 +526,197 @@ def required_capabilities() -> Dict[str, List[str]]:
|
|
|
335
526
|
|
|
336
527
|
|
|
337
528
|
def doctor() -> Dict[str, Any]:
|
|
338
|
-
"""Detect which declared capabilities this machine has. No secrets, no environment variables.
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
529
|
+
"""Detect which declared capabilities this machine has. No secrets, no environment variables.
|
|
530
|
+
|
|
531
|
+
`version` is this INSTALLED COPY's own version (read from its local package.json, same value
|
|
532
|
+
`contract --json`'s `skill.version` reports) -- never fetched from the network or compared
|
|
533
|
+
against the latest published release. A copy installed with `npx ffmpeg-skill` is not updated
|
|
534
|
+
automatically; re-run the installer to refresh it, then `doctor` again to confirm the version
|
|
535
|
+
changed. This exists so a stale installed copy is visible locally, not to check for updates.
|
|
536
|
+
|
|
537
|
+
Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
|
|
538
|
+
that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
|
|
539
|
+
`missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
|
|
540
|
+
is not a pass). `ok` is true only when nothing required is missing or unknown.
|
|
541
|
+
|
|
542
|
+
`gpu_encoders` is a separate, honest answer to a question none of the required/optional
|
|
543
|
+
capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
|
|
544
|
+
ffmpeg BUILD carries, from `-encoders` alone. No tool here requires or uses one -- every tool
|
|
545
|
+
still assumes CPU x264/x265 -- so `gpu_encoders` never affects `ok` or any tool's `usable`. It
|
|
546
|
+
only proves the build shipped the capability, never that the GPU/driver on this machine will
|
|
547
|
+
actually accept a job (that needs a real encode, which this introspection never runs).
|
|
548
|
+
|
|
549
|
+
`fonts` is the same kind of informational answer for the default drawtext font (caption.py's
|
|
550
|
+
--animate/--karaoke, graphics.py's templates -- see issue #66): available/missing/unknown for
|
|
551
|
+
whether BRAND_DEFAULTS["font"] ("DejaVu Sans") is actually installed, not silently substituted
|
|
552
|
+
by fontconfig. It never affects `ok` or a tool's `usable` -- a missing font is not a broken
|
|
553
|
+
tool, drawtext still runs and still writes an artifact, it may just render with a different
|
|
554
|
+
typeface than requested (which is why this exists: that substitution is otherwise invisible).
|
|
555
|
+
"""
|
|
556
|
+
listings = {
|
|
557
|
+
"encoders": _ff_listing("ffmpeg", "-encoders"),
|
|
558
|
+
"filters": _ff_listing("ffmpeg", "-filters"),
|
|
559
|
+
"bsfs": _ff_listing("ffmpeg", "-bsfs"),
|
|
560
|
+
}
|
|
561
|
+
sets = {k: set(v["names"]) for k, v in listings.items()}
|
|
562
|
+
state: Dict[str, str] = {} # capability -> available | missing | unknown
|
|
343
563
|
wanted = required_capabilities()
|
|
564
|
+
|
|
565
|
+
def _from(kind: str, name: str) -> str:
|
|
566
|
+
lst = listings[kind]
|
|
567
|
+
if lst["status"] == "parsed":
|
|
568
|
+
return "available" if name in sets[kind] else "missing"
|
|
569
|
+
if lst["status"] == "missing":
|
|
570
|
+
return "missing" # no ffmpeg at all: nothing it provides is available
|
|
571
|
+
return "unknown"
|
|
572
|
+
|
|
344
573
|
for cap in wanted["required"] + wanted["optional"]:
|
|
345
574
|
if cap == "ffmpeg":
|
|
346
|
-
|
|
575
|
+
state[cap] = "available" if shutil.which("ffmpeg") else "missing"
|
|
347
576
|
elif cap == "ffprobe":
|
|
348
|
-
|
|
577
|
+
state[cap] = "available" if shutil.which("ffprobe") else "missing"
|
|
349
578
|
elif cap.startswith("encoder:"):
|
|
350
|
-
|
|
579
|
+
state[cap] = _from("encoders", cap[8:])
|
|
351
580
|
elif cap.startswith("filter:"):
|
|
352
|
-
|
|
581
|
+
state[cap] = _from("filters", cap[7:])
|
|
353
582
|
elif cap.startswith("bsf:"):
|
|
354
|
-
|
|
583
|
+
state[cap] = _from("bsfs", cap[4:])
|
|
355
584
|
elif cap == "external:whisper":
|
|
356
|
-
|
|
585
|
+
state[cap] = "available" if _whisper_available() else "missing"
|
|
357
586
|
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
|
|
587
|
+
state[cap] = "missing"
|
|
588
|
+
available = sorted(c for c, st in state.items() if st == "available")
|
|
589
|
+
missing_required = sorted(c for c in wanted["required"] if state[c] == "missing")
|
|
590
|
+
missing_optional = sorted(c for c in wanted["optional"] if state[c] == "missing")
|
|
591
|
+
unknown = sorted(c for c, st in state.items() if st == "unknown")
|
|
592
|
+
unknown_required = [c for c in unknown if c in wanted["required"]]
|
|
593
|
+
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
362
594
|
return {
|
|
595
|
+
"version": skill_version(),
|
|
363
596
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
364
597
|
"ffmpeg": _version_line("ffmpeg"),
|
|
365
598
|
"ffprobe": _version_line("ffprobe"),
|
|
366
599
|
"available": available,
|
|
367
600
|
"missing": missing_required,
|
|
368
601
|
"missing_optional": missing_optional,
|
|
369
|
-
"
|
|
602
|
+
"unknown": unknown,
|
|
603
|
+
"detection": {k: {"status": v["status"], "count": len(v["names"]), "detail": v["detail"]} for k, v in listings.items()},
|
|
604
|
+
"errors": errors,
|
|
605
|
+
"ok": not missing_required and not unknown_required,
|
|
606
|
+
"tools": _tool_usability(state),
|
|
607
|
+
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
608
|
+
"fonts": _fonts_capability(),
|
|
370
609
|
}
|
|
371
610
|
|
|
372
611
|
|
|
612
|
+
def _fonts_capability() -> Dict[str, Any]:
|
|
613
|
+
font = _default_font()
|
|
614
|
+
result = _font_available(font)
|
|
615
|
+
return {"default_font": font, "status": result["status"], "detail": result["detail"]}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _capability_fix_hint(cap: str) -> str:
|
|
619
|
+
"""One-line, plain-language remedy for a single missing/unknown capability."""
|
|
620
|
+
if cap in ("ffmpeg", "ffprobe"):
|
|
621
|
+
from _common import INSTALL_HINTS
|
|
622
|
+
hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
|
|
623
|
+
return f"install ffmpeg: {hint}"
|
|
624
|
+
system = platform.system()
|
|
625
|
+
if system == "Darwin":
|
|
626
|
+
full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)"
|
|
627
|
+
elif system == "Windows":
|
|
628
|
+
full_hint = "on Windows, winget install Gyan.FFmpeg (the gyan.dev full build carries subtitles/drawtext/zscale; a plain choco ffmpeg package can lack them)"
|
|
629
|
+
else:
|
|
630
|
+
full_hint = "install/build ffmpeg with it enabled"
|
|
631
|
+
if cap.startswith("encoder:"):
|
|
632
|
+
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
633
|
+
if cap.startswith("filter:"):
|
|
634
|
+
return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
|
|
635
|
+
if cap.startswith("bsf:"):
|
|
636
|
+
return f"this ffmpeg build has no {cap[4:]} bitstream filter; {full_hint}"
|
|
637
|
+
if cap == "external:whisper":
|
|
638
|
+
return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
|
|
639
|
+
return f"'{cap}' is not available; see docs/contract.md"
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _tool_usability(state: Dict[str, str]) -> Dict[str, Dict[str, Any]]:
|
|
643
|
+
"""Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
|
|
644
|
+
computed. Answers "can I run this tool on this machine today", not just "what capabilities
|
|
645
|
+
exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
|
|
646
|
+
own required-capability list by hand to answer that."""
|
|
647
|
+
tools: Dict[str, Dict[str, Any]] = {}
|
|
648
|
+
for name, meta in TOOL_META.items():
|
|
649
|
+
required = list(meta["required"])
|
|
650
|
+
missing = [c for c in required if state.get(c) == "missing"]
|
|
651
|
+
unknown = [c for c in required if state.get(c) == "unknown"]
|
|
652
|
+
entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
|
|
653
|
+
if missing:
|
|
654
|
+
entry["missing"] = missing
|
|
655
|
+
entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c) for c in missing))
|
|
656
|
+
if unknown:
|
|
657
|
+
entry["unknown"] = unknown
|
|
658
|
+
tools[name] = entry
|
|
659
|
+
return tools
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
# Cross-repository Capability ids (kajisho5/AI-video-production-OS docs/SPEC.md
|
|
663
|
+
# `CapabilityContract.provides`), matching the ids already assigned to these 21 tools in
|
|
664
|
+
# that project's own docs/CAPABILITY_MATRIX.md section 9: "ffmpeg-skill's 21 raw tools ...
|
|
665
|
+
# are Capabilities in their own right, independent of the higher-level Skills that
|
|
666
|
+
# delegate to them". Each tool's own id is `ffmpeg-skill/<tool>` (a slash, matching every
|
|
667
|
+
# ToolSpec.id here); the Capability id uses a dot - `ffmpeg-skill.<tool>` - the same
|
|
668
|
+
# `<domain>.<verb>`-shaped convention every other Skill's Capability ids use elsewhere in
|
|
669
|
+
# that project (`video.trim`, `audio.gain`, ...), with "ffmpeg-skill" as the domain.
|
|
670
|
+
def capability_provides() -> List[Dict[str, str]]:
|
|
671
|
+
return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
# Abstract, domain-shaped capability ids (`<domain>.<verb>`, matching the convention `provides`
|
|
675
|
+
# already documents for cross-repo Capability ids) that a planning agent can resolve without
|
|
676
|
+
# already knowing this skill's tool names. Unlike `provides` (one entry per tool, id derived
|
|
677
|
+
# from the tool name), this is a hand-authored, many-to-one table: several of these ids name a
|
|
678
|
+
# tool PLUS the fixed parameters that pin it to that specific behaviour (`video.reframe` is
|
|
679
|
+
# `fit.py` with `fit=crop` fixed, not bare `fit.py`, which also speed-ramps and pads). This is
|
|
680
|
+
# still purely descriptive -- a caller still builds and runs the named tool's own CLI/MCP call
|
|
681
|
+
# from its `input_schema`; nothing here executes or chooses on the caller's behalf. Deliberately
|
|
682
|
+
# excludes any capability that would require judgment to resolve (e.g. no `video.highlight`:
|
|
683
|
+
# `scenes.py --highlights` ranks by a measured proxy, never by understood content -- see
|
|
684
|
+
# SKILL.md "What this skill does and does not decide" -- so it is not offered as a capability
|
|
685
|
+
# a planner can blindly delegate to). `media.proxy` (a low-bitrate, fast-decode proxy, distinct
|
|
686
|
+
# from `export.py`'s delivery presets, which target visual quality over size/speed) resolves to
|
|
687
|
+
# `proxy.py` -- itself a purely mechanical resize+re-encode with no opinion on which asset should
|
|
688
|
+
# be proxied or what for.
|
|
689
|
+
CAPABILITY_MAP: List[Dict[str, Any]] = [
|
|
690
|
+
{"capability": "video.trim", "tool_id": f"{SKILL_ID}/cut", "params": {}},
|
|
691
|
+
{"capability": "video.reframe", "tool_id": f"{SKILL_ID}/fit", "params": {"fit": "crop"}},
|
|
692
|
+
{"capability": "audio.loudness", "tool_id": f"{SKILL_ID}/loudness", "params": {}},
|
|
693
|
+
{"capability": "subtitle.burn", "tool_id": f"{SKILL_ID}/caption", "params": {}},
|
|
694
|
+
{"capability": "media.stream.inspect", "tool_id": f"{SKILL_ID}/probe", "params": {}},
|
|
695
|
+
{"capability": "media.frames.extract", "tool_id": f"{SKILL_ID}/look", "params": {}},
|
|
696
|
+
{"capability": "media.proxy", "tool_id": f"{SKILL_ID}/proxy", "params": {}},
|
|
697
|
+
]
|
|
698
|
+
|
|
699
|
+
|
|
700
|
+
def capability_map(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
701
|
+
"""Validate CAPABILITY_MAP against the live tool specs before returning it. A fixed param
|
|
702
|
+
that isn't a real input_schema property is drift, not a typo to ship silently (same "fail
|
|
703
|
+
loudly" posture as tool_spec()'s missing-TOOL_META check). A tool_id that no longer exists
|
|
704
|
+
is not drift in that sense -- a tool can legitimately be removed -- so that capability is
|
|
705
|
+
dropped from the map rather than crashing the whole contract build."""
|
|
706
|
+
by_id = {t["id"]: t for t in tools}
|
|
707
|
+
result = []
|
|
708
|
+
for entry in CAPABILITY_MAP:
|
|
709
|
+
spec = by_id.get(entry["tool_id"])
|
|
710
|
+
if spec is None:
|
|
711
|
+
continue
|
|
712
|
+
props = spec["input_schema"]["properties"]
|
|
713
|
+
for key in entry["params"]:
|
|
714
|
+
if key not in props:
|
|
715
|
+
raise RuntimeError(f"capability {entry['capability']!r} sets param {key!r} which is not in {entry['tool_id']}'s input_schema")
|
|
716
|
+
result.append(dict(entry))
|
|
717
|
+
return result
|
|
718
|
+
|
|
719
|
+
|
|
373
720
|
# ----------------------------------------------------------------------------- contract
|
|
374
721
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
375
722
|
if name not in TOOL_META:
|
|
@@ -412,6 +759,11 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
412
759
|
"mutates_input": False,
|
|
413
760
|
"produces_artifact": meta["produces_artifact"],
|
|
414
761
|
"verification": {"required": bool(meta["verify"]), "tools": [f"{SKILL_ID}/{t}" for t in meta["verify"]]},
|
|
762
|
+
# a tool with no declared entry reports "conditional" rather than guessing "always" or
|
|
763
|
+
# "never" -- same "unknown is not missing" principle as doctor's capability detection
|
|
764
|
+
"reencodes_video": REENCODE_META.get(name, {}).get("video", "conditional"),
|
|
765
|
+
"reencodes_audio": REENCODE_META.get(name, {}).get("audio", "conditional"),
|
|
766
|
+
**({"reencode_note": REENCODE_META[name]["note"]} if REENCODE_META.get(name, {}).get("note") else {}),
|
|
415
767
|
"requires_visual_verification": meta["visual"],
|
|
416
768
|
"audio_only": meta["audio_only"],
|
|
417
769
|
"video_required": meta["video_required"],
|
|
@@ -496,7 +848,8 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
496
848
|
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
|
|
497
849
|
if detect:
|
|
498
850
|
d = doctor()
|
|
499
|
-
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
|
|
851
|
+
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
|
|
852
|
+
"unknown": d["unknown"], "detection": d["detection"], "detected_by": "doctor"})
|
|
500
853
|
return {
|
|
501
854
|
"contract_version": CONTRACT_VERSION,
|
|
502
855
|
"skill": {
|
|
@@ -545,11 +898,14 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
545
898
|
},
|
|
546
899
|
"json_output": {
|
|
547
900
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
548
|
-
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"error\": {\"kind\": ..., \"message\": ...}} when --json was given", "stderr": "human-readable message"},
|
|
549
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error", "missing_tool": "ffmpeg or ffprobe not on PATH"},
|
|
901
|
+
"failure": {"status": "failed", "exit_code": "non-zero (127 when ffmpeg/ffprobe is missing)", "stdout": "{\"status\": \"failed\", \"exit_code\": N, \"error\": {\"kind\": ..., \"message\": ...}, \"commands\": [...]} when --json was given", "stderr": "human-readable message"},
|
|
902
|
+
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error (message carries the last stderr lines)", "output": "ffmpeg exited 0 but the artifact is missing, empty or unreadable (an empty file is removed)", "missing_tool": "ffmpeg or ffprobe not on PATH"},
|
|
903
|
+
"success_criterion": "exit 0 AND the output exists AND is non-empty AND ffprobe reads a stream from it; only then is status completed printed and the output probe attached",
|
|
550
904
|
},
|
|
551
905
|
"capabilities": caps,
|
|
552
906
|
"tools": tools,
|
|
907
|
+
"provides": capability_provides(),
|
|
908
|
+
"capability_map": capability_map(tools),
|
|
553
909
|
}
|
|
554
910
|
|
|
555
911
|
|
|
@@ -564,11 +920,27 @@ def main() -> int:
|
|
|
564
920
|
if args.json:
|
|
565
921
|
print(json.dumps(d, indent=2, sort_keys=True))
|
|
566
922
|
else:
|
|
923
|
+
print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
|
|
567
924
|
print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
|
|
568
925
|
print(f"available: {', '.join(d['available'])}")
|
|
569
926
|
print(f"missing required: {', '.join(d['missing']) or 'none'}")
|
|
570
927
|
print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
|
|
571
|
-
|
|
928
|
+
if d["unknown"]:
|
|
929
|
+
print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
|
|
930
|
+
not_usable = sorted(name for name, t in d["tools"].items() if t["usable"] != "yes")
|
|
931
|
+
if not_usable and d["ok"]:
|
|
932
|
+
print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
|
|
933
|
+
gpu = d["gpu_encoders"]
|
|
934
|
+
if gpu["status"] == "parsed":
|
|
935
|
+
print(f"GPU-backed encoders in this build: {', '.join(gpu['present']) or 'none'} (no tool here uses one yet; this build-presence check does not prove the GPU/driver will accept a job)")
|
|
936
|
+
fonts = d["fonts"]
|
|
937
|
+
print(f"default drawtext font '{fonts['default_font']}': {fonts['status']} ({fonts['detail']})")
|
|
938
|
+
for err in d["errors"]:
|
|
939
|
+
print(f"detection error: {err}", file=sys.stderr)
|
|
940
|
+
if d["ok"]:
|
|
941
|
+
return 0
|
|
942
|
+
# 1: something required is missing; 2: nothing proven missing but a required capability is unknown
|
|
943
|
+
return 1 if d["missing"] else 2
|
|
572
944
|
print(json.dumps(build(detect=not args.static), indent=2, sort_keys=True, ensure_ascii=False))
|
|
573
945
|
return 0
|
|
574
946
|
|