ffmpeg-skill 0.10.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 +33 -9
- package/SKILL.md +77 -12
- package/bin/install.js +15 -1
- 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 +122 -11
- package/scripts/_common.py +200 -12
- package/scripts/_contract.py +204 -12
- package/scripts/audio.py +1 -1
- package/scripts/background.py +73 -0
- package/scripts/caption.py +97 -24
- package/scripts/color.py +36 -9
- package/scripts/crop.py +79 -0
- package/scripts/cut.py +2 -2
- package/scripts/export.py +1 -1
- package/scripts/fit.py +60 -10
- package/scripts/graphics.py +12 -3
- package/scripts/insert.py +128 -0
- package/scripts/join.py +14 -5
- package/scripts/loudness.py +3 -3
- package/scripts/multicam.py +1 -1
- package/scripts/overlay.py +59 -5
- package/scripts/proxy.py +82 -0
- package/scripts/reverse.py +56 -0
- package/scripts/sequence.py +124 -0
- package/scripts/silence.py +2 -2
- package/scripts/stabilize.py +83 -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],
|
|
@@ -104,6 +129,9 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
104
129
|
{"capability": "filter:exposure", "when": "--correct"}, {"capability": "filter:eq", "when": "--correct"},
|
|
105
130
|
{"capability": "filter:colorbalance", "when": "--correct"}, {"capability": "filter:colortemperature", "when": "--correct"}],
|
|
106
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],
|
|
134
|
+
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
107
135
|
"export": dict(role="execution", inputs=["video asset"], outputs=["delivery artifact in the preset's format"],
|
|
108
136
|
required=FF, optional=[{"capability": X264, "when": "preset youtube / youtube4k / reels / x"}, {"capability": AAC, "when": "any preset except gif"},
|
|
109
137
|
{"capability": X265, "when": "preset h265"}, {"capability": "encoder:prores_ks", "when": "preset prores"},
|
|
@@ -159,16 +187,23 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
159
187
|
"probe": dict(video="never", audio="never", note="analysis only, no artifact"),
|
|
160
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)"),
|
|
161
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"),
|
|
162
|
-
"
|
|
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"),
|
|
163
197
|
"overlay": dict(video="always", audio="always"),
|
|
164
198
|
"graphics": dict(video="always", audio="always"),
|
|
165
|
-
"sync": dict(video="
|
|
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"),
|
|
166
200
|
"multicam": dict(video="always", audio="always"),
|
|
167
201
|
"audio": dict(video="never", audio="always", note="video stream is always -c:v copy when present; this tool's job is the audio"),
|
|
168
202
|
"loudness": dict(video="never", audio="always"),
|
|
169
203
|
"silence": dict(video="always", audio="always", note="removing gaps requires cutting on non-keyframe boundaries"),
|
|
170
204
|
"join": dict(video="always", audio="always"),
|
|
171
|
-
"color": dict(video="
|
|
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"),
|
|
172
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"),
|
|
173
208
|
"check": dict(video="never", audio="never", note="read-only, no artifact"),
|
|
174
209
|
"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 +417,9 @@ def _ff_listing(binary: str, flag: str) -> Dict[str, Any]:
|
|
|
382
417
|
if not exe:
|
|
383
418
|
return {"names": [], "status": "missing", "detail": f"{binary} not on PATH"}
|
|
384
419
|
try:
|
|
385
|
-
proc = subprocess.run([exe, "-hide_banner", flag], stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
|
|
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"}
|
|
386
423
|
except OSError as e:
|
|
387
424
|
return {"names": [], "status": "failed", "detail": f"{binary} {flag}: {e}"}
|
|
388
425
|
if proc.returncode != 0:
|
|
@@ -399,11 +436,36 @@ def _ff_list(binary: str, flag: str) -> List[str]:
|
|
|
399
436
|
return _ff_listing(binary, flag)["names"]
|
|
400
437
|
|
|
401
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
|
+
|
|
402
461
|
def _version_line(binary: str) -> Optional[str]:
|
|
403
462
|
exe = shutil.which(binary)
|
|
404
463
|
if not exe:
|
|
405
464
|
return None
|
|
406
|
-
|
|
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
|
|
407
469
|
first = (proc.stdout or proc.stderr).splitlines()[:1]
|
|
408
470
|
m = re.match(rf"{binary} version (\S+)", first[0]) if first else None
|
|
409
471
|
return m.group(1) if m else (first[0] if first else "unknown")
|
|
@@ -415,6 +477,44 @@ def _whisper_available() -> bool:
|
|
|
415
477
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
416
478
|
|
|
417
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
|
+
|
|
418
518
|
def required_capabilities() -> Dict[str, List[str]]:
|
|
419
519
|
req: set = set()
|
|
420
520
|
opt: set = set()
|
|
@@ -428,10 +528,30 @@ def required_capabilities() -> Dict[str, List[str]]:
|
|
|
428
528
|
def doctor() -> Dict[str, Any]:
|
|
429
529
|
"""Detect which declared capabilities this machine has. No secrets, no environment variables.
|
|
430
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
|
+
|
|
431
537
|
Three states per capability: available, missing, unknown. `unknown` means the ffmpeg listing
|
|
432
538
|
that would prove it could not be read (unparsed output, ffmpeg failure); it is never folded into
|
|
433
539
|
`missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
|
|
434
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).
|
|
435
555
|
"""
|
|
436
556
|
listings = {
|
|
437
557
|
"encoders": _ff_listing("ffmpeg", "-encoders"),
|
|
@@ -472,6 +592,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
472
592
|
unknown_required = [c for c in unknown if c in wanted["required"]]
|
|
473
593
|
errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
|
|
474
594
|
return {
|
|
595
|
+
"version": skill_version(),
|
|
475
596
|
"python": ".".join(str(x) for x in sys.version_info[:3]),
|
|
476
597
|
"ffmpeg": _version_line("ffmpeg"),
|
|
477
598
|
"ffprobe": _version_line("ffprobe"),
|
|
@@ -483,16 +604,30 @@ def doctor() -> Dict[str, Any]:
|
|
|
483
604
|
"errors": errors,
|
|
484
605
|
"ok": not missing_required and not unknown_required,
|
|
485
606
|
"tools": _tool_usability(state),
|
|
607
|
+
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
608
|
+
"fonts": _fonts_capability(),
|
|
486
609
|
}
|
|
487
610
|
|
|
488
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
|
+
|
|
489
618
|
def _capability_fix_hint(cap: str) -> str:
|
|
490
619
|
"""One-line, plain-language remedy for a single missing/unknown capability."""
|
|
491
620
|
if cap in ("ffmpeg", "ffprobe"):
|
|
492
621
|
from _common import INSTALL_HINTS
|
|
493
622
|
hint = INSTALL_HINTS.get(platform.system(), "see https://ffmpeg.org/download.html").strip().splitlines()[0].strip()
|
|
494
623
|
return f"install ffmpeg: {hint}"
|
|
495
|
-
|
|
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"
|
|
496
631
|
if cap.startswith("encoder:"):
|
|
497
632
|
return f"this ffmpeg build has no {cap[8:]} encoder; {full_hint}"
|
|
498
633
|
if cap.startswith("filter:"):
|
|
@@ -536,6 +671,52 @@ def capability_provides() -> List[Dict[str, str]]:
|
|
|
536
671
|
return [{"id": f"{SKILL_ID}.{name}", "lifecycle": "EXPERIMENTAL", "tool_id": f"{SKILL_ID}/{name}"} for name in public_tools()]
|
|
537
672
|
|
|
538
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
|
+
|
|
539
720
|
# ----------------------------------------------------------------------------- contract
|
|
540
721
|
def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
541
722
|
if name not in TOOL_META:
|
|
@@ -717,12 +898,14 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
717
898
|
},
|
|
718
899
|
"json_output": {
|
|
719
900
|
"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"},
|
|
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",
|
|
722
904
|
},
|
|
723
905
|
"capabilities": caps,
|
|
724
906
|
"tools": tools,
|
|
725
907
|
"provides": capability_provides(),
|
|
908
|
+
"capability_map": capability_map(tools),
|
|
726
909
|
}
|
|
727
910
|
|
|
728
911
|
|
|
@@ -737,12 +920,21 @@ def main() -> int:
|
|
|
737
920
|
if args.json:
|
|
738
921
|
print(json.dumps(d, indent=2, sort_keys=True))
|
|
739
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)")
|
|
740
924
|
print(f"python {d['python']}; ffmpeg {d['ffmpeg'] or 'MISSING'}; ffprobe {d['ffprobe'] or 'MISSING'}")
|
|
741
925
|
print(f"available: {', '.join(d['available'])}")
|
|
742
926
|
print(f"missing required: {', '.join(d['missing']) or 'none'}")
|
|
743
927
|
print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
|
|
744
928
|
if d["unknown"]:
|
|
745
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']})")
|
|
746
938
|
for err in d["errors"]:
|
|
747
939
|
print(f"detection error: {err}", file=sys.stderr)
|
|
748
940
|
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())
|