ffmpeg-skill 2.5.1 → 2.7.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 +4 -4
- package/SKILL.md +5 -5
- package/docs/contract.md +68 -13
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +44 -10
- package/scripts/_common/asr.py +747 -94
- package/scripts/_common/decision.py +125 -2
- package/scripts/_common/emit.py +145 -1
- package/scripts/_common/runner.py +258 -13
- package/scripts/_contract.py +73 -12
- package/scripts/batch.py +30 -1
- package/scripts/caption.py +22 -6
- package/scripts/export.py +10 -1
- package/scripts/render.py +23 -1
- package/scripts/silence.py +21 -6
- package/scripts/waveform.py +19 -8
|
@@ -155,7 +155,8 @@ class Context:
|
|
|
155
155
|
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
156
156
|
"""
|
|
157
157
|
|
|
158
|
-
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec"
|
|
158
|
+
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
159
|
+
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages")
|
|
159
160
|
|
|
160
161
|
def __init__(self) -> None:
|
|
161
162
|
self.reset()
|
|
@@ -176,6 +177,13 @@ class Context:
|
|
|
176
177
|
self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
|
|
177
178
|
self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
|
|
178
179
|
self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
|
|
180
|
+
self.hw = False # --hw / $FFMPEG_SKILL_HW: VideoToolbox encoders where they apply
|
|
181
|
+
self.hw_source: Optional[str] = None # "flag" (--hw/--no-hw given) or "env" (FFMPEG_SKILL_HW), None = off by default
|
|
182
|
+
self.hw_notes: List[str] = [] # why an encode stayed on (or fell back to) the CPU, for the result document
|
|
183
|
+
self.hw_swaps: List[Tuple[List[str], List[str]]] = [] # (VideoToolbox args, the CPU args they replaced) for run()'s fallback
|
|
184
|
+
self.hw_fallback = False # run() re-encoded a job VideoToolbox refused on the CPU (or a stage reported one)
|
|
185
|
+
self.hw_env_ignored = False # $FFMPEG_SKILL_HW=1 was set but this tool's delivery preset does not take it (export.py)
|
|
186
|
+
self.hw_stages: List[Dict[str, Any]] = [] # render.py: the `hw` facts each stage (a child process) reported
|
|
179
187
|
|
|
180
188
|
|
|
181
189
|
STATE = Context()
|
|
@@ -206,6 +214,31 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
206
214
|
help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
|
|
207
215
|
g.add_argument("--quality", type=int, default=ap._defaults["crf"], metavar="N",
|
|
208
216
|
help="encoder quality on the CRF scale (default %(default)s; lower = better; 18 visually lossless for x264/x265, up to 63 for av1); ignored by prores")
|
|
217
|
+
if (codec and "crf" in ap._defaults) or not codec:
|
|
218
|
+
_add_hw_flags(g, "encode on the GPU with Apple VideoToolbox (h264/hevc/prores; Apple Silicon only): several times faster, "
|
|
219
|
+
"larger files at the same quality; falls back to the CPU encoder, reported, where it cannot apply"
|
|
220
|
+
+ ("" if codec else ". The only way to put a delivery preset on the GPU: $FFMPEG_SKILL_HW does not apply here"),
|
|
221
|
+
"encode on the CPU (x264/x265/SVT-AV1/prores_ks) even when $FFMPEG_SKILL_HW=1")
|
|
222
|
+
ap.set_defaults(_hw_env=bool(codec))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _add_hw_flags(g: Any, hw_help: str, no_hw_help: str) -> None:
|
|
226
|
+
"""--hw and --no-hw, each its own boolean (dests `hw` and `no_hw`). They are not one dest
|
|
227
|
+
with store_true/store_false: the contract's input_schema keeps one property per dest and the
|
|
228
|
+
MCP transport turns `true` into the flag, so a shared dest published --no-hw under the name
|
|
229
|
+
`hw` and an MCP `hw: true` ran on the CPU. hw_flag() reads the pair back as True/False/None."""
|
|
230
|
+
hw = g.add_mutually_exclusive_group()
|
|
231
|
+
hw.add_argument("--hw", action="store_true", help=hw_help)
|
|
232
|
+
hw.add_argument("--no-hw", action="store_true", help=no_hw_help)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def hw_flag(args: "argparse.Namespace") -> Optional[bool]:
|
|
236
|
+
"""--hw -> True, --no-hw -> False, neither (or a tool without the flags) -> None."""
|
|
237
|
+
if getattr(args, "hw", False):
|
|
238
|
+
return True
|
|
239
|
+
if getattr(args, "no_hw", False):
|
|
240
|
+
return False
|
|
241
|
+
return None
|
|
209
242
|
|
|
210
243
|
|
|
211
244
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -228,6 +261,25 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
228
261
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
229
262
|
args.preset = "veryfast"
|
|
230
263
|
STATE.codec = getattr(args, "codec", None) or None
|
|
264
|
+
STATE.hw_notes, STATE.hw_swaps, STATE.hw_fallback, STATE.hw_env_ignored, STATE.hw_stages = [], [], False, False, []
|
|
265
|
+
flag = hw_flag(args)
|
|
266
|
+
if flag is not None:
|
|
267
|
+
STATE.hw, STATE.hw_source = bool(flag), "flag"
|
|
268
|
+
if getattr(args, "_hw_orchestrator", False):
|
|
269
|
+
# render.py / batch.py --hw|--no-hw: every stage they run inherits it as an explicit
|
|
270
|
+
# choice -- export.py's delivery presets included, which $FFMPEG_SKILL_HW never reaches
|
|
271
|
+
os.environ[HW_ENV] = "1" if flag else "0"
|
|
272
|
+
os.environ[HW_FORCED_ENV] = "1"
|
|
273
|
+
elif hasattr(args, "hw") and os.environ.get(HW_FORCED_ENV) == "1":
|
|
274
|
+
STATE.hw, STATE.hw_source = env_hw(), "flag"
|
|
275
|
+
elif hasattr(args, "hw") and getattr(args, "_hw_env", False) and env_hw():
|
|
276
|
+
STATE.hw, STATE.hw_source = True, "env"
|
|
277
|
+
else:
|
|
278
|
+
STATE.hw, STATE.hw_source = False, None
|
|
279
|
+
# export.py (a delivery preset decides the encoder): the machine default is not applied,
|
|
280
|
+
# and the result says so rather than leaving a caller to wonder why the GPU was not used
|
|
281
|
+
STATE.hw_env_ignored = (hasattr(args, "hw") and env_hw() and not getattr(args, "_hw_env", False)
|
|
282
|
+
and not getattr(args, "_hw_orchestrator", False))
|
|
231
283
|
quality = getattr(args, "quality", None)
|
|
232
284
|
if quality is not None:
|
|
233
285
|
top = 63 if STATE.codec == "av1" else 51
|
|
@@ -354,15 +406,48 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
354
406
|
pass
|
|
355
407
|
|
|
356
408
|
|
|
357
|
-
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
409
|
+
def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Context]" = None, retried: bool = False) -> None:
|
|
358
410
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
359
411
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
360
412
|
# The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
|
|
361
413
|
# a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
|
|
362
414
|
# are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
|
|
363
415
|
# document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
|
|
416
|
+
#
|
|
417
|
+
# `cmd` is the command that ran last: after run()'s GPU->CPU retry (`retried`), the CPU one.
|
|
418
|
+
# Under --hw / $FFMPEG_SKILL_HW the message names its video encoder and the document carries
|
|
419
|
+
# `encoder` and `hw`, because two encoders may have run and a caller must know whose stderr
|
|
420
|
+
# this is.
|
|
421
|
+
ctx = ctx or STATE
|
|
364
422
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
365
|
-
|
|
423
|
+
what, extra = str(cmd[0]), {}
|
|
424
|
+
enc = video_encoder_of(cmd) if ctx.hw else None
|
|
425
|
+
if enc:
|
|
426
|
+
on_gpu = enc.endswith("_videotoolbox")
|
|
427
|
+
why = videotoolbox_failure(stderr) if on_gpu else None
|
|
428
|
+
if not on_gpu and retried:
|
|
429
|
+
what += f" (video encoder {enc}, the CPU retry after VideoToolbox refused the job)"
|
|
430
|
+
elif on_gpu and why is None:
|
|
431
|
+
what += f" (video encoder {enc}; not retried on the CPU: ffmpeg reported no VideoToolbox error)"
|
|
432
|
+
else:
|
|
433
|
+
what += f" (video encoder {enc})"
|
|
434
|
+
report = hw_report(ctx, enc)
|
|
435
|
+
if on_gpu:
|
|
436
|
+
report["notes"].append(
|
|
437
|
+
"the VideoToolbox encode failed and was not retried on the CPU: " +
|
|
438
|
+
("ffmpeg reported no encoder-open or compression-session error, so the cause is not the GPU (see the message)"
|
|
439
|
+
if why is None else f"VideoToolbox refused it ({why}) and no CPU encoder line was recorded to retry with"))
|
|
440
|
+
extra = {"encoder": enc, "hw": report}
|
|
441
|
+
die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode, **extra)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
445
|
+
"""The video encoder an ffmpeg argv names last (-c:v / -vcodec / -codec:v), or None."""
|
|
446
|
+
enc = None
|
|
447
|
+
for i, a in enumerate(cmd[:-1]):
|
|
448
|
+
if a in ("-c:v", "-vcodec", "-codec:v"):
|
|
449
|
+
enc = str(cmd[i + 1])
|
|
450
|
+
return enc
|
|
366
451
|
|
|
367
452
|
|
|
368
453
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
@@ -621,6 +706,126 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
|
|
|
621
706
|
return list(cmd[:-1]) + [tmp], output, tmp
|
|
622
707
|
|
|
623
708
|
|
|
709
|
+
HW_ENV = "FFMPEG_SKILL_HW"
|
|
710
|
+
# set only by render.py/batch.py --hw/--no-hw for the stages they run: "the outer command chose
|
|
711
|
+
# explicitly", so a child resolves $FFMPEG_SKILL_HW as a flag (export.py included), not a default
|
|
712
|
+
HW_FORCED_ENV = "_FFMPEG_SKILL_HW_EXPLICIT"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def add_hw_orchestrator_args(ap: "argparse.ArgumentParser") -> None:
|
|
716
|
+
"""--hw / --no-hw on a tool that runs other tools (render.py, batch.py) and encodes nothing itself."""
|
|
717
|
+
_add_hw_flags(ap, "run every stage's encode on Apple VideoToolbox, the final delivery export included (Apple Silicon; "
|
|
718
|
+
"falls back to the CPU, reported, where it cannot apply). Without it, $FFMPEG_SKILL_HW=1 still "
|
|
719
|
+
"puts the intermediate stages on the GPU and leaves the delivery export on the CPU",
|
|
720
|
+
"every stage on the CPU encoders, whatever $FFMPEG_SKILL_HW says")
|
|
721
|
+
ap.set_defaults(_hw_orchestrator=True)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def env_hw() -> bool:
|
|
725
|
+
"""$FFMPEG_SKILL_HW asks for VideoToolbox by default (1/true/yes/on); anything else is off."""
|
|
726
|
+
return os.environ.get(HW_ENV, "").strip().lower() in ("1", "true", "yes", "on")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def hw_platform_reason() -> Optional[str]:
|
|
730
|
+
"""None when this machine can take a VideoToolbox constant-quality encode, else why not.
|
|
731
|
+
`-q:v` on VideoToolbox is Apple-Silicon-only in FFmpeg: an Intel Mac lists the encoders but
|
|
732
|
+
rejects the quality setting, so the machine check is macOS AND Apple Silicon hardware."""
|
|
733
|
+
if platform.system() != "Darwin":
|
|
734
|
+
return "VideoToolbox is macOS-only"
|
|
735
|
+
if not apple_silicon():
|
|
736
|
+
return "VideoToolbox constant-quality encoding needs Apple Silicon"
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
_APPLE_SILICON: Optional[bool] = None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def apple_silicon() -> bool:
|
|
744
|
+
"""Whether this Mac's hardware is Apple Silicon -- not this interpreter's architecture: an
|
|
745
|
+
x86_64 Python under Rosetta reports platform.machine() "x86_64" on an M-series Mac, where
|
|
746
|
+
`sysctl -n hw.optional.arm64` still reads 1 (an Intel Mac has no such key). What decides -q:v
|
|
747
|
+
is the ffmpeg binary's own build; an x86_64 ffmpeg there refuses it, and run() re-encodes that
|
|
748
|
+
job on the CPU with ffmpeg's line in the note. Read once."""
|
|
749
|
+
global _APPLE_SILICON
|
|
750
|
+
if _APPLE_SILICON is None:
|
|
751
|
+
_APPLE_SILICON = platform.machine() == "arm64"
|
|
752
|
+
if not _APPLE_SILICON and platform.system() == "Darwin":
|
|
753
|
+
try:
|
|
754
|
+
out = subprocess.run([shutil.which("sysctl") or "/usr/sbin/sysctl", "-n", "hw.optional.arm64"],
|
|
755
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
756
|
+
errors="replace", timeout=10).stdout
|
|
757
|
+
_APPLE_SILICON = out.strip() == "1"
|
|
758
|
+
except (OSError, subprocess.SubprocessError):
|
|
759
|
+
_APPLE_SILICON = False
|
|
760
|
+
return _APPLE_SILICON
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# What FFmpeg prints when a VideoToolbox encoder cannot take a job, read from the sources of 5.1,
|
|
764
|
+
# 6.0, 6.1, 7.0, 7.1, 8.0 and master (fftools/ffmpeg*.c, libavcodec/videotoolboxenc.c):
|
|
765
|
+
# - the CLI's encoder-open failure, "Error while opening encoder", on every version. From 6.0 the
|
|
766
|
+
# line carries the output stream's log context, which names the encoder ("[vost#0:0/
|
|
767
|
+
# h264_videotoolbox @ 0x...]", from 8.0 followed by "[enc:h264_videotoolbox @ 0x...]"). 5.1
|
|
768
|
+
# prints "Error initializing output stream 0:0 -- Error while opening encoder for output stream
|
|
769
|
+
# #0:0 ...", which names no encoder, so there it counts only beside an error line the
|
|
770
|
+
# VideoToolbox encoder logged in its own context;
|
|
771
|
+
# - the encoder's own session failures, logged in its codec context ("[h264_videotoolbox @
|
|
772
|
+
# 0x...]"): "cannot create compression session", "cannot prepare encoder", "cannot encode
|
|
773
|
+
# frame", "Error encoding frame". Each of them fails the encode. Matched without case and
|
|
774
|
+
# without the "Error: " prefix, which master drops ("Cannot create compression session").
|
|
775
|
+
# Deliberately not counted, so the job is not run twice and the GPU is not blamed:
|
|
776
|
+
# - "Could not open encoder before EOF" (7.0+). It names the encoder, but it follows a filter
|
|
777
|
+
# graph that failed before the first frame -- a missing font, a bad filter argument -- so the
|
|
778
|
+
# encoder never got as far as opening;
|
|
779
|
+
# - the encoder's ERROR-level lines that do not fail the encode ("Error setting profile/level
|
|
780
|
+
# property ... Output will be encoded using a supported profile/level combination", the
|
|
781
|
+
# frames_before/frames_after/entropy/realtime/pixel-aspect properties), which a job that fails
|
|
782
|
+
# for another reason (a full disk: "Error submitting a packet to the muxer") can still carry.
|
|
783
|
+
_VT_ENCODER_RE = re.compile(r"(?:h264|hevc|prores)_videotoolbox")
|
|
784
|
+
_VT_OWN_CONTEXT_RE = re.compile(r"\[(?:h264|hevc|prores)_videotoolbox @ [^\]]*\]")
|
|
785
|
+
_VT_SESSION_RE = re.compile(r"create compression session|prepare encoder|encode frame|encoding frame", re.I)
|
|
786
|
+
# the encoder's own ERROR-level lines that do not fail the encode (listed above)
|
|
787
|
+
_VT_NONFATAL_RE = re.compile(r"profile/level|entropy|frames_before|frames_after|realtime|pixel aspect|aspect ratio", re.I)
|
|
788
|
+
_STREAM_CONTEXT_RE = re.compile(r"\[[a-z]ost#\d+:\d+")
|
|
789
|
+
_ENCODER_OPEN_FAILED = "Error while opening encoder"
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def videotoolbox_failure(stderr: str) -> Optional[str]:
|
|
793
|
+
"""The stderr line that shows a VideoToolbox encoder refused the job (could not open, or its
|
|
794
|
+
compression session failed), with the log contexts' pointers dropped; None when the failure
|
|
795
|
+
is something else. Only such a failure is retried on the CPU (see the notes above)."""
|
|
796
|
+
lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
|
|
797
|
+
own = [ln for ln in lines if _VT_OWN_CONTEXT_RE.search(ln)]
|
|
798
|
+
# the lines that can explain a refusal: not the non-fatal property errors, which a job that
|
|
799
|
+
# failed elsewhere (5.1: an audio encoder that could not open) still carries
|
|
800
|
+
fatal = [ln for ln in own if not _VT_NONFATAL_RE.search(ln)]
|
|
801
|
+
found = next((ln for ln in own if _VT_SESSION_RE.search(ln)), None)
|
|
802
|
+
if found is None:
|
|
803
|
+
for ln in lines:
|
|
804
|
+
if _ENCODER_OPEN_FAILED not in ln:
|
|
805
|
+
continue
|
|
806
|
+
if _VT_ENCODER_RE.search(ln): # 6.0+: the line names the encoder that failed to open
|
|
807
|
+
found = fatal[-1] if fatal else ln
|
|
808
|
+
break
|
|
809
|
+
if fatal and not _STREAM_CONTEXT_RE.search(ln): # 5.1: no encoder named; the encoder's own error line is
|
|
810
|
+
found = fatal[-1]
|
|
811
|
+
break
|
|
812
|
+
return re.sub(r" @ 0x[0-9a-fA-F]+", "", found)[:200] if found else None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _hw_fallback(cmd: List[str], ctx: "Context") -> Optional[List[str]]:
|
|
816
|
+
"""`cmd` with every VideoToolbox encoder line this process built swapped back to the CPU line
|
|
817
|
+
it replaced, or None when it has none -- for run() when the GPU refused an encode."""
|
|
818
|
+
out, changed = list(cmd), False
|
|
819
|
+
for vt, cpu in ctx.hw_swaps:
|
|
820
|
+
n = len(vt)
|
|
821
|
+
for i in range(len(out) - n + 1):
|
|
822
|
+
if out[i:i + n] == vt:
|
|
823
|
+
out[i:i + n] = cpu
|
|
824
|
+
changed = True
|
|
825
|
+
break
|
|
826
|
+
return out if changed else None
|
|
827
|
+
|
|
828
|
+
|
|
624
829
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Optional[Context]" = None) -> subprocess.CompletedProcess:
|
|
625
830
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
626
831
|
|
|
@@ -648,19 +853,50 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
648
853
|
flush_drawtext_textfiles(cmd)
|
|
649
854
|
with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
|
|
650
855
|
exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
|
|
651
|
-
proc
|
|
652
|
-
|
|
653
|
-
retry
|
|
856
|
+
def even_retry(proc: subprocess.CompletedProcess, ran: List[str]) -> "Tuple[subprocess.CompletedProcess, List[str]]":
|
|
857
|
+
"""An attempt that failed on odd dimensions, run again with an even scale: after the
|
|
858
|
+
first attempt, and after a GPU->CPU retry (VideoToolbox may refuse an odd-sized job
|
|
859
|
+
for another reason before x264 ever reports the size)."""
|
|
860
|
+
if proc.returncode == 0 or not is_ffmpeg:
|
|
861
|
+
return proc, ran
|
|
862
|
+
retry = _odd_dimension_retry(ran, proc.stderr or "")
|
|
654
863
|
if retry is not None:
|
|
655
864
|
info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
|
|
656
865
|
ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
|
|
657
|
-
|
|
658
|
-
|
|
866
|
+
return _execute(retry), retry
|
|
867
|
+
if "not divisible by 2" in (proc.stderr or ""):
|
|
659
868
|
die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
|
|
660
869
|
"cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
|
|
661
870
|
kind="input")
|
|
871
|
+
return proc, ran
|
|
872
|
+
|
|
873
|
+
# `tried` is the last command run: the CPU fallback swaps its encoder line in
|
|
874
|
+
proc, tried = even_retry(_execute(exec_cmd), exec_cmd)
|
|
875
|
+
retried = False
|
|
876
|
+
if proc.returncode != 0 and is_ffmpeg and ctx.hw_swaps:
|
|
877
|
+
cpu_cmd = _hw_fallback(tried, ctx)
|
|
878
|
+
if cpu_cmd is not None:
|
|
879
|
+
# Only a failure VideoToolbox itself reported is retried on the CPU: a bad filter
|
|
880
|
+
# graph, a missing font or a full disk would fail the same way twice and be
|
|
881
|
+
# reported as the GPU's fault (videotoolbox_failure() has the strings and why).
|
|
882
|
+
# A failure that is not one stays a failure of this command: with check=True,
|
|
883
|
+
# _fail() names it; with check=False the caller's own retry decides (a success
|
|
884
|
+
# after that must not carry a note about it).
|
|
885
|
+
why = videotoolbox_failure(proc.stderr or "")
|
|
886
|
+
if why is not None:
|
|
887
|
+
reason = f"VideoToolbox refused the encode: {why}"
|
|
888
|
+
info(reason + "; retrying on the CPU encoder")
|
|
889
|
+
ctx.hw_notes.append(reason + "; re-encoded on the CPU")
|
|
890
|
+
ctx.hw_fallback = True
|
|
891
|
+
ctx.commands[-1] = _cmdline(cpu_cmd[:-1] + [cmd[-1]])
|
|
892
|
+
if not quiet:
|
|
893
|
+
# echoed like the first attempt, so a caller that reads the "$ " lines
|
|
894
|
+
# (render.py's stages) records the command that really ran
|
|
895
|
+
info("$ " + ctx.commands[-1], ctx=ctx)
|
|
896
|
+
proc, tried = even_retry(_execute(cpu_cmd), cpu_cmd)
|
|
897
|
+
retried = True
|
|
662
898
|
if proc.returncode != 0 and check:
|
|
663
|
-
_fail(
|
|
899
|
+
_fail(tried, proc.returncode, proc.stderr or "", ctx=ctx, retried=retried)
|
|
664
900
|
if final and tmp:
|
|
665
901
|
if proc.returncode == 0:
|
|
666
902
|
try:
|
|
@@ -1005,13 +1241,22 @@ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
|
|
|
1005
1241
|
|
|
1006
1242
|
|
|
1007
1243
|
def ffmpeg_encoders() -> set:
|
|
1008
|
-
"""Names from `ffmpeg -encoders
|
|
1009
|
-
an AV1 encoder
|
|
1244
|
+
"""Names from `ffmpeg -encoders` -- the build that will run the encode -- read once; empty when
|
|
1245
|
+
it cannot be read. Used to pick an AV1 encoder, to refuse --codec av1 / prores before ffmpeg
|
|
1246
|
+
would, and to see whether --hw's VideoToolbox encoders are built in.
|
|
1247
|
+
|
|
1248
|
+
Under --dry-run only, the list comes from `ffprobe -encoders` instead: a dry run promises
|
|
1249
|
+
never to run ffmpeg (docs/contract.md: ffmpeg_execution "none") while ffprobe always may, as in
|
|
1250
|
+
ffmpeg_version(). That is the one case for ffprobe. On a mixed install (a Homebrew ffprobe next
|
|
1251
|
+
to a static ffmpeg) its list is another build's, so a real run never uses it: the AV1/ProRes
|
|
1252
|
+
refusals and the VideoToolbox check must describe the binary that encodes, and a plan that
|
|
1253
|
+
read ffprobe's list is re-decided against ffmpeg's when it runs."""
|
|
1010
1254
|
global _ENCODERS
|
|
1011
1255
|
if _ENCODERS is None:
|
|
1012
1256
|
_ENCODERS = set()
|
|
1257
|
+
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1013
1258
|
try:
|
|
1014
|
-
out = subprocess.run([shutil.which(
|
|
1259
|
+
out = subprocess.run([shutil.which(tool) or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
|
|
1015
1260
|
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=PROBE_TIMEOUT).stdout
|
|
1016
1261
|
_ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
|
|
1017
1262
|
except (OSError, subprocess.SubprocessError):
|
|
@@ -1043,4 +1288,4 @@ def read_text_or_die(path: str, flag: str) -> str:
|
|
|
1043
1288
|
# emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
|
|
1044
1289
|
# somewhere: by the time this line runs every name emit reads from runner above is defined, and
|
|
1045
1290
|
# every name runner reads from emit is only ever read inside a function body, never at import.
|
|
1046
|
-
from _common.emit import _plan_at_exit, die, info # noqa: E402
|
|
1291
|
+
from _common.emit import _plan_at_exit, die, hw_report, info # noqa: E402
|
package/scripts/_contract.py
CHANGED
|
@@ -54,6 +54,8 @@ ROLES = {
|
|
|
54
54
|
# filter:<name> `ffmpeg -filters`
|
|
55
55
|
# bsf:<name> `ffmpeg -bsfs`
|
|
56
56
|
# external:whisper a local whisper engine (whisper.cpp / faster-whisper / openai-whisper)
|
|
57
|
+
# external:parakeet a local Parakeet engine (parakeet-mlx, or parakeet-cli with a .gguf model);
|
|
58
|
+
# English. Either external engine satisfies --transcribe.
|
|
57
59
|
# "optional" entries name the flag or condition under which the capability is needed.
|
|
58
60
|
FF = ["ffmpeg", "ffprobe"]
|
|
59
61
|
X264 = "encoder:libx264"
|
|
@@ -145,7 +147,7 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
145
147
|
required=FF + [X264], optional=[],
|
|
146
148
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
147
149
|
"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 one or several language-tagged soft subtitle streams (--mode mux, --srt file:lang repeated)", "generated .srt / .ass sidecar"],
|
|
148
|
-
required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"},
|
|
150
|
+
required=FF + [X264, AAC, "filter:subtitles"], optional=[{"capability": "filter:ass", "when": "--animate / --karaoke"}, HDR_X265, {"capability": "external:whisper", "when": "--transcribe"}, {"capability": "external:parakeet", "when": "--transcribe (English; either engine family satisfies it)"},
|
|
149
151
|
{"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"}],
|
|
150
152
|
video_required=True, audio_only=False, visual=True, verify=["probe", "look"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
151
153
|
"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"],
|
|
@@ -172,7 +174,8 @@ TOOL_META: Dict[str, Dict[str, Any]] = {
|
|
|
172
174
|
video_required=False, audio_only=True, visual=False, verify=["probe", "check"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
173
175
|
"silence": dict(role="analysis_and_execution", inputs=["video or audio asset"], outputs=["silence list JSON (--list)", "artifact with silences removed", "EDL text (--edl)"],
|
|
174
176
|
required=FF + ["filter:silencedetect"], optional=[{"capability": X264, "when": "removing silences from a video"}, HDR_X265, {"capability": AAC, "when": "removing silences from a video"},
|
|
175
|
-
{"capability": "external:whisper", "when": "--filler --transcribe"}
|
|
177
|
+
{"capability": "external:whisper", "when": "--filler --transcribe"},
|
|
178
|
+
{"capability": "external:parakeet", "when": "--filler --transcribe (English; either engine family satisfies it)"}] + AUDIO_OUT,
|
|
176
179
|
video_required=False, audio_only=True, visual=False, verify=["probe"], produces_artifact=True, idempotency="content_equivalent", deterministic=True),
|
|
177
180
|
"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)"],
|
|
178
181
|
required=FF + ["filter:xfade", "filter:acrossfade"],
|
|
@@ -295,7 +298,8 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
295
298
|
}
|
|
296
299
|
|
|
297
300
|
IDEMPOTENCY = {
|
|
298
|
-
"bit_exact": "same inputs and flags give byte-identical output"
|
|
301
|
+
"bit_exact": "same inputs and flags give byte-identical output (with the CPU encoders: under --hw / $FFMPEG_SKILL_HW a "
|
|
302
|
+
"VideoToolbox encode is content_equivalent at best)",
|
|
299
303
|
"content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
|
|
300
304
|
"cached": "re-runs skip inputs whose content hash and recipe are unchanged",
|
|
301
305
|
"environment_dependent": "output includes timings or machine state and differs between runs",
|
|
@@ -348,7 +352,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
348
352
|
props: Dict[str, Any] = {}
|
|
349
353
|
required: List[str] = []
|
|
350
354
|
positional: List[str] = []
|
|
351
|
-
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality"}
|
|
355
|
+
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality", "hw", "no_hw"}
|
|
352
356
|
for action in parser._actions:
|
|
353
357
|
if isinstance(action, argparse._HelpAction):
|
|
354
358
|
continue
|
|
@@ -371,6 +375,13 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
371
375
|
required.append(action.dest)
|
|
372
376
|
if action.dest in common:
|
|
373
377
|
prop["common"] = True
|
|
378
|
+
if action.dest in props:
|
|
379
|
+
# one property per dest: a second action (a store_false --no-x beside a store_true --x)
|
|
380
|
+
# would replace the first, and the contract and MCP would publish only the second flag
|
|
381
|
+
# under the first one's name -- an MCP `x: true` would then send --no-x
|
|
382
|
+
first = props[action.dest]["cli"]
|
|
383
|
+
raise RuntimeError(f"{parser.prog}: {'/'.join(action.option_strings) or action.dest} shares dest {action.dest!r} "
|
|
384
|
+
f"with {'/'.join(first) if isinstance(first, list) else 'a positional'}; give each flag its own dest")
|
|
374
385
|
props[action.dest] = prop
|
|
375
386
|
groups = [g for g in getattr(parser, "_mutually_exclusive_groups", []) if g._group_actions]
|
|
376
387
|
schema: Dict[str, Any] = {"type": "object", "properties": props, "required": required, "positional": positional, "additionalProperties": False}
|
|
@@ -380,8 +391,9 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
380
391
|
return schema
|
|
381
392
|
|
|
382
393
|
|
|
383
|
-
def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
384
|
-
"""What the tool prints on stdout with --json (keys observed in the implementation).
|
|
394
|
+
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
395
|
+
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
396
|
+
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
385
397
|
if name == "probe":
|
|
386
398
|
return {"type": "object", "description": "one probe document, or an array of them for several inputs",
|
|
387
399
|
"properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
|
|
@@ -389,6 +401,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
389
401
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
390
402
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
391
403
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
404
|
+
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
|
|
405
|
+
"hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item) and on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export), source: flag|env, used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
|
|
392
406
|
"plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
|
|
393
407
|
"verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
|
|
394
408
|
"verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets) or check (render)"}}
|
|
@@ -398,7 +412,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
398
412
|
"notes": {"type": "array", "items": {"type": "string"}, "description": "present when no --platform was named: youtube was assumed and judgement rows are WARN"},
|
|
399
413
|
"checks": {"type": "array", "items": {"type": "object", "properties": {"check": {"type": "string", "description": "row name; --content adds black, frozen and silence; audio FAILs when the track is present but silent (peak <= -50 dBFS)"}, "status": {"enum": ["PASS", "WARN", "FAIL"]}, "value": {}, "expected": {}, "fix": {"type": "string"}, "kind": {"enum": ["format", "judgement"]}}}}}
|
|
400
414
|
elif name == "caption":
|
|
401
|
-
extra = {"
|
|
415
|
+
extra = {"transcription": {"type": "object", "description": "with --transcribe: {engine, model, language, routing (why auto chose it), detected_language when auto detected it}; when auto ran the English-only Parakeet model on English assumed, notes says so"},
|
|
416
|
+
"caption": {"type": "object", "description": "cue layout: shifted / wrapped / rebalanced / split / extended / dropped counts, plus wrap ('phrase' or 'measured'), phrase_breaks (1.16), broken_inside_word -- atoms hard-sliced at the column edge because they did not fit alone even at the size floor (1.18.4) -- and overlong -- now residual: a single character alone wider than the column. Burn mode: cues_burned -- non-blank cues that overlap [0, duration] and are drawn -- and cues_outside -- non-blank cues wholly outside it (2.2.6); a burn with no visible cue is refused, kind input"},
|
|
402
417
|
"tracks": {"type": "array", "description": "--mode mux: one entry per subtitle stream in the output ({index, file, language, title, codec, default, cues, kept_from_input}); a stream the input already carried has file null and kept_from_input true (1.16)"},
|
|
403
418
|
"subtitle_tracks": {"type": "integer", "description": "--mode mux: how many subtitle streams the output carries"},
|
|
404
419
|
"emoji": {"type": "object", "description": "how the emoji in the text were drawn (mode, overlays, missing)"},
|
|
@@ -419,7 +434,9 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
419
434
|
"speech": {"type": "array", "description": "--speech (1.18): [{time, speech_music_ratio}]"}}
|
|
420
435
|
elif name == "silence":
|
|
421
436
|
extra = {"silences": {"type": "array"}, "keep": {"type": "array"}, "input_duration": {"type": "number"}, "kept_duration": {"type": "number"}, "removed_seconds": {"type": "number"},
|
|
422
|
-
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"}
|
|
437
|
+
"speech_aware": {"type": "object", "description": "--speech-aware (1.18): {min_silence, floor, breaths_kept, breaths_kept_seconds, breaths}"},
|
|
438
|
+
"filler": {"type": "object", "description": "--filler: {lang, source (whisper-json:FILE | whisper:ENGINE | parakeet:ENGINE), engine, transcription (with --transcribe: {engine, model, language, routing}), removed, removed_count, ...}"},
|
|
439
|
+
"notes": {"type": "array", "items": {"type": "string"}, "description": "--filler --transcribe under --engine auto: present when the English-only Parakeet model ran on English assumed (no --filler-lang, no language detected, no Whisper engine installed or none that succeeded); the transcript is wrong if the speech is not English"}}
|
|
423
440
|
elif name == "sync":
|
|
424
441
|
extra = {"reference": {"type": "string"}, "second": {"type": "string"}, "offset_seconds": {"type": "number"}, "confidence": {"type": "number"}, "meaning": {"type": "string"}, "drift": {"type": "object"},
|
|
425
442
|
"sources": {"type": "array", "description": "1.18: [{path, offset_s, confidence, drift_ppm}], one per SOURCE; the only per-source shape once more than one SOURCE is given"}}
|
|
@@ -481,6 +498,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
481
498
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
482
499
|
props = dict(base)
|
|
483
500
|
props.update(extra)
|
|
501
|
+
notes = props.get("notes")
|
|
502
|
+
if hw and isinstance(notes, dict) and notes.get("description"):
|
|
503
|
+
# a tool-specific condition is not the only one: emit() adds the GPU line to any tool
|
|
504
|
+
props["notes"] = dict(notes, description=notes["description"] + "; also, on a VideoToolbox encode (--hw / "
|
|
505
|
+
"$FFMPEG_SKILL_HW), a line that its quality is not CRF-equivalent (see hw)")
|
|
484
506
|
required = ["status", "output", "dry_run", "commands"]
|
|
485
507
|
return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
|
|
486
508
|
|
|
@@ -656,6 +678,21 @@ def _whisper_available() -> bool:
|
|
|
656
678
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
657
679
|
|
|
658
680
|
|
|
681
|
+
def _parakeet_available() -> bool:
|
|
682
|
+
"""parakeet-mlx on PATH, or parakeet-cli with a model it can find (PARAKEET_CPP_MODEL or a
|
|
683
|
+
.gguf in ~/.cache/parakeet.cpp) -- the same test the speech bridge applies before running it."""
|
|
684
|
+
from _common.asr import _parakeet_available as runtime_check # the bridge's own test, not a copy of it
|
|
685
|
+
return any(runtime_check(e, shutil) for e in ("parakeet-mlx", "parakeet.cpp"))
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _hw_default() -> Dict[str, Any]:
|
|
689
|
+
"""--hw's machine facts, normalised (never the raw environment value): whether VideoToolbox
|
|
690
|
+
constant-quality encoding can run here (the tools' own check: Apple Silicon hardware, also
|
|
691
|
+
under a Rosetta Python), and whether $FFMPEG_SKILL_HW makes it the default."""
|
|
692
|
+
from _common.runner import env_hw, hw_platform_reason
|
|
693
|
+
return {"platform_ok": hw_platform_reason() is None, "default_on": env_hw()}
|
|
694
|
+
|
|
695
|
+
|
|
659
696
|
def _default_font() -> str:
|
|
660
697
|
from _common import BRAND_DEFAULTS
|
|
661
698
|
return str(BRAND_DEFAULTS["font"])
|
|
@@ -846,6 +883,8 @@ def doctor() -> Dict[str, Any]:
|
|
|
846
883
|
state[cap] = _from("bsfs", cap[4:])
|
|
847
884
|
elif cap == "external:whisper":
|
|
848
885
|
state[cap] = "available" if _whisper_available() else "missing"
|
|
886
|
+
elif cap == "external:parakeet":
|
|
887
|
+
state[cap] = "available" if _parakeet_available() else "missing"
|
|
849
888
|
else:
|
|
850
889
|
state[cap] = "missing"
|
|
851
890
|
available = sorted(c for c, st in state.items() if st == "available")
|
|
@@ -872,6 +911,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
872
911
|
"ok": not missing_required and not unknown_required,
|
|
873
912
|
"tools": _tool_usability(state),
|
|
874
913
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
914
|
+
"hw": _hw_default(),
|
|
875
915
|
"fonts": _fonts_capability(probe=True),
|
|
876
916
|
}
|
|
877
917
|
|
|
@@ -972,7 +1012,15 @@ def _capability_fix_hint(cap: str) -> str:
|
|
|
972
1012
|
return (f"no installed font covers {LANGUAGE_NAMES.get(script, script)} text on this machine; "
|
|
973
1013
|
f"{FONT_INSTALL_HINT} (doctor --json .fonts.scripts lists every script)")
|
|
974
1014
|
if cap == "external:whisper":
|
|
975
|
-
return "install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe"
|
|
1015
|
+
return ("install a local whisper (whisper-cli, whisper-cpp, faster-whisper or openai-whisper) for --transcribe; "
|
|
1016
|
+
"faster-whisper and openai-whisper download their model on first use, and every engine transcribes "
|
|
1017
|
+
"on this machine (the audio is never uploaded)")
|
|
1018
|
+
if cap == "external:parakeet":
|
|
1019
|
+
from _common.asr import PARAKEET_MLX_DEFAULT_MODEL
|
|
1020
|
+
return ("for English --transcribe, install parakeet-mlx (uv tool install parakeet-mlx; its first run downloads "
|
|
1021
|
+
f"{PARAKEET_MLX_DEFAULT_MODEL} from Hugging Face, then it transcribes on this machine and the audio is "
|
|
1022
|
+
"never uploaded) or parakeet-cli with a tdt-0.6b-v2 .gguf in ~/.cache/parakeet.cpp; a local whisper "
|
|
1023
|
+
"covers every language")
|
|
976
1024
|
return f"'{cap}' is not available; see docs/contract.md"
|
|
977
1025
|
|
|
978
1026
|
|
|
@@ -1101,7 +1149,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1101
1149
|
"inputs": list(meta["inputs"]),
|
|
1102
1150
|
"outputs": list(meta["outputs"]),
|
|
1103
1151
|
"input_schema": schema,
|
|
1104
|
-
"output_schema": output_schema(name, meta),
|
|
1152
|
+
"output_schema": output_schema(name, meta, hw="hw" in schema["properties"]),
|
|
1105
1153
|
"supports_dry_run": supports_dry_run,
|
|
1106
1154
|
"dry_run": {"supported": supports_dry_run,
|
|
1107
1155
|
"ffmpeg_execution": "full" if not supports_dry_run else "analysis_only" if name in DRY_RUN_ANALYSIS else "none",
|
|
@@ -1337,7 +1385,7 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1337
1385
|
version = skill_version()
|
|
1338
1386
|
tools = [tool_spec(n, version) for n in public_tools()]
|
|
1339
1387
|
wanted = required_capabilities()
|
|
1340
|
-
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper"}
|
|
1388
|
+
caps: Dict[str, Any] = {"required": wanted["required"], "optional": wanted["optional"], "naming": "ffmpeg | ffprobe | encoder:<name> | filter:<name> | bsf:<name> | external:whisper | external:parakeet"}
|
|
1341
1389
|
if detect:
|
|
1342
1390
|
d = doctor()
|
|
1343
1391
|
caps.update({"available": d["available"], "missing": d["missing"], "missing_optional": d["missing_optional"],
|
|
@@ -1364,8 +1412,21 @@ def build(detect: bool = True) -> Dict[str, Any]:
|
|
|
1364
1412
|
"execution": {
|
|
1365
1413
|
"shell": False,
|
|
1366
1414
|
"arbitrary_executables": False,
|
|
1367
|
-
"subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH"
|
|
1415
|
+
"subprocess": "argv list only: [python3, scripts/<tool>.py, ...] and [ffmpeg|ffprobe, ...] resolved from PATH; "
|
|
1416
|
+
"with --transcribe, a local speech engine's CLI (whisper.cpp's whisper-cli / whisper-cpp / main, "
|
|
1417
|
+
"openai-whisper's whisper, parakeet-mlx, parakeet-cli) resolved from PATH",
|
|
1368
1418
|
"network": False,
|
|
1419
|
+
# `network` is about the skill's own code. A speech engine may fetch its model weights the
|
|
1420
|
+
# first time it runs; faster-whisper is a library, so its fetch is made from the skill's own
|
|
1421
|
+
# process. A caller that sandboxes the network must know that before a first --transcribe.
|
|
1422
|
+
"model_downloads": {
|
|
1423
|
+
"when": "first use of a speech engine (--transcribe) whose model is not cached yet; media is never uploaded",
|
|
1424
|
+
"in_process": {"faster-whisper": "Hugging Face (Systran/faster-whisper-<model>)"},
|
|
1425
|
+
"child_process": {"parakeet-mlx": "Hugging Face (mlx-community/parakeet-*)",
|
|
1426
|
+
"openai-whisper": "OpenAI (its model checkpoint)"},
|
|
1427
|
+
"none": ["whisper.cpp", "parakeet.cpp"],
|
|
1428
|
+
"offline": "HF_HUB_OFFLINE=1 keeps faster-whisper and parakeet-mlx on the cached copy",
|
|
1429
|
+
},
|
|
1369
1430
|
"input_mutation": False,
|
|
1370
1431
|
},
|
|
1371
1432
|
"invocation": {
|