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 +1 -1
- package/docs/contract.md +26 -10
- package/package.json +1 -1
- package/references/gotchas.md +3 -1
- package/scripts/_common/decision.py +46 -3
- package/scripts/_common/emit.py +12 -8
- package/scripts/_common/runner.py +8 -4
- package/scripts/_contract.py +45 -8
- package/scripts/export.py +4 -2
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
|
|
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.
|
|
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.
|
|
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
|
|
232
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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.
|
|
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",
|
package/references/gotchas.md
CHANGED
|
@@ -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
|
|
342
|
-
|
|
343
|
-
if
|
|
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 [])))
|
package/scripts/_common/emit.py
CHANGED
|
@@ -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
|
-
|
|
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=
|
|
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
|
package/scripts/_contract.py
CHANGED
|
@@ -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)
|
|
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
|
|
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
|
|
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":
|