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.
@@ -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
- die(f"command failed ({returncode}): {cmd[0]}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode)
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 = _execute(exec_cmd)
652
- if proc.returncode != 0 and is_ffmpeg:
653
- retry = _odd_dimension_retry(exec_cmd, proc.stderr or "")
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
- proc = _execute(retry)
658
- elif "not divisible by 2" in (proc.stderr or ""):
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(exec_cmd, proc.returncode, proc.stderr or "")
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`, read once; empty when ffmpeg is missing. Used only to pick
1009
- an AV1 encoder and to refuse --codec av1 / prores before ffmpeg would."""
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("ffmpeg") or "ffmpeg", "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
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
@@ -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"}] + AUDIO_OUT,
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 = {"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"},
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": {