ffmpeg-skill 2.5.1 → 2.6.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
@@ -295,7 +295,8 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
295
295
  }
296
296
 
297
297
  IDEMPOTENCY = {
298
- "bit_exact": "same inputs and flags give byte-identical output",
298
+ "bit_exact": "same inputs and flags give byte-identical output (with the CPU encoders: under --hw / $FFMPEG_SKILL_HW a "
299
+ "VideoToolbox encode is content_equivalent at best)",
299
300
  "content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
300
301
  "cached": "re-runs skip inputs whose content hash and recipe are unchanged",
301
302
  "environment_dependent": "output includes timings or machine state and differs between runs",
@@ -348,7 +349,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
348
349
  props: Dict[str, Any] = {}
349
350
  required: List[str] = []
350
351
  positional: List[str] = []
351
- common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality"}
352
+ common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality", "hw", "no_hw"}
352
353
  for action in parser._actions:
353
354
  if isinstance(action, argparse._HelpAction):
354
355
  continue
@@ -371,6 +372,13 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
371
372
  required.append(action.dest)
372
373
  if action.dest in common:
373
374
  prop["common"] = True
375
+ if action.dest in props:
376
+ # one property per dest: a second action (a store_false --no-x beside a store_true --x)
377
+ # would replace the first, and the contract and MCP would publish only the second flag
378
+ # under the first one's name -- an MCP `x: true` would then send --no-x
379
+ first = props[action.dest]["cli"]
380
+ raise RuntimeError(f"{parser.prog}: {'/'.join(action.option_strings) or action.dest} shares dest {action.dest!r} "
381
+ f"with {'/'.join(first) if isinstance(first, list) else 'a positional'}; give each flag its own dest")
374
382
  props[action.dest] = prop
375
383
  groups = [g for g in getattr(parser, "_mutually_exclusive_groups", []) if g._group_actions]
376
384
  schema: Dict[str, Any] = {"type": "object", "properties": props, "required": required, "positional": positional, "additionalProperties": False}
@@ -380,8 +388,9 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
380
388
  return schema
381
389
 
382
390
 
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)."""
391
+ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
392
+ """What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
393
+ tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
385
394
  if name == "probe":
386
395
  return {"type": "object", "description": "one probe document, or an array of them for several inputs",
387
396
  "properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
@@ -389,6 +398,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
389
398
  base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
390
399
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
391
400
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
401
+ "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"},
402
+ "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
403
  "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
393
404
  "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
405
  "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)"}}
@@ -481,6 +492,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
481
492
  "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
493
  props = dict(base)
483
494
  props.update(extra)
495
+ notes = props.get("notes")
496
+ if hw and isinstance(notes, dict) and notes.get("description"):
497
+ # a tool-specific condition is not the only one: emit() adds the GPU line to any tool
498
+ props["notes"] = dict(notes, description=notes["description"] + "; also, on a VideoToolbox encode (--hw / "
499
+ "$FFMPEG_SKILL_HW), a line that its quality is not CRF-equivalent (see hw)")
484
500
  required = ["status", "output", "dry_run", "commands"]
485
501
  return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
486
502
 
@@ -656,6 +672,14 @@ def _whisper_available() -> bool:
656
672
  return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
657
673
 
658
674
 
675
+ def _hw_default() -> Dict[str, Any]:
676
+ """--hw's machine facts, normalised (never the raw environment value): whether VideoToolbox
677
+ constant-quality encoding can run here (the tools' own check: Apple Silicon hardware, also
678
+ under a Rosetta Python), and whether $FFMPEG_SKILL_HW makes it the default."""
679
+ from _common.runner import env_hw, hw_platform_reason
680
+ return {"platform_ok": hw_platform_reason() is None, "default_on": env_hw()}
681
+
682
+
659
683
  def _default_font() -> str:
660
684
  from _common import BRAND_DEFAULTS
661
685
  return str(BRAND_DEFAULTS["font"])
@@ -872,6 +896,7 @@ def doctor() -> Dict[str, Any]:
872
896
  "ok": not missing_required and not unknown_required,
873
897
  "tools": _tool_usability(state),
874
898
  "gpu_encoders": _gpu_encoders(listings["encoders"]),
899
+ "hw": _hw_default(),
875
900
  "fonts": _fonts_capability(probe=True),
876
901
  }
877
902
 
@@ -1101,7 +1126,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
1101
1126
  "inputs": list(meta["inputs"]),
1102
1127
  "outputs": list(meta["outputs"]),
1103
1128
  "input_schema": schema,
1104
- "output_schema": output_schema(name, meta),
1129
+ "output_schema": output_schema(name, meta, hw="hw" in schema["properties"]),
1105
1130
  "supports_dry_run": supports_dry_run,
1106
1131
  "dry_run": {"supported": supports_dry_run,
1107
1132
  "ffmpeg_execution": "full" if not supports_dry_run else "analysis_only" if name in DRY_RUN_ANALYSIS else "none",
package/scripts/batch.py CHANGED
@@ -33,6 +33,8 @@ from pathlib import Path
33
33
  from typing import Any, Dict, List, Optional
34
34
 
35
35
  from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
36
+ from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw
37
+ from _common.emit import absorb_stage_hw, steps_encoder_report
36
38
 
37
39
  HERE = Path(__file__).resolve().parent
38
40
  MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
@@ -73,6 +75,18 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
73
75
  return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
74
76
 
75
77
 
78
+ def gpu_key() -> str:
79
+ """The GPU setting the steps resolve, as a suffix for the item cache key: a VideoToolbox file is
80
+ not the CPU's bytes, so a --hw output is never served to a --no-hw run or the other way round
81
+ (render.py's cache_key folds the same two variables in). Read after apply_common(), which has
82
+ exported --hw/--no-hw to the steps. '' when every step encodes on the CPU -- no flag and no
83
+ $FFMPEG_SKILL_HW, or --no-hw -- so the keys of a cache written before --hw existed still hit."""
84
+ if not env_hw():
85
+ return ""
86
+ # the variable alone leaves export.py's delivery presets on the CPU; --hw puts them on the GPU
87
+ return ":hw" if os.environ.get(HW_FORCED_ENV) == "1" else ":hw-env"
88
+
89
+
76
90
  JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
77
91
 
78
92
  _LOG = threading.local()
@@ -157,6 +171,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
157
171
  pj = work / f"{src.stem}_project.json"
158
172
  pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
159
173
  ok, _doc = run_step(["render.py", str(pj)], budget())
174
+ step_docs = [("render", _doc)]
160
175
  cut_reencoded: List[bool] = []
161
176
  else:
162
177
  steps = recipe.get("steps") or []
@@ -165,11 +180,13 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
165
180
  cur = str(src)
166
181
  ok = True
167
182
  cut_reencoded = []
183
+ step_docs = []
168
184
  for i, step in enumerate(steps):
169
185
  last = i == len(steps) - 1
170
186
  out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
171
187
  argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
172
188
  step_ok, doc = run_step(argv, budget())
189
+ step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
173
190
  # only cut.py's own doc carries `reencoded` -- true if ANY range this call cut needed
174
191
  # the tolerance-triggered hybrid re-encode fallback (cut.py ORs its per-segment results
175
192
  # into one top-level field; it doesn't report which range, so this is per cut.py call,
@@ -183,6 +200,9 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
183
200
  result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
184
201
  if cut_reencoded:
185
202
  result["cut_reencoded"] = cut_reencoded
203
+ # the encoder each item really ran and its GPU facts (--hw / FFMPEG_SKILL_HW: on VideoToolbox,
204
+ # fell back and why): this process encodes nothing itself, so its top-level hw.used is null
205
+ result.update(steps_encoder_report(step_docs))
186
206
  return result
187
207
 
188
208
 
@@ -199,6 +219,7 @@ def main() -> int:
199
219
  "Default 1, which is 1.16's behaviour exactly.")
200
220
  ap.add_argument("--work", help="work directory for intermediates (default: <output_dir>/.work)")
201
221
  add_common(ap)
222
+ add_hw_orchestrator_args(ap)
202
223
  args = ap.parse_args()
203
224
  apply_common(args)
204
225
 
@@ -240,7 +261,7 @@ def main() -> int:
240
261
  cache = json.loads(cache_path.read_text(encoding="utf-8"))
241
262
  except ValueError:
242
263
  cache = {}
243
- rkey = recipe_key(recipe)
264
+ rkey = recipe_key(recipe) + gpu_key()
244
265
  glob = recipe.get("glob") or "*"
245
266
 
246
267
  # --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
@@ -303,6 +324,10 @@ def main() -> int:
303
324
  # write is already atomic: two finishers could otherwise serialise from two different
304
325
  # snapshots and lose an entry.
305
326
  with cache_lock:
327
+ # this run just rewrote r["output"]: an entry under another key (another recipe,
328
+ # the other GPU setting) that names the same file now describes bytes that are gone
329
+ for stale in [k for k, v in cache.items() if k != key and isinstance(v, dict) and v.get("output") == r["output"]]:
330
+ del cache[stale]
306
331
  cache[key] = r
307
332
  # write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
308
333
  # a concurrent manual run) could leave a truncated file that json.loads() above
@@ -507,6 +532,10 @@ def main() -> int:
507
532
  results = one_pass()
508
533
  except KeyboardInterrupt:
509
534
  info("watch stopped")
535
+ # the items' GPU facts (asked for, fell back and why) reach the top-level `hw`, as render.py's
536
+ # stages' do; `used` stays null there, because this process encoded nothing itself
537
+ for r in results:
538
+ absorb_stage_hw(Path(r["file"]).name, r)
510
539
  done = sum(1 for r in results if r["ok"])
511
540
  info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
512
541
  if not args.json:
package/scripts/export.py CHANGED
@@ -37,6 +37,7 @@ from pathlib import Path
37
37
  from typing import Dict, List
38
38
 
39
39
  from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
40
+ from _common.decision import hw_preset_video, restate_last_swap
40
41
  from check import SPECS as PLATFORMS, measure_loudness
41
42
  from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
42
43
  # Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
@@ -190,20 +191,28 @@ def main() -> int:
190
191
  "veryfast" if STATE.fast else "medium", meta)
191
192
  # encoder_args() already applied --fast (its own preset scale per encoder: SVT-AV1 counts
192
193
  # 1..12, not x264's names) and appends +faststart, which this tool adds again for mp4
194
+ as_built = list(video)
193
195
  while "-movflags" in video:
194
196
  i = video.index("-movflags")
195
197
  del video[i:i + 2]
198
+ restate_last_swap(as_built, video)
196
199
  if args.crf is not None and "-crf" in video:
197
200
  video[video.index("-crf") + 1] = str(args.crf)
198
201
  if STATE.fast and "-preset" in video and not p.get("codec"):
199
202
  video[video.index("-preset") + 1] = "veryfast"
203
+ # a fixed preset's line is tagged BT.709 (prores, an editing master, keeps the source's tags)
204
+ tag_bt709 = args.preset not in ("prores",) and not p.get("codec")
205
+ if STATE.hw and not p.get("codec") and video:
206
+ # --hw (never $FFMPEG_SKILL_HW here) puts a fixed preset on VideoToolbox: the same codec,
207
+ # its CRF mapped to -q:v, the preset's frame rate kept; tags come with the VT line itself
208
+ video = hw_preset_video(video, meta, bt709=tag_bt709)
200
209
  cmd += video
201
210
  if args.preset != "copy":
202
211
  # a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
203
212
  # no longer be a copy, and would silently mislabel colour the agent never actually looked at
204
213
  if "-r" not in video:
205
214
  cmd += cfr_args(meta)
206
- if args.preset not in ("prores",) and not p.get("codec"):
215
+ if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
207
216
  cmd += bt709_tag_args(video[video.index("-c:v") + 1])
208
217
  if out_ext == "mp4":
209
218
  cmd += ["-movflags", "+faststart"]