ffmpeg-skill 0.10.0 → 0.12.5
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 +70 -10
- package/SKILL.md +89 -12
- package/bin/install.js +15 -1
- package/mcp/__pycache__/server.cpython-311.pyc +0 -0
- package/mcp/server.py +2 -0
- package/package.json +2 -2
- package/references/ci-platform-pitfalls.md +111 -0
- package/references/process-pitfalls.md +85 -0
- package/references/scripts.md +139 -12
- 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/_common.py +255 -14
- package/scripts/_contract.py +276 -13
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +106 -24
- package/scripts/color.py +129 -30
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +73 -14
- package/scripts/graphics.py +18 -7
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/look.py +13 -8
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +78 -12
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/scenes.py +8 -2
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +101 -0
- package/scripts/sync.py +1 -1
package/scripts/_contract.py
CHANGED
|
@@ -33,6 +33,12 @@ HERE = Path(__file__).resolve().parent
|
|
|
33
33
|
ROOT = HERE.parent
|
|
34
34
|
SKILL_ID = "ffmpeg-skill"
|
|
35
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
|
|
36
42
|
|
|
37
43
|
ROLES = {
|
|
38
44
|
"analysis": "reads media and reports measurements; writes no media",
|
|
@@ -70,11 +76,30 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
70
76
|
"fit": dict(role="execution", inputs=["video asset"], outputs=["video artifact at the requested duration / aspect / fps"],
|
|
71
77
|
required=FF + [X264, AAC], optional=[HDR_X265, {"capability": "filter:minterpolate", "when": "--smooth interpolate"}],
|
|
72
78
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
73
|
-
"
|
|
74
|
-
|
|
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"}],
|
|
75
90
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
76
|
-
"
|
|
77
|
-
|
|
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"}],
|
|
100
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
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],
|
|
78
103
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
79
104
|
"graphics": dict(role="execution", inputs=["video asset"], outputs=["video artifact with the drawn template"],
|
|
80
105
|
required=FF + [X264, AAC, "filter:drawtext"], optional=[HDR_X265],
|
|
@@ -102,7 +127,11 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
102
127
|
required=FF, optional=[{"capability": X264, "when": "--to-sdr / --lut / --correct"}, {"capability": "filter:zscale", "when": "--to-sdr"}, {"capability": "filter:tonemap", "when": "--to-sdr"},
|
|
103
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"},
|
|
104
129
|
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
105
|
-
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}
|
|
130
|
+
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"},
|
|
131
|
+
{"capability": "filter:colorlevels", "when": "--correct with any --levels-*"}, {"capability": "filter:curves", "when": "--correct --curves"}],
|
|
132
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
133
|
+
"proxy": dict(role="execution", inputs=["video asset"], outputs=["low-resolution, low-bitrate proxy artifact for downstream analysis, preview or editing decisions"],
|
|
134
|
+
required=FF + [X264, AAC], optional=[HDR_X265],
|
|
106
135
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
107
136
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
108
137
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
|
|
@@ -159,16 +188,23 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
159
188
|
"probe": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
160
189
|
"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
190
|
"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
|
-
"
|
|
191
|
+
"crop": dict(video="always", audio="always", note="the crop filter always forces a re-encode of both streams"),
|
|
192
|
+
"insert": dict(video="always", audio="never", note="always encodes a fresh silent clip from the still image; there is no audio stream to touch"),
|
|
193
|
+
"background": dict(video="always", audio="never", note="always encodes a fresh generated clip; there is no input to copy from"),
|
|
194
|
+
"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"),
|
|
195
|
+
"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"),
|
|
196
|
+
"sequence": dict(video="always", audio="never", note="always encodes a fresh clip from the frame sequence; there is no audio stream"),
|
|
197
|
+
"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"),
|
|
163
198
|
"overlay": dict(video="always", audio="always"),
|
|
164
199
|
"graphics": dict(video="always", audio="always"),
|
|
165
|
-
"sync": dict(video="
|
|
200
|
+
"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"),
|
|
166
201
|
"multicam": dict(video="always", audio="always"),
|
|
167
202
|
"audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
|
|
168
203
|
"loudness": dict(video="never", audio="always"),
|
|
169
204
|
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
|
|
170
205
|
"join": dict(video="always", audio="always"),
|
|
171
|
-
"color": dict(video="
|
|
206
|
+
"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"),
|
|
207
|
+
"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"),
|
|
172
208
|
"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
209
|
"check": dict(video="never", audio="never", note="read-only, no artifact"),
|
|
174
210
|
"scenes": dict(video="never", audio="never", note="analysis only; --sheet renders a new contact-sheet PNG, not a re-encode of the source"),
|
|
@@ -382,7 +418,9 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
|
382
418
|
if not exe:
|
|
383
419
|
return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
|
|
384
420
|
try:
|
|
385
|
-
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
421
|
+
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
|
|
422
|
+
except subprocess.TimeoutExpired:
|
|
423
|
+
return {"names": [], "status": "failed", "detail": f"{binary} {flag} did not exit within {_DETECT_TIMEOUT}s"}
|
|
386
424
|
except OSError as e:
|
|
387
425
|
return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
|
|
388
426
|
if proc.returncode != 0:
|
|
@@ -399,11 +437,36 @@ def _ff_list(binary: str, flag: str) -> List[str]:
|
|
|
399
437
|
return _ff_listing(binary, flag)["names"]
|
|
400
438
|
|
|
401
439
|
|
|
440
|
+
# GPU-backed encoders are named `<codec>_<backend>` by every ffmpeg build (h264_nvenc,
|
|
441
|
+
# hevc_videotoolbox, av1_qsv, h264_vaapi, hevc_amf, ...); recognised by the backend suffix so a
|
|
442
|
+
# new codec in a future ffmpeg build needs no change here.
|
|
443
|
+
_GPU_ENCODER_SUFFIXES = ("_nvenc", "_videotoolbox", "_qsv", "_vaapi", "_amf")
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _gpu_encoders(encoders_listing: Dict[str, Any]) -> Dict[str, Any]:
|
|
447
|
+
"""GPU-backed encoders this ffmpeg BUILD was compiled with, read from `-encoders` alone.
|
|
448
|
+
|
|
449
|
+
This proves the build carries e.g. h264_nvenc; it does NOT prove the GPU/driver on this
|
|
450
|
+
machine will accept a job -- that would require actually running an encode, which doctor's
|
|
451
|
+
introspection deliberately never does beyond listing/-version (see doctor()'s own docstring).
|
|
452
|
+
A caller that needs to know "will a GPU encode actually work here" has to try one; this only
|
|
453
|
+
answers "did this ffmpeg build even ship the capability."
|
|
454
|
+
"""
|
|
455
|
+
status = encoders_listing["status"]
|
|
456
|
+
if status != "parsed":
|
|
457
|
+
return {"status": status, "detail": encoders_listing["detail"], "present": []}
|
|
458
|
+
present = sorted(n for n in encoders_listing["names"] if n.endswith(_GPU_ENCODER_SUFFIXES))
|
|
459
|
+
return {"status": "parsed", "present": present}
|
|
460
|
+
|
|
461
|
+
|
|
402
462
|
def _version_line(binary: str) -> Optional[str]:
|
|
403
463
|
exe = shutil.which(binary)
|
|
404
464
|
if not exe:
|
|
405
465
|
return None
|
|
406
|
-
|
|
466
|
+
try:
|
|
467
|
+
proc = subprocess.run([exe, "-version"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
|
|
468
|
+
except subprocess.TimeoutExpired:
|
|
469
|
+
return None
|
|
407
470
|
first = (proc.stdout or proc.stderr).splitlines()[:1]
|
|
408
471
|
m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
|
|
409
472
|
return m.group(1) if m else (first[0] if first else "unknown")
|
|
@@ -415,6 +478,88 @@ def _whisper_available() -> bool:
|
|
|
415
478
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
416
479
|
|
|
417
480
|
|
|
481
|
+
def _default_font() -> str:
|
|
482
|
+
from _common import BRAND_DEFAULTS
|
|
483
|
+
return str(BRAND_DEFAULTS["font"])
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def _drawtext_probe() -> Dict[str, Any]:
|
|
487
|
+
"""Actually render one frame through drawtext, rather than trusting `-filters` alone.
|
|
488
|
+
|
|
489
|
+
`-filters` only reports whether this ffmpeg build was compiled with the filter; it never
|
|
490
|
+
proves drawtext can actually execute. On some real Windows ffmpeg builds (winget's gyan.dev
|
|
491
|
+
9.x), drawtext crashes with an access violation whenever it has to resolve a font through
|
|
492
|
+
fontconfig -- with or without a valid fonts.conf -- so `-filters` correctly reports drawtext
|
|
493
|
+
present and doctor used to report the capability `available` anyway; every tool that actually
|
|
494
|
+
used it (look, scenes --sheet, overlay --text, graphics) then crashed on first real use (#100).
|
|
495
|
+
|
|
496
|
+
This runs the cheapest real drawtext render there is: a one-frame synthetic clip, no font=
|
|
497
|
+
given at all (ffmpeg's own default resolution -- the same path that crashed). A clean exit
|
|
498
|
+
means drawtext genuinely works here. Anything that could not prove either way (no ffmpeg,
|
|
499
|
+
timeout, an ordinary nonzero exit with a real ffmpeg error) is `unknown`, same "unknown is not
|
|
500
|
+
missing" principle as every other capability here. A crash specifically -- killed by signal on
|
|
501
|
+
POSIX, or an unhandled access violation surfacing as a huge unsigned exit code on Windows -- is
|
|
502
|
+
the one case this function exists to catch, and folds into `missing`: the filter is present in
|
|
503
|
+
the build but cannot actually be used as ffmpeg's own default would use it.
|
|
504
|
+
"""
|
|
505
|
+
exe = shutil.which("ffmpeg")
|
|
506
|
+
if not exe:
|
|
507
|
+
return {"status": "unknown", "detail": "ffmpeg not on PATH"}
|
|
508
|
+
try:
|
|
509
|
+
proc = subprocess.run(
|
|
510
|
+
[exe, "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:d=1",
|
|
511
|
+
"-vf", "drawtext=text=x", "-frames:v", "1", "-f", "null", "-"],
|
|
512
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT,
|
|
513
|
+
)
|
|
514
|
+
except subprocess.TimeoutExpired:
|
|
515
|
+
return {"status": "unknown", "detail": f"drawtext probe did not exit within {_DETECT_TIMEOUT}s"}
|
|
516
|
+
except OSError as e:
|
|
517
|
+
return {"status": "unknown", "detail": f"drawtext probe: {e}"}
|
|
518
|
+
if proc.returncode == 0:
|
|
519
|
+
return {"status": "available", "detail": "one-frame drawtext render succeeded"}
|
|
520
|
+
if proc.returncode < 0 or proc.returncode >= 0x80000000:
|
|
521
|
+
return {"status": "missing",
|
|
522
|
+
"detail": f"drawtext render crashed (exit {proc.returncode}) instead of failing cleanly -- "
|
|
523
|
+
"the filter is present in this build but cannot be used as-is, likely a fontconfig "
|
|
524
|
+
"resolution crash (see https://github.com/kajisho5/ffmpeg-skill/issues/100); "
|
|
525
|
+
"pass an explicit --font-file to every drawtext tool as a workaround"}
|
|
526
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
527
|
+
return {"status": "unknown", "detail": f"drawtext probe exited {proc.returncode}: {tail}"}
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _font_available(font_name: str) -> Dict[str, Any]:
|
|
531
|
+
"""Whether `font_name` (a fontconfig family name, as passed to drawtext's `font=`) is actually
|
|
532
|
+
installed, distinct from silently resolving to a substitute.
|
|
533
|
+
|
|
534
|
+
This cannot be answered by running drawtext and checking its exit code: fontconfig substitutes
|
|
535
|
+
the closest match for ANY name, known or not, so `ffmpeg -vf drawtext=font='<garbage>'` still
|
|
536
|
+
exits 0 (verified against this repo's own sandbox ffmpeg -- a deliberately bogus family name
|
|
537
|
+
produces the same success exit code as "DejaVu Sans"). That is exactly the "false success" this
|
|
538
|
+
capability exists to catch (see issue #66): a missing font never fails the encode, it just
|
|
539
|
+
silently renders with a different typeface. `fc-match` is queried instead, since it reports the
|
|
540
|
+
family fontconfig actually resolved to; that only equals the request when the font is installed.
|
|
541
|
+
"""
|
|
542
|
+
exe = shutil.which("fc-match")
|
|
543
|
+
if not exe:
|
|
544
|
+
return {"status": "unknown", "detail": "fc-match not on PATH; drawtext succeeding proves nothing (fontconfig substitutes silently), so availability cannot be verified"}
|
|
545
|
+
try:
|
|
546
|
+
proc = subprocess.run([exe, "--format=%{family}\n", font_name], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=_DETECT_TIMEOUT)
|
|
547
|
+
except subprocess.TimeoutExpired:
|
|
548
|
+
return {"status": "unknown", "detail": f"fc-match did not exit within {_DETECT_TIMEOUT}s"}
|
|
549
|
+
except OSError as e:
|
|
550
|
+
return {"status": "unknown", "detail": f"fc-match: {e}"}
|
|
551
|
+
if proc.returncode != 0:
|
|
552
|
+
tail = " ".join(proc.stderr.strip().splitlines()[-2:])
|
|
553
|
+
return {"status": "unknown", "detail": f"fc-match exited {proc.returncode}: {tail}"}
|
|
554
|
+
lines = [l for l in proc.stdout.splitlines() if l.strip()]
|
|
555
|
+
resolved = lines[0].strip() if lines else ""
|
|
556
|
+
if not resolved:
|
|
557
|
+
return {"status": "unknown", "detail": "fc-match produced no output"}
|
|
558
|
+
if resolved.lower() == font_name.lower():
|
|
559
|
+
return {"status": "available", "detail": f"fc-match resolves '{font_name}' to itself"}
|
|
560
|
+
return {"status": "missing", "detail": f"fc-match substitutes '{resolved}' for '{font_name}' -- '{font_name}' is not installed"}
|
|
561
|
+
|
|
562
|
+
|
|
418
563
|
def required_capabilities() -> Dict[str, List[str]]:
|
|
419
564
|
req: set = set()
|
|
420
565
|
opt: set = set()
|
|
@@ -428,10 +573,30 @@ def required_capabilities() -> Dict[str, List[str]]:
|
|
|
428
573
|
def doctor() -> Dict[str, Any]:
|
|
429
574
|
"""Detect which declared capabilities this machine has. No secrets, no environment variables.
|
|
430
575
|
|
|
576
|
+
`version` is this INSTALLED COPY's own version (read from its local package.json, same value
|
|
577
|
+
`contract --json`'s `skill.version` reports) -- never fetched from the network or compared
|
|
578
|
+
against the latest published release. A copy installed with `npx ffmpeg-skill` is not updated
|
|
579
|
+
automatically; re-run the installer to refresh it, then `doctor` again to confirm the version
|
|
580
|
+
changed. This exists so a stale installed copy is visible locally, not to check for updates.
|
|
581
|
+
|
|
431
582
|
Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
|
|
432
583
|
that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
|
|
433
584
|
`missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
|
|
434
585
|
is not a pass). `ok` is true only when nothing required is missing or unknown.
|
|
586
|
+
|
|
587
|
+
`gpu_encoders` is a separate, honest answer to a question none of the required/optional
|
|
588
|
+
capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
|
|
589
|
+
ffmpeg BUILD carries, from `-encoders` alone. No tool here requires or uses one -- every tool
|
|
590
|
+
still assumes CPU x264/x265 -- so `gpu_encoders` never affects `ok` or any tool's `usable`. It
|
|
591
|
+
only proves the build shipped the capability, never that the GPU/driver on this machine will
|
|
592
|
+
actually accept a job (that needs a real encode, which this introspection never runs).
|
|
593
|
+
|
|
594
|
+
`fonts` is the same kind of informational answer for the default drawtext font (caption.py's
|
|
595
|
+
--animate/--karaoke, graphics.py's templates -- see issue #66): available/missing/unknown for
|
|
596
|
+
whether BRAND_DEFAULTS["font"] ("DejaVu Sans") is actually installed, not silently substituted
|
|
597
|
+
by fontconfig. It never affects `ok` or a tool's `usable` -- a missing font is not a broken
|
|
598
|
+
tool, drawtext still runs and still writes an artifact, it may just render with a different
|
|
599
|
+
typeface than requested (which is why this exists: that substitution is otherwise invisible).
|
|
435
600
|
"""
|
|
436
601
|
listings = {
|
|
437
602
|
"encoders": _ff_listing("ffmpeg", "-encoders"),
|
|
@@ -441,6 +606,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
441
606
|
sets = {k: set(v["names"]) for k, v in listings.items()}
|
|
442
607
|
state: Dict[str, str] = {} # capability -> available | missing | unknown
|
|
443
608
|
wanted = required_capabilities()
|
|
609
|
+
drawtext_probe: Optional[Dict[str, Any]] = None
|
|
444
610
|
|
|
445
611
|
def _from(kind: str, name: str) -> str:
|
|
446
612
|
lst = listings[kind]
|
|
@@ -457,6 +623,23 @@ def doctor() -> Dict[str, Any]:
|
|
|
457
623
|
state[cap] = "available" if shutil.which("ffprobe") else "missing"
|
|
458
624
|
elif cap.startswith("encoder:"):
|
|
459
625
|
state[cap] = _from("encoders", cap[8:])
|
|
626
|
+
elif cap == "filter:drawtext":
|
|
627
|
+
listing_state = _from("filters", "drawtext")
|
|
628
|
+
if listing_state == "available":
|
|
629
|
+
# Only escalate an "available" listing to "missing" on an unambiguous crash --
|
|
630
|
+
# an ordinary nonzero exit (a real ffmpeg's own -h/-filters-only build variance, or
|
|
631
|
+
# in tests a fake ffmpeg shim that only implements -filters/-encoders/-bsfs/-version)
|
|
632
|
+
# proves nothing either way, so it leaves the listing-based result standing rather
|
|
633
|
+
# than downgrading it; see _drawtext_probe()'s own docstring for why a crash alone
|
|
634
|
+
# is the one case this exists to catch.
|
|
635
|
+
probe = _drawtext_probe()
|
|
636
|
+
if probe["status"] == "missing":
|
|
637
|
+
drawtext_probe = probe
|
|
638
|
+
state[cap] = "missing"
|
|
639
|
+
else:
|
|
640
|
+
state[cap] = "available"
|
|
641
|
+
else:
|
|
642
|
+
state[cap] = listing_state
|
|
460
643
|
elif cap.startswith("filter:"):
|
|
461
644
|
state[cap] = _from("filters", cap[7:])
|
|
462
645
|
elif cap.startswith("bsf:"):
|
|
@@ -471,7 +654,10 @@ def doctor() -> Dict[str, Any]:
|
|
|
471
654
|
unknown = sorted(c for c, st in state.items() if st == "unknown")
|
|
472
655
|
unknown_required = [c for c in unknown if c in wanted["required"]]
|
|
473
656
|
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
657
|
+
if drawtext_probe is not None and drawtext_probe["status"] != "available":
|
|
658
|
+
errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
|
|
474
659
|
return {
|
|
660
|
+
"version": skill_version(),
|
|
475
661
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
476
662
|
"ffmpeg": _version_line("ffmpeg"),
|
|
477
663
|
"ffprobe": _version_line("ffprobe"),
|
|
@@ -483,18 +669,38 @@ def doctor() -> Dict[str, Any]:
|
|
|
483
669
|
"errors": errors,
|
|
484
670
|
"ok": not missing_required and not unknown_required,
|
|
485
671
|
"tools": _tool_usability(state),
|
|
672
|
+
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
673
|
+
"fonts": _fonts_capability(),
|
|
486
674
|
}
|
|
487
675
|
|
|
488
676
|
|
|
677
|
+
def _fonts_capability() -> Dict[str, Any]:
|
|
678
|
+
font = _default_font()
|
|
679
|
+
result = _font_available(font)
|
|
680
|
+
return {"default_font": font, "status": result["status"], "detail": result["detail"]}
|
|
681
|
+
|
|
682
|
+
|
|
489
683
|
def _capability_fix_hint(cap: str) -> str:
|
|
490
684
|
"""One-line, plain-language remedy for a single missing/unknown capability."""
|
|
491
685
|
if cap in ("ffmpeg", "ffprobe"):
|
|
492
686
|
from _common import INSTALL_HINTS
|
|
493
687
|
hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
|
|
494
688
|
return f"install ffmpeg: {hint}"
|
|
495
|
-
|
|
689
|
+
system = platform.system()
|
|
690
|
+
if system == "Darwin":
|
|
691
|
+
full_hint = "on macOS, brew install ffmpeg-full (the plain formula lacks subtitles/drawtext/zscale)"
|
|
692
|
+
elif system == "Windows":
|
|
693
|
+
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)"
|
|
694
|
+
else:
|
|
695
|
+
full_hint = "install/build ffmpeg with it enabled"
|
|
496
696
|
if cap.startswith("encoder:"):
|
|
497
697
|
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
698
|
+
if cap == "filter:drawtext":
|
|
699
|
+
return ("drawtext crashed instead of rendering a frame (see errors[] for the exit detail) -- "
|
|
700
|
+
"every drawtext tool already resolves a concrete font file automatically when one can "
|
|
701
|
+
"be found (#100); if it still crashes, use --no-timecode with look.py or scenes.py "
|
|
702
|
+
"--sheet to skip drawtext entirely, or pass --font-file explicitly to overlay.py/"
|
|
703
|
+
"graphics.py (the two that accept it) rather than relying on font= resolution")
|
|
498
704
|
if cap.startswith("filter:"):
|
|
499
705
|
return f"this ffmpeg build has no {cap[7:]} filter; {full_hint}"
|
|
500
706
|
if cap.startswith("bsf:"):
|
|
@@ -536,6 +742,52 @@ def capability_provides() -> List[Dict[str, str]]:
|
|
|
536
742
|
return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
|
|
537
743
|
|
|
538
744
|
|
|
745
|
+
# Abstract, domain-shaped capability ids (`<domain>.<verb>`, matching the convention `provides`
|
|
746
|
+
# already documents for cross-repo Capability ids) that a planning agent can resolve without
|
|
747
|
+
# already knowing this skill's tool names. Unlike `provides` (one entry per tool, id derived
|
|
748
|
+
# from the tool name), this is a hand-authored, many-to-one table: several of these ids name a
|
|
749
|
+
# tool PLUS the fixed parameters that pin it to that specific behaviour (`video.reframe` is
|
|
750
|
+
# `fit.py` with `fit=crop` fixed, not bare `fit.py`, which also speed-ramps and pads). This is
|
|
751
|
+
# still purely descriptive -- a caller still builds and runs the named tool's own CLI/MCP call
|
|
752
|
+
# from its `input_schema`; nothing here executes or chooses on the caller's behalf. Deliberately
|
|
753
|
+
# excludes any capability that would require judgment to resolve (e.g. no `video.highlight`:
|
|
754
|
+
# `scenes.py --highlights` ranks by a measured proxy, never by understood content -- see
|
|
755
|
+
# SKILL.md "What this skill does and does not decide" -- so it is not offered as a capability
|
|
756
|
+
# a planner can blindly delegate to). `media.proxy` (a low-bitrate, fast-decode proxy, distinct
|
|
757
|
+
# from `export.py`'s delivery presets, which target visual quality over size/speed) resolves to
|
|
758
|
+
# `proxy.py` -- itself a purely mechanical resize+re-encode with no opinion on which asset should
|
|
759
|
+
# be proxied or what for.
|
|
760
|
+
CAPABILITY_MAP: List[Dict[str, Any]] = [
|
|
761
|
+
{"capability": "video.trim", "tool_id": f"{SKILL_ID}/cut", "params": {}},
|
|
762
|
+
{"capability": "video.reframe", "tool_id": f"{SKILL_ID}/fit", "params": {"fit": "crop"}},
|
|
763
|
+
{"capability": "audio.loudness", "tool_id": f"{SKILL_ID}/loudness", "params": {}},
|
|
764
|
+
{"capability": "subtitle.burn", "tool_id": f"{SKILL_ID}/caption", "params": {}},
|
|
765
|
+
{"capability": "media.stream.inspect", "tool_id": f"{SKILL_ID}/probe", "params": {}},
|
|
766
|
+
{"capability": "media.frames.extract", "tool_id": f"{SKILL_ID}/look", "params": {}},
|
|
767
|
+
{"capability": "media.proxy", "tool_id": f"{SKILL_ID}/proxy", "params": {}},
|
|
768
|
+
]
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def capability_map(tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
772
|
+
"""Validate CAPABILITY_MAP against the live tool specs before returning it. A fixed param
|
|
773
|
+
that isn't a real input_schema property is drift, not a typo to ship silently (same "fail
|
|
774
|
+
loudly" posture as tool_spec()'s missing-TOOL_META check). A tool_id that no longer exists
|
|
775
|
+
is not drift in that sense -- a tool can legitimately be removed -- so that capability is
|
|
776
|
+
dropped from the map rather than crashing the whole contract build."""
|
|
777
|
+
by_id = {t["id"]: t for t in tools}
|
|
778
|
+
result = []
|
|
779
|
+
for entry in CAPABILITY_MAP:
|
|
780
|
+
spec = by_id.get(entry["tool_id"])
|
|
781
|
+
if spec is None:
|
|
782
|
+
continue
|
|
783
|
+
props = spec["input_schema"]["properties"]
|
|
784
|
+
for key in entry["params"]:
|
|
785
|
+
if key not in props:
|
|
786
|
+
raise RuntimeError(f"capability {entry['capability']!r} sets param {key!r} which is not in {entry['tool_id']}'s input_schema")
|
|
787
|
+
result.append(dict(entry))
|
|
788
|
+
return result
|
|
789
|
+
|
|
790
|
+
|
|
539
791
|
# ----------------------------------------------------------------------------- contract
|
|
540
792
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
541
793
|
if name not in TOOL_META:
|
|
@@ -717,12 +969,14 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
717
969
|
},
|
|
718
970
|
"json_output": {
|
|
719
971
|
"success": {"status": "completed", "exit_code": 0, "stdout": "one JSON document (output_schema)"},
|
|
720
|
-
"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"},
|
|
721
|
-
"error_kinds": {"input": "missing or unsuitable input, bad arguments", "ffmpeg": "ffmpeg/ffprobe returned an error", "missing_tool": "ffmpeg or ffprobe not on PATH"},
|
|
972
|
+
"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"},
|
|
973
|
+
"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"},
|
|
974
|
+
"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",
|
|
722
975
|
},
|
|
723
976
|
"capabilities": caps,
|
|
724
977
|
"tools": tools,
|
|
725
978
|
"provides": capability_provides(),
|
|
979
|
+
"capability_map": capability_map(tools),
|
|
726
980
|
}
|
|
727
981
|
|
|
728
982
|
|
|
@@ -737,12 +991,21 @@ def main() -> int:
|
|
|
737
991
|
if args.json:
|
|
738
992
|
print(json.dumps(d, indent=2, sort_keys=True))
|
|
739
993
|
else:
|
|
994
|
+
print(f"ffmpeg-skill {d['version']} (this installed copy; re-run `npx ffmpeg-skill` to refresh it -- copies are not updated automatically)")
|
|
740
995
|
print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
|
|
741
996
|
print(f"available: {', '.join(d['available'])}")
|
|
742
997
|
print(f"missing required: {', '.join(d['missing']) or 'none'}")
|
|
743
998
|
print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
|
|
744
999
|
if d["unknown"]:
|
|
745
1000
|
print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
|
|
1001
|
+
not_usable = sorted(name for name, t in d["tools"].items() if t["usable"] != "yes")
|
|
1002
|
+
if not_usable and d["ok"]:
|
|
1003
|
+
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)")
|
|
1004
|
+
gpu = d["gpu_encoders"]
|
|
1005
|
+
if gpu["status"] == "parsed":
|
|
1006
|
+
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)")
|
|
1007
|
+
fonts = d["fonts"]
|
|
1008
|
+
print(f"default drawtext font '{fonts['default_font']}': {fonts['status']} ({fonts['detail']})")
|
|
746
1009
|
for err in d["errors"]:
|
|
747
1010
|
print(f"detection error: {err}", file=sys.stderr)
|
|
748
1011
|
if d["ok"]:
|
package/scripts/audio.py
CHANGED
|
@@ -209,7 +209,7 @@ def main() -> int:
|
|
|
209
209
|
cmd += ["-vn"] # audio extension: the picture is dropped, not copied into a container that cannot hold it
|
|
210
210
|
cmd += audio_codec_for(output, args.bitrate) + ["-shortest", output]
|
|
211
211
|
run(cmd)
|
|
212
|
-
r = probe(output)
|
|
212
|
+
r = probe(output, role="output")
|
|
213
213
|
a = r["audio"]
|
|
214
214
|
if r.get("video") and audio_out and not STATE["dry_run"]:
|
|
215
215
|
die(f"{output} unexpectedly contains a video stream")
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Generate a solid-colour or two-colour gradient background clip.
|
|
3
|
+
|
|
4
|
+
No input file: a silent, fixed-duration, exact-size clip generated entirely
|
|
5
|
+
by ffmpeg's own source filters (`color` for solid, `gradients` for a
|
|
6
|
+
two-colour gradient) -- for a title card background, a placeholder behind a
|
|
7
|
+
logo, or a base layer for overlay.py to composite onto.
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
python3 background.py --duration 3 --width 1920 --height 1080 --color 0x101010
|
|
11
|
+
python3 background.py --duration 5 --width 1080 --height 1920 --gradient 0xff6a00:0x0057ff --angle 45
|
|
12
|
+
"""
|
|
13
|
+
import argparse
|
|
14
|
+
import math
|
|
15
|
+
import sys
|
|
16
|
+
|
|
17
|
+
from _common import add_common, apply_common, die, emit, ffmpeg_base, info, parse_time, probe, run, video_args
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main() -> int:
|
|
21
|
+
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
22
|
+
ap.add_argument("-o", "--output", required=True, help="output file")
|
|
23
|
+
ap.add_argument("--duration", required=True, help="clip duration (seconds or mm:ss)")
|
|
24
|
+
ap.add_argument("--width", type=int, required=True, help="output width in px (must be even)")
|
|
25
|
+
ap.add_argument("--height", type=int, required=True, help="output height in px (must be even)")
|
|
26
|
+
ap.add_argument("--fps", type=float, default=30.0, help="output frame rate (default 30)")
|
|
27
|
+
src = ap.add_mutually_exclusive_group()
|
|
28
|
+
src.add_argument("--color", default="black", help="solid background colour, e.g. black, 0x101010 (default black)")
|
|
29
|
+
src.add_argument("--gradient", help="two colours as C1:C2 for a linear gradient, e.g. 0xff6a00:0x0057ff")
|
|
30
|
+
ap.add_argument("--angle", type=float, default=0.0, help="gradient angle in degrees (with --gradient, default 0 = left to right)")
|
|
31
|
+
ap.add_argument("--crf", type=int, default=18, help="x264 CRF (default 18)")
|
|
32
|
+
ap.add_argument("--preset", default="medium", help="x264 preset")
|
|
33
|
+
add_common(ap)
|
|
34
|
+
args = ap.parse_args()
|
|
35
|
+
apply_common(args)
|
|
36
|
+
|
|
37
|
+
target = parse_time(args.duration)
|
|
38
|
+
if target <= 0:
|
|
39
|
+
die("--duration must be > 0")
|
|
40
|
+
if args.fps <= 0:
|
|
41
|
+
die("--fps must be > 0")
|
|
42
|
+
if args.width <= 0 or args.height <= 0:
|
|
43
|
+
die(f"--width/--height must be > 0, got width={args.width} height={args.height}")
|
|
44
|
+
if args.width % 2 or args.height % 2:
|
|
45
|
+
die(f"--width/--height must be even (4:2:0 chroma), got width={args.width} height={args.height}")
|
|
46
|
+
|
|
47
|
+
if args.gradient:
|
|
48
|
+
try:
|
|
49
|
+
c0, c1 = args.gradient.split(":")
|
|
50
|
+
except ValueError:
|
|
51
|
+
die(f"--gradient needs two colours as C1:C2, got '{args.gradient}'")
|
|
52
|
+
rad = math.radians(args.angle)
|
|
53
|
+
x1 = round(args.width * math.cos(rad))
|
|
54
|
+
y1 = round(args.width * math.sin(rad))
|
|
55
|
+
src_filter = f"gradients=size={args.width}x{args.height}:rate={args.fps:g}:c0={c0}:c1={c1}:x0=0:y0=0:x1={x1}:y1={y1}"
|
|
56
|
+
else:
|
|
57
|
+
src_filter = f"color=c={args.color}:size={args.width}x{args.height}:rate={args.fps:g}"
|
|
58
|
+
|
|
59
|
+
output = args.output
|
|
60
|
+
cmd = ffmpeg_base() + ["-f", "lavfi", "-i", src_filter, "-t", f"{target:.3f}"]
|
|
61
|
+
cmd += video_args(None, args.crf, args.preset)
|
|
62
|
+
cmd += ["-an", output]
|
|
63
|
+
run(cmd)
|
|
64
|
+
|
|
65
|
+
result = probe(output, role="output")
|
|
66
|
+
v = result["video"]
|
|
67
|
+
info(f"wrote {output} ({result['duration']:.3f}s, {v['width']}x{v['height']}, {v['fps']:g}fps)")
|
|
68
|
+
emit(output)
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
if __name__ == "__main__":
|
|
73
|
+
sys.exit(main())
|