ffmpeg-skill 2.8.5 → 2.8.6

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 CHANGED
@@ -356,7 +356,7 @@ npx ffmpeg-skill doctor --json # available / missing / missing_optional / unkn
356
356
 
357
357
  `doctor` reads `ffmpeg -encoders`, `-filters` and `-bsfs` and resolves every capability the contract declares against this machine's build. Three states per capability: `available`, `missing`, `unknown`. Exit 0 when everything required is available, 1 when something required is missing, 2 when nothing is proven missing but a required capability is unknown. With detection on (the default), `contract --json` carries the same lists under `capabilities`. `doctor` also reports fonts: the default drawtext family, and `fonts.scripts` — one `available`/`missing`/`unknown` per writing system (ja, zh, ko, ar, he, hi, th, ru, el) with the file it would use — so "can this machine render Korean captions" is answered before the job, not after. `doctor --json`'s `tools` field folds that down to one answer per tool — `{"caption": {"usable": "no", "missing": ["filter:subtitles"], "fix": "..."}, ...}` — so "is `doctor` overall `ok`" and "can I run `caption.py` on this machine" are answered separately: a plain Homebrew `ffmpeg` is `ok` for tools that don't need `subtitles`/`drawtext`/`zscale`, while `caption`'s own `usable` is `"no"`.
358
358
 
359
- `doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). It never affects `ok` or any tool's `usable`. The one GPU path a tool uses is Apple VideoToolbox, opt-in: `--hw` on every re-encoding tool (h264/hevc/prores on the GPU, AV1 stays on SVT-AV1), `FFMPEG_SKILL_HW=1` to make it this machine's default for everything except `export.py`'s delivery presets (those take `--hw` explicitly, or `render.py --hw` for a whole project). It runs only on Apple Silicon and never by default; it falls back to the CPU encoder — reported in the result's `hw.fallback`/`hw.notes` — only when VideoToolbox itself refuses a job (any other ffmpeg failure is reported as it happened, not retried); every result names the `encoder` that actually ran (`batch.py`, which encodes nothing itself: each `results` row), and a GPU encode adds a `notes` line that its quality is not CRF-equivalent. Measured on an M4 Max, 2–7× faster than x264/x265 `medium`; at matched SSIM (`tests/bench_vt.py`) 1.1–2.9× the bytes on SDR and 1.9–3.5× on HDR, which is why it is a flag and not the default (`docs/design-decisions.md`). `doctor --json`'s `hw` says whether this machine can run it (`platform_ok`) and whether it is the default here (`default_on`).
359
+ `doctor --json`'s `gpu_encoders` reports which GPU-backed encoders (`nvenc`, `videotoolbox`, `qsv`, `vaapi`, `amf`) this ffmpeg *build* was compiled with — read from `-encoders` alone, so it proves the capability shipped, not that the GPU/driver on this machine will actually accept a job (that needs a real encode, which `doctor`'s introspection never runs). It never affects `ok` or any tool's `usable`. The one GPU path a tool uses is Apple VideoToolbox, opt-in: `--hw` on every re-encoding tool (h264/hevc/prores on the GPU, AV1 stays on SVT-AV1), `FFMPEG_SKILL_HW=1` to make it this machine's default for everything except `export.py`'s delivery presets (those take `--hw` explicitly, or `render.py --hw` for a whole project). It runs only on Apple Silicon and never by default, except on a build with no `libx264`/`libx265` (an LGPL ffmpeg that has only `h264_videotoolbox`/`hevc_videotoolbox`): there it is the only encoder, so every re-encoding tool and `export.py`'s presets use it without `--hw`, the result says `hw.source: "auto"`, and `doctor` lists the missing x264/x265 under `covered_by` instead of calling the tools unusable (`--no-hw` still asks for the CPU). It falls back to the CPU encoder — reported in the result's `hw.fallback`/`hw.notes` — only when VideoToolbox itself refuses a job (any other ffmpeg failure is reported as it happened, not retried); every result names the `encoder` that actually ran (`batch.py`, which encodes nothing itself: each `results` row), and a GPU encode adds a `notes` line that its quality is not CRF-equivalent. Measured on an M4 Max, 2–7× faster than x264/x265 `medium`; at matched SSIM (`tests/bench_vt.py`) 1.1–2.9× the bytes on SDR and 1.9–3.5× on HDR, which is why it is a flag and not the default (`docs/design-decisions.md`). `doctor --json`'s `hw` says whether this machine can run it (`platform_ok`) and whether it is the default here (`default_on`).
360
360
 
361
361
  ## Gotchas and best practices
362
362
 
package/docs/contract.md CHANGED
@@ -21,7 +21,7 @@ The contract is derived from the code that runs, not maintained beside it:
21
21
  | Field | Meaning | Changes when |
22
22
  |---|---|---|
23
23
  | `contract_version` | shape of this document (`1.0`) | a key is renamed, removed or changes meaning |
24
- | `skill.version` | the npm / package.json version (`2.8.5`) | any release |
24
+ | `skill.version` | the npm / package.json version (`2.8.6`) | any release |
25
25
 
26
26
  A release that adds a tool or a flag keeps `contract_version`; a breaking change to the
27
27
  ToolSpec shape bumps it. Consumers pin on `contract_version` and read `skill.version`
@@ -119,7 +119,7 @@ JSON would have been rewritten for that. The flat keys, typed per tool in `outpu
119
119
  "contract_version": "1.0",
120
120
  "deprecated": [],
121
121
  "removed": [{"what": "...", "since": "1.10.0", "replacement": "...", "removed_in": "2.0.0", "where": "cli | json | mcp | behaviour"}],
122
- "skill": {"id": "ffmpeg-skill", "version": "2.8.5", "execution_mode": "local", "kind": "execution",
122
+ "skill": {"id": "ffmpeg-skill", "version": "2.8.6", "execution_mode": "local", "kind": "execution",
123
123
  "entrypoints": {"cli": "...", "mcp": "...", "contract": "...", "doctor": "..."},
124
124
  "not_provided": ["AI reasoning", "decisions", "production plans", "project IR", "approvals", "network access", "transcription engine"]},
125
125
  "requirements": {"python": ">=3.9 (standard library only)", "ffmpeg": ">=5.0", "ffprobe": ">=5.0"},
@@ -228,8 +228,12 @@ input schema (`hw`, `no_hw`; MCP sends `--hw` for `hw: true` and `--no-hw` for `
228
228
  mutually exclusive. `--hw` moves an h264/hevc/prores encode to Apple VideoToolbox (Apple Silicon
229
229
  only; AV1 stays on the CPU); `--no-hw` keeps it on the CPU even when `FFMPEG_SKILL_HW=1` makes the
230
230
  GPU this machine's default. Neither changes anything for a caller that passes neither on a
231
- machine without the variable. What ran is in the result's `encoder` and `hw` (see the keys added
232
- after 2.2.2, and `doctor`'s `gpu_encoders` below).
231
+ machine without the variable, with one exception: on a build whose `ffmpeg -encoders` has no
232
+ `libx264` (or no `libx265`) but has the matching VideoToolbox encoder, on Apple Silicon, an H.264
233
+ (or HEVC) encode takes VideoToolbox without `--hw` -- `export.py`'s presets too, which
234
+ `FFMPEG_SKILL_HW` never moves -- because there is no CPU encoder to choose instead; `--no-hw` still
235
+ asks for the CPU and gets what ffmpeg says about it. What ran is in the result's `encoder` and `hw`
236
+ (see the keys added after 2.2.2, and `doctor`'s `gpu_encoders` and `covered_by` below).
233
237
 
234
238
  `--plan FILE` (1.6) is a dry run that also writes a plan document: `{"plan_version": 1,
235
239
  "tool", "argv", "cwd", "inputs": [{"path", "size", "sha256_head_tail"}], "commands",
@@ -410,11 +414,22 @@ acompressor A->A`) by the io-spec token, so the flag width does not matter; fixt
410
414
  both live in `tests/fixtures/`.
411
415
 
412
416
  `ffmpeg-skill doctor` exits 0 when every required capability is available, 1 when one is
413
- missing, 2 when none is missing but a required one is unknown. `ok` is true only for 0.
414
- The keys of 0.9.0 (`available`, `missing`, `missing_optional`, `ok`) are unchanged.
417
+ missing, 2 when none is missing but a required one is unknown. `ok` is true only for 0. The
418
+ keys of 0.9.0 (`available`, `missing`, `missing_optional`, `ok`) are unchanged.
419
+
420
+ One capability can be neither: `encoder:libx264` (`encoder:libx265`) absent from the build while
421
+ `h264_videotoolbox` (`hevc_videotoolbox`) is in it, on a machine that can run VideoToolbox (the
422
+ check `--hw` uses: macOS on Apple Silicon). The tools encode with VideoToolbox there without
423
+ `--hw`, so the capability is left out of `available`, `missing` and `missing_optional` -- it is not
424
+ in the build, and it blocks nothing -- and is named in the additive `covered_by`:
425
+ `{"encoder:libx264": "encoder:h264_videotoolbox"}` (`{}` for every other build, which reports what
426
+ it always did). `ok` and the per-tool `usable` follow what will run, a tool that needs the covered
427
+ capability lists it in its own `covered_by`, and `notes` says its output is not CRF-equivalent.
428
+ The contract's `capabilities.required`/`optional` lists still name `encoder:libx264`: they say what
429
+ the tool needs from a build, not which encoder satisfies it on a given machine.
415
430
 
416
431
  `doctor`'s `tools` field folds that same per-capability `state` into a per-tool answer:
417
- `{"<tool>": {"usable": "yes"|"no"|"unknown", "missing": [...], "fix": "...", "unknown": [...]}}`.
432
+ `{"<tool>": {"usable": "yes"|"no"|"unknown", "missing": [...], "fix": "...", "unknown": [...], "covered_by": {...}}}`.
418
433
  `missing`/`unknown` list only that tool's own required capabilities that are in that state
419
434
  (`missing` is absent when there is none, same for `unknown`); `fix` is a one-line, plain-language
420
435
  remedy for each missing capability, joined with "; " when there is more than one. This exists so
@@ -437,9 +452,10 @@ additive.
437
452
  "parsed"|"unparsed"|"failed"|"missing", "present": [...]}`. It proves the build shipped the
438
453
  capability, not that the GPU/driver on this machine will accept a job (that needs a real
439
454
  encode, which this introspection never runs). No tool declares or requires a GPU encoder, so
440
- `gpu_encoders` never affects `ok` or any tool's `usable`. `--hw` (and
455
+ `gpu_encoders` never affects `ok` or any tool's `usable` (`covered_by`, above, is the one place a
456
+ VideoToolbox encoder counts, and only for a libx264/libx265 the build lacks). `--hw` (and
441
457
  `FFMPEG_SKILL_HW=1`, which `export.py`'s delivery presets ignore) puts h264/hevc/prores encodes on
442
- VideoToolbox on Apple Silicon only, never by default. A job VideoToolbox refuses (the encoder could
458
+ VideoToolbox on Apple Silicon only, never by default (a build without libx264/libx265 excepted). A job VideoToolbox refuses (the encoder could
443
459
  not open, or its compression session failed) is re-encoded on the CPU and said so in the result's
444
460
  `hw.fallback`/`hw.notes`; any other failure is not retried. When `FFMPEG_SKILL_HW=1` rather than `--hw` chose the GPU and the GPU ran,
445
461
  `hw.notes` also says so, with the file-size cost and `--no-hw` as the remedy for a final
@@ -587,7 +603,7 @@ Per-tool keys added after 2.2.2, all additive:
587
603
  | `silent` | `join.py` (every run) | `[{index, path, peak_db, at, end}]`: inputs whose whole-file audio peak (volumedetect) is at or below `--silence-threshold` (default -50 dBFS) and that `--on-silent warn` (default) joined anyway, with a `notes` line; `at`/`end` are where the input sits in the output, in seconds (a transition overlaps its neighbours). An input named by `--allow-silent N[,N]` (1-based) is a planned pause: always joined, listed with `intended: true`, no warning or note, whatever `--on-silent` says. `[]` otherwise. `--on-silent fail` names them in the `kind: input` refusal's `problems` (reason `silent (peak -91.0 dBFS)`), `skip` lists them under `skipped`. An input with no audio stream is never silent; `verified` is unaffected |
588
604
  | `short_segments`, `duplicates` | `join.py` (every run) | warnings, never refusals, and the join command is unchanged: `short_segments: [{index, path, duration}]` names each measured input shorter than 2 frames at the join's fps (an audio-only join: shorter than 0.05 s); `duplicates: [{path, indices}]` names a path (compared resolved) listed more than once — repeating a clip can be intended. `[]` when none; each non-empty one adds a `notes` line |
589
605
  | `silent` | `waveform.py` (every run) | `true` when the input audio's whole-file peak is at or below `--silence-threshold` (default -50 dBFS), which draws a flat line; `--on-silent warn` (default) renders it anyway with a `notes` line, `fail` refuses (`kind: input`) before ffmpeg runs. `false` when audible, `null` under `--dry-run` (nothing measured). `verified` is unaffected |
590
- | `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it) and on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it: `{requested, source: flag\|env, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
606
+ | `encoder`, `hw`, `notes` | every re-encoding tool, `export.py`, `render.py`, `batch.py` (and its rows), `waveform.py` | `encoder`: the video encoder the last command ran (after any GPU→CPU fallback; under `--dry-run`, the planned one), `copy` for a stream copy; absent when no command encoded video. A tool that runs other tools reports its stages': `render.py` its stages and an executed plan's tool, `waveform.py` the title/caption stage that writes its file (its `commands` include that stage's). `hw` is present when VideoToolbox was asked for (`--hw`, `FFMPEG_SKILL_HW=1`, or a stage or `batch.py` item that reported it), on `export.py` when `FFMPEG_SKILL_HW=1` is set but its delivery preset does not take it, and when an encode ran on VideoToolbox because the build has no `libx264`/`libx265`: `{requested, source: flag\|env\|auto, used, fallback, notes}`. `requested` says whether this run's encodes were asked onto VideoToolbox (`false` for that export, and for `source: "auto"`, where nobody asked: `notes` names the missing CPU encoder and the one that ran, and `--no-hw` is not offered as a remedy since there is no CPU encoder to go back to; a job VideoToolbox refuses there is a plain `kind: ffmpeg` failure, not retried), `used` whether the encoder that ran last is VideoToolbox (`null` under `--dry-run`, which ran nothing, and when the tool encoded nothing itself: `batch.py`, whose top-level `hw` gathers its items' `requested`/`source`/`fallback`/`notes` while each `results` row carries the item's own `encoder`, and `hw`/`notes` when VideoToolbox was asked for), `fallback` whether a job VideoToolbox refused was re-encoded on the CPU, and `notes` why. Only a VideoToolbox failure (the encoder could not open, or its compression session failed) is retried on the CPU; any other failure is reported as it happened, once. Every VideoToolbox encode adds a top-level `notes` line: its quality is not CRF-equivalent (`--quality` / a preset's CRF maps to `-q:v` approximately), and `verified` stays what it is for every tool, the measured properties of the output. A `kind: ffmpeg` failure under `--hw` / `FFMPEG_SKILL_HW` carries `encoder` and `hw` for the command that ran last (a stage's, when `render.py`/`waveform.py` re-raise it); when the command itself failed inside the run (not a tool's own check afterwards, such as `cut.py`'s), its message also names that encoder |
591
607
  | `transcription` | `caption.py --transcribe` (every `--mode`); inside `filler` for `silence.py --filler --transcribe` | `{routing, detected_language?, engine, model, language}`: the engine and model that made the transcript; `language` is the language the engine was told -- the one named (`--language`, `--filler-lang`), else the one auto's detector named -- and, when nothing was named, the language the engine decoded where it reports one (whisper.cpp's word-timing JSON, faster-whisper, openai-whisper's word-timing JSON), else `null`; `detected_language` is present when auto's whisper.cpp detector named one; `routing` says why `--engine auto` chose the engine (`"requested"` for a named engine), ending `, no Parakeet engine transcribed it` when auto chose Parakeet and a Whisper engine made the transcript after every Parakeet engine failed |
592
608
  | `notes` | `silence.py --filler --transcribe` (new for `silence.py`; `caption.py` already had it) | present when `--engine auto` ran the English-only Parakeet model on English *assumed* (no language given or detectable, and no Whisper engine installed or none that succeeded): the transcript is wrong if the speech is not English. `caption.py --transcribe` puts the same line in its `notes` |
593
609
  | `reencode_reason`, `segment_precision`, `least_exact_precision`, `start_snapped` | `cut.py` (every run) | `reencode_reason`: why anything was re-encoded, distinct values in first-seen order, `[]` for a clean copy: `requested` (`--accurate`), `codec` (`--codec`), `vfr`, `vfr_inconclusive`, `pcm_container`, `copy_failed`, `tolerance`, `concat_fallback` (a `--segments` join that could not stay a stream copy, which now also sets `reencoded: true` and `mode: hybrid`; it reported `copy` before). `segment_precision`: each `--segments` segment's precision (`null` for one segment); `least_exact_precision`: the least exact of them, on every run. `precision` and `keyframe_snapped` keep their meaning (`keyframe_snapped` is `precision == "packet"`, a stream copy end to end); `least_exact_precision` differs from `precision` only for an audio-only join of copied and re-encoded parts. `start_snapped`: measured, `true` when a copied segment's picture starts more than one frame from its requested start (or its start could not be measured: an unknown start is never claimed exact), `false` when every start is where asked or everything re-encoded, `null` under `--dry-run`. `requested_segments`/`requested_duration` keep their meaning, the request clamped to the media's duration: a segment ended with the video (less than a frame of sound trimmed) shows in `duration_delta_seconds` and a `notes` line |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ffmpeg-skill",
3
- "version": "2.8.5",
3
+ "version": "2.8.6",
4
4
  "description": "Agent Skill that gives coding agents (Claude Code, Cursor, Codex) a local video editor: 42 FFmpeg tools with a machine-readable contract, contract-derived MCP server, FFmpeg capability detection, probe-first / verify-last workflow. Cut, join, silence removal, fit, captions and karaoke, overlays, motion graphics, HDR to SDR, LUTs, audio clean-up and typed dynamics, sync with drift correction, multicam, loudness, delivery checks, project rendering, batch. No API keys, no cloud, no dependencies.",
5
5
  "keywords": [
6
6
  "ffmpeg",
@@ -293,7 +293,9 @@ intermediates and keep the default for the final export. On Apple Silicon,
293
293
  `--hw` (or `FFMPEG_SKILL_HW=1` for the machine) moves h264/hevc/prores encodes to
294
294
  VideoToolbox: 2–7× faster, 1.1–2.9× the bytes at the same quality (1.9–3.5× on HDR). The variable
295
295
  leaves `export.py`'s delivery presets on the CPU, which is the usual split (GPU
296
- intermediates, CPU delivery); `render.py --hw` puts the export on the GPU too.
296
+ intermediates, CPU delivery); `render.py --hw` puts the export on the GPU too. A build with no
297
+ libx264/libx265 (an LGPL ffmpeg) has no CPU delivery to keep: its tools and presets use
298
+ VideoToolbox by themselves, `hw.source` says `auto`.
297
299
  Check `encoder`/`hw` in the result: a job the GPU refuses (H.264 wider than 4096)
298
300
  was re-encoded on the CPU.
299
301
 
@@ -337,14 +337,49 @@ def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: b
337
337
  (_vt_bt709("hevc") if keep_bt709 else [])
338
338
 
339
339
 
340
+ # An LGPL ffmpeg (the kind a desktop app ships) has VideoToolbox but neither libx264 nor libx265.
341
+ # There the CPU line cannot run at all, so VideoToolbox is the only encoder there is and takes the
342
+ # job without --hw (#314). Only when the encoder list was read and lacks the CPU encoder, and only
343
+ # where --hw would run; a build that has libx264 never gets here (docs/design-decisions.md).
344
+ _VT_FOR_CPU = {"libx264": "h264_videotoolbox", "libx265": "hevc_videotoolbox"}
345
+
346
+
347
+ def _vt_in_place_of(cpu_encoder: Optional[str]) -> Optional[str]:
348
+ """The VideoToolbox encoder that takes over from `cpu_encoder` because this ffmpeg build lacks
349
+ it, else None: not when --hw already decided, nor under an explicit --no-hw (the caller asked
350
+ for the CPU), nor on a machine VideoToolbox cannot run on. The platform is asked before the
351
+ encoder list, so Linux and Windows never read it for this. An empty list (ffmpeg's could not
352
+ be read) proves nothing is missing."""
353
+ vt = _VT_FOR_CPU.get(cpu_encoder or "")
354
+ if vt is None or STATE.hw or STATE.hw_source == "flag" or hw_platform_reason() is not None:
355
+ return None
356
+ have = ffmpeg_encoders()
357
+ return vt if have and cpu_encoder not in have and vt in have else None
358
+
359
+
360
+ def _note_vt_in_place_of(cpu_encoder: str, vt: str) -> None:
361
+ if cpu_encoder not in STATE.hw_auto:
362
+ STATE.hw_auto.append(cpu_encoder)
363
+ note = f"{cpu_encoder} is not in this ffmpeg build, so this encode ran on {vt} (VideoToolbox) instead"
364
+ if note not in STATE.hw_notes:
365
+ STATE.hw_notes.append(note)
366
+
367
+
340
368
  def _maybe_hw(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: bool, cpu: List[str]) -> List[str]:
341
- """`cpu` unchanged unless --hw / $FFMPEG_SKILL_HW is on and VideoToolbox can take `codec`; the
342
- swap is remembered so run() can put the CPU line back if the GPU refuses the job."""
343
- if not STATE.hw:
369
+ """`cpu` unchanged unless --hw / $FFMPEG_SKILL_HW is on and VideoToolbox can take `codec`, or
370
+ unless the build has no CPU encoder for it (_vt_in_place_of); the --hw swap is remembered so
371
+ run() can put the CPU line back if the GPU refuses the job. A build without the CPU encoder
372
+ has no line to put back, so nothing is remembered for it."""
373
+ cpu_encoder = cpu[cpu.index("-c:v") + 1] if "-c:v" in cpu else None
374
+ vt_name = _vt_in_place_of(cpu_encoder)
375
+ if not STATE.hw and vt_name is None:
344
376
  return cpu
345
377
  vt = _vt_args(codec, crf, meta, keep_bt709)
346
378
  if vt is None:
347
379
  return cpu
380
+ if vt_name:
381
+ _note_vt_in_place_of(cpu_encoder or "", vt_name)
382
+ return vt
348
383
  # a copy: a caller that edits the returned line in place (export.py strips -movflags) must
349
384
  # not edit the recorded slice with it, or restate_last_swap() never sees the line as built
350
385
  STATE.hw_swaps.append((list(vt), cpu))
@@ -357,6 +392,8 @@ _CPU_TO_CODEC = {"libx264": "h264", "libx265": "hevc", "prores_ks": "prores"}
357
392
  def hw_preset_video(video: List[str], meta: Optional[Dict[str, Any]], *, bt709: bool) -> List[str]:
358
393
  """export.py's fixed-preset encoder line moved to VideoToolbox (same codec, the preset's -crf
359
394
  mapped to -q:v, its -r kept); unchanged -- with the reason noted -- when that cannot apply.
395
+ Called under --hw, and for a preset whose libx264/libx265 this build lacks (_vt_in_place_of),
396
+ where it is the only encoder there is; otherwise it returns `video` untouched.
360
397
 
361
398
  `bt709`: whether export.py tags this preset's CPU line BT.709 (every fixed preset but prores,
362
399
  whose master keeps the source's tags). The VideoToolbox line writes the format the CPU preset
@@ -367,6 +404,9 @@ def hw_preset_video(video: List[str], meta: Optional[Dict[str, Any]], *, bt709:
367
404
  codec = _CPU_TO_CODEC.get(enc or "")
368
405
  if codec is None:
369
406
  return video
407
+ vt_name = _vt_in_place_of(enc)
408
+ if not STATE.hw and vt_name is None:
409
+ return video
370
410
  crf = int(video[video.index("-crf") + 1]) if "-crf" in video else 18
371
411
  vt = _vt_args(codec, crf, meta if codec == "prores" else None, bt709)
372
412
  if vt is None:
@@ -374,6 +414,9 @@ def hw_preset_video(video: List[str], meta: Optional[Dict[str, Any]], *, bt709:
374
414
  vt = strip_movflags(vt) # export adds it
375
415
  if "-r" in video:
376
416
  vt += ["-r", video[video.index("-r") + 1]]
417
+ if vt_name:
418
+ _note_vt_in_place_of(enc or "", vt_name)
419
+ return vt
377
420
  # the CPU line export would have written: the preset's own options plus the BT.709 tags export
378
421
  # adds to it (only to the CPU line), so a GPU->CPU fallback is tagged exactly like a CPU run
379
422
  STATE.hw_swaps.append((list(vt), list(video) + (bt709_tag_args(enc) if bt709 else [])))
@@ -28,7 +28,7 @@ def die(msg: str, code: int = 1, kind: str = "input", *, ctx: "Optional[Context]
28
28
  ctx.plan = None # a failed run plans nothing (the exit hook must not write a plan for it)
29
29
  STATE.plan = None # the hook falls back to STATE when nothing passed a ctx; a failed run plans nothing there either
30
30
  sys.stderr.write(f"error: {msg}\n" + (f"hint: {hint}\n" if hint else ""))
31
- if kind == "ffmpeg" and "hw" not in extra and (ctx.hw or ctx.hw_stages or ctx.hw_env_ignored):
31
+ if kind == "ffmpeg" and "hw" not in extra and (ctx.hw or ctx.hw_stages or ctx.hw_env_ignored or ctx.hw_auto):
32
32
  # a tool's own ffmpeg failure (cut.py runs run(check=False) and dies itself; render.py
33
33
  # re-raises a stage's) carries `encoder` and `hw` as run()'s _fail() does: under --hw /
34
34
  # $FFMPEG_SKILL_HW two encoders may have run, and a caller must know whose failure it is
@@ -116,6 +116,8 @@ def hw_report(ctx: "Context", enc: Optional[str]) -> Dict[str, Any]:
116
116
  source = ctx.hw_source if ctx.hw else next((st.get("source") for st in stages if st.get("requested")), None)
117
117
  if not requested and ctx.hw_env_ignored:
118
118
  source = "env"
119
+ if not requested and ctx.hw_auto:
120
+ source = "auto" # nobody asked: this build has no libx264/libx265, so VideoToolbox was the only encoder
119
121
  # a stage that reported its source without asking (export.py under the variable: requested
120
122
  # false, source env) still names where the request came from
121
123
  source = source or next((st.get("source") for st in stages if st.get("source")), None)
@@ -128,14 +130,15 @@ def hw_report(ctx: "Context", enc: Optional[str]) -> Dict[str, Any]:
128
130
  # the machine default, not this call, chose the GPU: say what it costs and how to opt out
129
131
  # (once: a stage that ran on it already said so)
130
132
  notes.append(ENV_HW_NOTE)
131
- if ctx.hw_env_ignored and enc not in (None, "copy"):
133
+ if ctx.hw_env_ignored and enc not in (None, "copy") and not vt:
132
134
  notes.append(env_not_for_delivery_note())
133
135
  return {"requested": requested, "source": source, "used": used,
134
136
  "fallback": bool(ctx.hw_fallback) or any(st.get("fallback") for st in stages), "notes": notes}
135
137
 
136
138
 
137
- def hw_quality_note(enc: Optional[str], line: Optional[str]) -> Optional[str]:
138
- """The `notes` line every VideoToolbox encode carries: its quality is not the CRF encode's."""
139
+ def hw_quality_note(enc: Optional[str], line: Optional[str], no_cpu_encoder: bool = False) -> Optional[str]:
140
+ """The `notes` line every VideoToolbox encode carries: its quality is not the CRF encode's.
141
+ `no_cpu_encoder`: the build has no libx264/libx265, so `--no-hw` is no remedy and is not offered."""
139
142
  import re as _re
140
143
  if not enc or not enc.endswith("_videotoolbox"):
141
144
  return None
@@ -147,7 +150,7 @@ def hw_quality_note(enc: Optional[str], line: Optional[str]) -> Optional[str]:
147
150
  return (f"{enc}{' -q:v ' + m.group(1) if m else ''} is not CRF-equivalent: --quality (or the preset's CRF) is mapped "
148
151
  "to -q:v by an approximate SSIM fit (docs/design-decisions.md), so size and detail differ from the x264/x265 "
149
152
  "encode (~1.1-3.5x the bytes at matched SSIM); `verified` covers the output's measured properties, not quality "
150
- "parity with the CPU encode; --no-hw for the CRF encode")
153
+ "parity with the CPU encode" + ("" if no_cpu_encoder else "; --no-hw for the CRF encode"))
151
154
 
152
155
 
153
156
  def _encoder_report(ctx: "Context") -> Dict[str, Any]:
@@ -157,7 +160,7 @@ def _encoder_report(ctx: "Context") -> Dict[str, Any]:
157
160
  $FFMPEG_SKILL_HW, a stage's, or the variable a delivery preset does not take)."""
158
161
  enc, _line = _video_encoders(ctx.commands)
159
162
  out: Dict[str, Any] = {"encoder": enc} if enc else {}
160
- if ctx.hw or ctx.hw_stages or ctx.hw_env_ignored:
163
+ if ctx.hw or ctx.hw_stages or ctx.hw_env_ignored or ctx.hw_auto:
161
164
  out["hw"] = hw_report(ctx, enc)
162
165
  return out
163
166
 
@@ -167,7 +170,8 @@ def _hw_notes_for(ctx: "Context") -> List[str]:
167
170
  and (render.py) every stage that ran on VideoToolbox."""
168
171
  enc, line = _video_encoders(ctx.commands)
169
172
  out: List[str] = []
170
- for note in [hw_quality_note(enc, line)] + [st.get("quality_note") for st in ctx.hw_stages]:
173
+ cpu = {"h264_videotoolbox": "libx264", "hevc_videotoolbox": "libx265"}.get(enc or "")
174
+ for note in [hw_quality_note(enc, line, cpu in ctx.hw_auto)] + [st.get("quality_note") for st in ctx.hw_stages]:
171
175
  if note and note not in out:
172
176
  out.append(note)
173
177
  return out
@@ -186,7 +190,7 @@ def absorb_stage_hw(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> No
186
190
  if n not in ctx.hw_notes and not any(existing.endswith(": " + n) for existing in ctx.hw_notes):
187
191
  ctx.hw_notes.append(f"{stage}: {n}")
188
192
  enc, line = _video_encoders([str(c) for c in (doc.get("commands") or [])])
189
- quality = hw_quality_note(enc, line) if hw.get("used") else None
193
+ quality = hw_quality_note(enc, line, hw.get("source") == "auto") if hw.get("used") else None
190
194
  ctx.hw_stages.append({"stage": stage, "requested": bool(hw.get("requested")), "source": hw.get("source"),
191
195
  "used": hw.get("used"), "fallback": bool(hw.get("fallback")), "quality_note": quality})
192
196
 
@@ -192,7 +192,7 @@ class Context:
192
192
  """
193
193
 
194
194
  __slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
195
- "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages")
195
+ "hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages", "hw_auto")
196
196
 
197
197
  def __init__(self) -> None:
198
198
  self.reset()
@@ -220,6 +220,7 @@ class Context:
220
220
  self.hw_fallback = False # run() re-encoded a job VideoToolbox refused on the CPU (or a stage reported one)
221
221
  self.hw_env_ignored = False # $FFMPEG_SKILL_HW=1 was set but this tool's delivery preset does not take it (export.py)
222
222
  self.hw_stages: List[Dict[str, Any]] = [] # render.py: the `hw` facts each stage (a child process) reported
223
+ self.hw_auto: List[str] = [] # CPU encoders (libx264/libx265) this ffmpeg build lacks, whose encodes ran on VideoToolbox without --hw
223
224
 
224
225
 
225
226
  STATE = Context()
@@ -297,7 +298,7 @@ def apply_common(args: "argparse.Namespace") -> None:
297
298
  if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
298
299
  args.preset = "veryfast"
299
300
  STATE.codec = getattr(args, "codec", None) or None
300
- STATE.hw_notes, STATE.hw_swaps, STATE.hw_fallback, STATE.hw_env_ignored, STATE.hw_stages = [], [], False, False, []
301
+ STATE.hw_notes, STATE.hw_swaps, STATE.hw_fallback, STATE.hw_env_ignored, STATE.hw_stages, STATE.hw_auto = [], [], False, False, [], []
301
302
  flag = hw_flag(args)
302
303
  if flag is not None:
303
304
  STATE.hw, STATE.hw_source = bool(flag), "flag"
@@ -457,7 +458,7 @@ def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Conte
457
458
  ctx = ctx or STATE
458
459
  tail = "\n".join(stderr.strip().splitlines()[-15:])
459
460
  what, extra = str(cmd[0]), {}
460
- enc = video_encoder_of(cmd) if ctx.hw else None
461
+ enc = video_encoder_of(cmd) if ctx.hw or ctx.hw_auto else None
461
462
  if enc:
462
463
  on_gpu = enc.endswith("_videotoolbox")
463
464
  why = videotoolbox_failure(stderr) if on_gpu else None
@@ -1330,9 +1331,12 @@ def ffmpeg_encoders() -> set:
1330
1331
  if _ENCODERS is None:
1331
1332
  _ENCODERS = set()
1332
1333
  tool = "ffprobe" if STATE.dry_run else "ffmpeg"
1334
+ # --timeout bounds this read too: on Apple Silicon every CPU re-encode asks for the list
1335
+ # (#314), so a deadlocked ffmpeg must not outlive the limit the caller set before the encode
1336
+ limit = min(PROBE_TIMEOUT, STATE.timeout) if STATE.timeout else PROBE_TIMEOUT
1333
1337
  try:
1334
1338
  out = subprocess.run([ffmpeg_exe(tool)[0] or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
1335
- stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=PROBE_TIMEOUT).stdout
1339
+ stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=limit).stdout
1336
1340
  _ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
1337
1341
  except (OSError, subprocess.SubprocessError):
1338
1342
  pass
@@ -403,7 +403,7 @@ def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str
403
403
  "dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
404
404
  "probe": {"type": "object", "description": "probe of the output when a file was written"},
405
405
  "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"},
406
- "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"},
406
+ "hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item), on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it, and when this ffmpeg build has no libx264/libx265 and an encode ran on VideoToolbox instead: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export, and for the build without libx264/libx265), source: flag|env|auto (auto: nobody asked, the build has no libx264/libx265 and this Apple Silicon machine ran VideoToolbox in its place), 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"},
407
407
  "plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
408
408
  "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"},
409
409
  "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)"}}
@@ -690,6 +690,24 @@ def _gpu_encoders(encoders_listing: Dict[str, Any]) -> Dict[str, Any]:
690
690
  return {"status": "parsed", "present": present}
691
691
 
692
692
 
693
+ # What the tools do about a build without libx264 / libx265 (_common.decision._vt_in_place_of)
694
+ _VIDEOTOOLBOX_FOR = {"encoder:libx264": "encoder:h264_videotoolbox", "encoder:libx265": "encoder:hevc_videotoolbox"}
695
+
696
+
697
+ def _covered_by_videotoolbox(encoders_listing: Dict[str, Any]) -> Dict[str, str]:
698
+ """`{"encoder:libx264": "encoder:h264_videotoolbox", ...}` for each CPU encoder the (read) listing
699
+ lacks while the matching VideoToolbox encoder is listed and this machine can run it: the tools
700
+ encode with the latter there without --hw, so the former does not block anything. Empty
701
+ otherwise -- an unread listing proves nothing, and a build that has the CPU encoder needs no cover."""
702
+ if encoders_listing["status"] != "parsed":
703
+ return {}
704
+ from _common.runner import hw_platform_reason
705
+ if hw_platform_reason() is not None:
706
+ return {}
707
+ have = set(encoders_listing["names"])
708
+ return {cap: alt for cap, alt in _VIDEOTOOLBOX_FOR.items() if cap[8:] not in have and alt[8:] in have}
709
+
710
+
693
711
  def _version_line(binary: str) -> Optional[str]:
694
712
  exe = _exe(binary)
695
713
  if not exe:
@@ -863,11 +881,19 @@ def doctor() -> Dict[str, Any]:
863
881
  `missing` (a filter that exists is not reported absent) nor into `available` (a failed detection
864
882
  is not a pass). `ok` is true only when nothing required is missing or unknown.
865
883
 
884
+ One exception to "missing blocks": `encoder:libx264` / `encoder:libx265` absent from the build
885
+ while the matching VideoToolbox encoder is present and this machine can run it (Apple Silicon).
886
+ The tools use VideoToolbox there without --hw (#314), so the capability is neither `available`
887
+ (it is not in the build) nor `missing` (nothing is blocked): it is left out of both lists, named
888
+ in `covered_by`, and a tool that needs it stays usable. A build that has libx264 reports exactly
889
+ what it did before.
890
+
866
891
  `gpu_encoders` is a separate, honest answer to a question none of the required/optional
867
892
  capabilities above ask: which GPU-backed encoders (nvenc, videotoolbox, qsv, vaapi, amf) this
868
893
  ffmpeg BUILD carries, from `-encoders` alone. No tool requires one -- every tool runs on CPU
869
894
  x264/x265 unless `--hw` / FFMPEG_SKILL_HW=1 puts it on VideoToolbox (Apple Silicon only) -- so
870
- `gpu_encoders` never affects `ok` or any tool's `usable`. It
895
+ `gpu_encoders` never affects `ok` or any tool's `usable` (`covered_by` is the one place a
896
+ VideoToolbox encoder counts: for a libx264/libx265 the build lacks, see above). It
871
897
  only proves the build shipped the capability, never that the GPU/driver on this machine will
872
898
  actually accept a job (that needs a real encode, which this introspection never runs).
873
899
 
@@ -886,6 +912,7 @@ def doctor() -> Dict[str, Any]:
886
912
  sets = {k: set(v["names"]) for k, v in listings.items()}
887
913
  state: Dict[str, str] = {} # capability -> available | missing | unknown
888
914
  wanted = required_capabilities()
915
+ covered = _covered_by_videotoolbox(listings["encoders"])
889
916
  drawtext_probe: Optional[Dict[str, Any]] = None
890
917
  drawtext_crashed = False # listed but crashed (#100), as opposed to absent from the build
891
918
 
@@ -935,14 +962,16 @@ def doctor() -> Dict[str, Any]:
935
962
  else:
936
963
  state[cap] = "missing"
937
964
  available = sorted(c for c, st in state.items() if st == "available")
938
- missing_required = sorted(c for c in wanted["required"] if state[c] == "missing")
939
- missing_optional = sorted(c for c in wanted["optional"] if state[c] == "missing")
965
+ missing_required = sorted(c for c in wanted["required"] if state[c] == "missing" and c not in covered)
966
+ missing_optional = sorted(c for c in wanted["optional"] if state[c] == "missing" and c not in covered)
940
967
  unknown = sorted(c for c, st in state.items() if st == "unknown")
941
968
  unknown_required = [c for c in unknown if c in wanted["required"]]
942
969
  errors = [f"{k}: {v['detail']}" for k, v in listings.items() if v["status"] in ("unparsed", "failed")]
943
970
  if drawtext_probe is not None and drawtext_probe["status"] != "available":
944
971
  errors.append(f"filter:drawtext: {drawtext_probe['detail']}")
945
972
  notes = [f"filter:drawtext: {drawtext_probe['note']}"] if drawtext_probe and drawtext_probe.get("note") else []
973
+ notes += [f"{cap}: not in this ffmpeg build; the tools that need it encode with {alt[8:]} (VideoToolbox) instead, so their output is "
974
+ "not CRF-equivalent (the result's `encoder`/`hw` say so)" for cap, alt in sorted(covered.items())]
946
975
  return {
947
976
  "version": skill_version(),
948
977
  "python": ".".join(str(x) for x in sys.version_info[:3]),
@@ -957,7 +986,8 @@ def doctor() -> Dict[str, Any]:
957
986
  "errors": errors,
958
987
  **({"notes": notes} if notes else {}),
959
988
  "ok": not missing_required and not unknown_required,
960
- "tools": _tool_usability(state, drawtext_crashed),
989
+ "tools": _tool_usability(state, drawtext_crashed, covered),
990
+ "covered_by": covered,
961
991
  "gpu_encoders": _gpu_encoders(listings["encoders"]),
962
992
  "hw": _hw_default(),
963
993
  "fonts": _fonts_capability(probe=True),
@@ -1079,15 +1109,17 @@ def _capability_fix_hint(cap: str, drawtext_crashed: bool = False) -> str:
1079
1109
  return f"'{cap}' is not available; see docs/contract.md"
1080
1110
 
1081
1111
 
1082
- def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False) -> Dict[str, Dict[str, Any]]:
1112
+ def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False, covered: Optional[Dict[str, str]] = None) -> Dict[str, Dict[str, Any]]:
1083
1113
  """Per-tool usable/missing/unknown, folded from the same capability `state` doctor already
1084
1114
  computed. Answers "can I run this tool on this machine today", not just "what capabilities
1085
1115
  exist" -- a caller reading only `available`/`missing` still has to cross-reference each tool's
1086
- own required-capability list by hand to answer that."""
1116
+ own required-capability list by hand to answer that. A required capability in `covered`
1117
+ (libx264 replaced by VideoToolbox on this machine) blocks nothing; the tool lists it in `covered_by`."""
1118
+ covered = covered or {}
1087
1119
  tools: Dict[str, Dict[str, Any]] = {}
1088
1120
  for name, meta in TOOL_META.items():
1089
1121
  required = list(meta["required"])
1090
- missing = [c for c in required if state.get(c) == "missing"]
1122
+ missing = [c for c in required if state.get(c) == "missing" and c not in covered]
1091
1123
  unknown = [c for c in required if state.get(c) == "unknown"]
1092
1124
  entry: Dict[str, Any] = {"usable": "no" if missing else ("unknown" if unknown else "yes")}
1093
1125
  if missing:
@@ -1095,6 +1127,9 @@ def _tool_usability(state: Dict[str, str], drawtext_crashed: bool = False) -> Di
1095
1127
  entry["fix"] = "; ".join(dict.fromkeys(_capability_fix_hint(c, drawtext_crashed) for c in missing))
1096
1128
  if unknown:
1097
1129
  entry["unknown"] = unknown
1130
+ via = {c: covered[c] for c in required if c in covered}
1131
+ if via:
1132
+ entry["covered_by"] = via
1098
1133
  tools[name] = entry
1099
1134
  return tools
1100
1135
 
@@ -1547,6 +1582,8 @@ def main() -> int:
1547
1582
  print(f"missing optional: {', '.join(d['missing_optional']) or 'none'}")
1548
1583
  if d["unknown"]:
1549
1584
  print(f"unknown (detection failed, not proven missing): {', '.join(d['unknown'])}")
1585
+ for cap, alt in sorted(d["covered_by"].items()):
1586
+ print(f"{cap} is not in this build; VideoToolbox ({alt[8:]}) encodes in its place (not CRF-equivalent)")
1550
1587
  not_usable = sorted(name for name, t in d["tools"].items() if t["usable"] != "yes")
1551
1588
  if not_usable and d["ok"]:
1552
1589
  print(f"note: overall 'ok' means nothing REQUIRED BY EVERY TOOL is missing -- {len(not_usable)} tool(s) still can't run today: {', '.join(not_usable)} (see doctor --json .tools for why)")
package/scripts/export.py CHANGED
@@ -202,9 +202,11 @@ def main() -> int:
202
202
  video[video.index("-preset") + 1] = "veryfast"
203
203
  # a fixed preset's line is tagged BT.709 (prores, an editing master, keeps the source's tags)
204
204
  tag_bt709 = args.preset not in ("prores",) and not p.get("codec")
205
- if STATE.hw and not p.get("codec") and video:
205
+ if not p.get("codec") and video:
206
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
207
+ # its CRF mapped to -q:v, the preset's frame rate kept; tags come with the VT line itself.
208
+ # A build without libx264/libx265 has no other encoder, so it goes there without --hw too
209
+ # (hw_preset_video returns the line untouched in every other case)
208
210
  video = hw_preset_video(video, meta, bt709=tag_bt709)
209
211
  cmd += video
210
212
  if args.preset != "copy":