ffmpeg-skill 2.5.1 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -4
- package/SKILL.md +5 -5
- package/docs/contract.md +68 -13
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +44 -10
- package/scripts/_common/asr.py +747 -94
- package/scripts/_common/decision.py +125 -2
- package/scripts/_common/emit.py +145 -1
- package/scripts/_common/runner.py +258 -13
- package/scripts/_contract.py +73 -12
- package/scripts/batch.py +30 -1
- package/scripts/caption.py +22 -6
- package/scripts/export.py +10 -1
- package/scripts/render.py +23 -1
- package/scripts/silence.py +21 -6
- package/scripts/waveform.py +19 -8
|
@@ -16,7 +16,7 @@ from pathlib import Path
|
|
|
16
16
|
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
17
17
|
from _common.color import bt709_tag_args, _sdr_bt709
|
|
18
18
|
from _common.emit import die
|
|
19
|
-
from _common.runner import CODECS, STATE, ffmpeg_encoders
|
|
19
|
+
from _common.runner import CODECS, STATE, ffmpeg_encoders, ffmpeg_version, hw_platform_reason
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def pad_filters(out_w: int, out_h: int, fill: str, color: str, blur: int, darken: float = 0.0) -> str:
|
|
@@ -277,7 +277,126 @@ def cfr_args(meta: Optional[Dict[str, Any]], fps: Optional[float] = None) -> Lis
|
|
|
277
277
|
return ["-fps_mode", "cfr", "-r", f"{rate:g}"]
|
|
278
278
|
|
|
279
279
|
|
|
280
|
+
# ------------------------------------------------------- VideoToolbox (--hw)
|
|
281
|
+
#
|
|
282
|
+
# CRF -> VideoToolbox -q:v (1-100, higher = better): at each CRF the highest -q:v whose SSIM matched
|
|
283
|
+
# the CPU line's (x264/x265 medium) across the clips of tests/bench_vt.py -- synthetic CG, fractal
|
|
284
|
+
# and grain plus iPhone 17 Pro 1080p (M3, FFmpeg 9.0.2), so a GPU encode never measures below the
|
|
285
|
+
# CPU one. HDR has its own curve: its CPU line is x265 Main10 at CRF+2, and the Main10 VideoToolbox
|
|
286
|
+
# line needs ~11-15 less -q:v for that SSIM (the SDR curve wrote HLG phone footage at 6-8x x265's
|
|
287
|
+
# bytes). Matched quality costs 1.1-2.9x the bytes of x264/x265 on SDR and 1.9-3.5x on HDR --
|
|
288
|
+
# which is why --hw is opt-in and delivery presets stay on the CPU (docs/design-decisions.md).
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def vt_quality(codec: str, crf: int, hdr: bool = False) -> int:
|
|
292
|
+
base, slope = (63.0, 1.6) if hdr else (75.0, 2.2) if codec == "h264" else (78.0, 2.0)
|
|
293
|
+
return max(1, min(100, int(round(base - slope * (crf - 18)))))
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _vt_bt709(codec: str) -> List[str]:
|
|
297
|
+
"""BT.709 tags for a VideoToolbox SDR encode. From FFmpeg 7.1 the -colorspace output options
|
|
298
|
+
drive a real matrix conversion on an untagged source (see bt709_tag_args), and VideoToolbox has
|
|
299
|
+
no -x264-params VUI route; a bitstream filter writes the VUI after encoding, which the filter
|
|
300
|
+
graph never sees (measured: 49.9 dB PSNR tag-neutral vs 23.8 dB through the output options).
|
|
301
|
+
It is used on every version, not only >= 7.1: it is tag-only everywhere, and a git build of
|
|
302
|
+
7.1 reads as (7, 0) from its libavutil major, which would otherwise take the converting path."""
|
|
303
|
+
return ["-bsf:v", f"{codec}_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1"]
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _vt_args(codec: str, crf: int, meta: Optional[Dict[str, Any]], keep_bt709: bool) -> Optional[List[str]]:
|
|
307
|
+
"""VideoToolbox encoder options for `codec`, or None (with a note in STATE.hw_notes) when the
|
|
308
|
+
request stays on the CPU: no VideoToolbox encoder for the codec, this machine cannot, or the
|
|
309
|
+
ffmpeg build lacks it."""
|
|
310
|
+
name = {"h264": "h264_videotoolbox", "hevc": "hevc_videotoolbox", "prores": "prores_videotoolbox"}.get(codec)
|
|
311
|
+
reason = None if name else f"no VideoToolbox encoder for {codec}"
|
|
312
|
+
reason = reason or hw_platform_reason()
|
|
313
|
+
if not reason and name not in ffmpeg_encoders():
|
|
314
|
+
reason = f"this ffmpeg build has no {name}"
|
|
315
|
+
if reason:
|
|
316
|
+
note = f"{reason}; encoded on the CPU"
|
|
317
|
+
if note not in STATE.hw_notes:
|
|
318
|
+
STATE.hw_notes.append(note)
|
|
319
|
+
return None
|
|
320
|
+
v = (meta or {}).get("video") or {}
|
|
321
|
+
hdr = bool(v.get("bt2020_or_hdr"))
|
|
322
|
+
hdr_tags = ["-colorspace", v.get("color_space") or "bt2020nc", "-color_primaries", v.get("color_primaries") or "bt2020",
|
|
323
|
+
"-color_trc", v.get("color_transfer") or "arib-std-b67"]
|
|
324
|
+
if codec == "prores":
|
|
325
|
+
return ["-c:v", name, "-profile:v", "hq", "-pix_fmt", "p210le"] + (hdr_tags if hdr else [])
|
|
326
|
+
# The HDR curve was fitted on the Main10 HEVC line; an 8-bit H.264 line (an export preset on
|
|
327
|
+
# an HDR source) replaces x264 at the same CRF and keeps the H.264 curve.
|
|
328
|
+
q = str(vt_quality(codec, crf, hdr and codec == "hevc"))
|
|
329
|
+
if codec == "h264":
|
|
330
|
+
return ["-c:v", name, "-q:v", q, "-profile:v", "high", "-pix_fmt", "yuv420p", "-movflags", "+faststart"] + \
|
|
331
|
+
(_vt_bt709("h264") if keep_bt709 else [])
|
|
332
|
+
if hdr:
|
|
333
|
+
# Measured on FFmpeg 9.0: HDR10 mastering-display and content-light side data carry through
|
|
334
|
+
# hevc_videotoolbox exactly as through libx265 (test_hw_hdr10_side_data_survives).
|
|
335
|
+
return ["-c:v", name, "-q:v", q, "-profile:v", "main10", "-pix_fmt", "p010le", "-tag:v", "hvc1"] + hdr_tags + ["-movflags", "+faststart"]
|
|
336
|
+
return ["-c:v", name, "-q:v", q, "-pix_fmt", "yuv420p", "-tag:v", "hvc1", "-movflags", "+faststart"] + \
|
|
337
|
+
(_vt_bt709("hevc") if keep_bt709 else [])
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
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:
|
|
344
|
+
return cpu
|
|
345
|
+
vt = _vt_args(codec, crf, meta, keep_bt709)
|
|
346
|
+
if vt is None:
|
|
347
|
+
return cpu
|
|
348
|
+
# a copy: a caller that edits the returned line in place (export.py strips -movflags) must
|
|
349
|
+
# not edit the recorded slice with it, or restate_last_swap() never sees the line as built
|
|
350
|
+
STATE.hw_swaps.append((list(vt), cpu))
|
|
351
|
+
return vt
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
_CPU_TO_CODEC = {"libx264": "h264", "libx265": "hevc", "prores_ks": "prores"}
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def hw_preset_video(video: List[str], meta: Optional[Dict[str, Any]], *, bt709: bool) -> List[str]:
|
|
358
|
+
"""export.py's fixed-preset encoder line moved to VideoToolbox (same codec, the preset's -crf
|
|
359
|
+
mapped to -q:v, its -r kept); unchanged -- with the reason noted -- when that cannot apply.
|
|
360
|
+
|
|
361
|
+
`bt709`: whether export.py tags this preset's CPU line BT.709 (every fixed preset but prores,
|
|
362
|
+
whose master keeps the source's tags). The VideoToolbox line writes the format the CPU preset
|
|
363
|
+
writes: an h264/hevc preset is 8-bit with BT.709 tags whatever the source (on an HDR source it
|
|
364
|
+
says so in a note, as without --hw), so it is built as for an SDR source -- not the Main10 HDR
|
|
365
|
+
line, which would make the GPU file and its CPU fallback different formats."""
|
|
366
|
+
enc = video[video.index("-c:v") + 1] if "-c:v" in video else None
|
|
367
|
+
codec = _CPU_TO_CODEC.get(enc or "")
|
|
368
|
+
if codec is None:
|
|
369
|
+
return video
|
|
370
|
+
crf = int(video[video.index("-crf") + 1]) if "-crf" in video else 18
|
|
371
|
+
vt = _vt_args(codec, crf, meta if codec == "prores" else None, bt709)
|
|
372
|
+
if vt is None:
|
|
373
|
+
return video
|
|
374
|
+
vt = strip_movflags(vt) # export adds it
|
|
375
|
+
if "-r" in video:
|
|
376
|
+
vt += ["-r", video[video.index("-r") + 1]]
|
|
377
|
+
# the CPU line export would have written: the preset's own options plus the BT.709 tags export
|
|
378
|
+
# adds to it (only to the CPU line), so a GPU->CPU fallback is tagged exactly like a CPU run
|
|
379
|
+
STATE.hw_swaps.append((list(vt), list(video) + (bt709_tag_args(enc) if bt709 else [])))
|
|
380
|
+
return vt
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def strip_movflags(args: List[str]) -> List[str]:
|
|
384
|
+
return [a for i, a in enumerate(args) if not (a == "-movflags" or (i and args[i - 1] == "-movflags"))]
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def restate_last_swap(before: List[str], after: List[str]) -> None:
|
|
388
|
+
"""A caller that edits an encoder line after encoder_args() returned it (export.py strips
|
|
389
|
+
-movflags) restates the recorded swap, so run()'s GPU->CPU fallback still finds the slice."""
|
|
390
|
+
if STATE.hw_swaps and STATE.hw_swaps[-1][0] == before:
|
|
391
|
+
STATE.hw_swaps[-1] = (list(after), strip_movflags(STATE.hw_swaps[-1][1]))
|
|
392
|
+
|
|
393
|
+
|
|
280
394
|
def encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
|
|
395
|
+
"""encoder options for `codec` (_cpu_encoder_args), on VideoToolbox when --hw asks and it can."""
|
|
396
|
+
return _maybe_hw(codec, crf, meta, keep_bt709, _cpu_encoder_args(codec, crf, preset, meta, keep_bt709))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _cpu_encoder_args(codec: str, crf: int, preset: str, meta: Optional[Dict[str, Any]] = None, keep_bt709: bool = True) -> List[str]:
|
|
281
400
|
"""The one place that turns (--codec, --quality, --preset, source) into encoder options.
|
|
282
401
|
|
|
283
402
|
h264 -> x264 8-bit BT.709 (refuses HDR: 8-bit H.264 cannot carry it); hevc -> x265, Main10
|
|
@@ -340,7 +459,7 @@ def x264_args(crf: int = 18, preset: str = "medium", keep_bt709: bool = True) ->
|
|
|
340
459
|
(color.py's --to-sdr path builds its own H.264 line; the flag still has to reach it)."""
|
|
341
460
|
if STATE.codec and STATE.codec != "h264":
|
|
342
461
|
return encoder_args(STATE.codec, crf, preset, None, keep_bt709)
|
|
343
|
-
return _x264_raw(crf, preset, keep_bt709)
|
|
462
|
+
return _maybe_hw("h264", crf, None, keep_bt709, _x264_raw(crf, preset, keep_bt709))
|
|
344
463
|
|
|
345
464
|
|
|
346
465
|
def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "medium") -> List[str]:
|
|
@@ -356,6 +475,10 @@ def video_args(meta: Optional[Dict[str, Any]], crf: int = 18, preset: str = "med
|
|
|
356
475
|
v = (meta or {}).get("video") or {}
|
|
357
476
|
if not v.get("bt2020_or_hdr"):
|
|
358
477
|
return x264_args(crf, preset)
|
|
478
|
+
return _maybe_hw("hevc", crf, meta, True, _x265_hdr_default(v, crf, preset))
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _x265_hdr_default(v: Dict[str, Any], crf: int, preset: str) -> List[str]:
|
|
359
482
|
cs = v.get("color_space") or "bt2020nc"
|
|
360
483
|
prim = v.get("color_primaries") or "bt2020"
|
|
361
484
|
trc = v.get("color_transfer") or "arib-std-b67"
|
package/scripts/_common/emit.py
CHANGED
|
@@ -9,7 +9,7 @@ import json
|
|
|
9
9
|
import math
|
|
10
10
|
import os
|
|
11
11
|
import sys
|
|
12
|
-
from typing import Any, Dict, List, Optional, Sequence
|
|
12
|
+
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
|
13
13
|
from _common.runner import Context, ERROR_CODE, ERROR_RETRYABLE, STATE
|
|
14
14
|
|
|
15
15
|
|
|
@@ -28,6 +28,14 @@ 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):
|
|
32
|
+
# a tool's own ffmpeg failure (cut.py runs run(check=False) and dies itself; render.py
|
|
33
|
+
# re-raises a stage's) carries `encoder` and `hw` as run()'s _fail() does: under --hw /
|
|
34
|
+
# $FFMPEG_SKILL_HW two encoders may have run, and a caller must know whose failure it is
|
|
35
|
+
enc = extra.get("encoder") or _video_encoders(ctx.commands)[0]
|
|
36
|
+
if enc and "encoder" not in extra:
|
|
37
|
+
extra["encoder"] = enc
|
|
38
|
+
extra["hw"] = hw_report(ctx, enc)
|
|
31
39
|
if ctx.json:
|
|
32
40
|
doc: Dict[str, Any] = {
|
|
33
41
|
"status": "failed", "exit_code": code,
|
|
@@ -63,6 +71,138 @@ def _set_current_ctx(ctx: "Context") -> None:
|
|
|
63
71
|
_CURRENT_CTX = ctx
|
|
64
72
|
|
|
65
73
|
|
|
74
|
+
ENV_HW_NOTE = ("VideoToolbox chosen by FFMPEG_SKILL_HW=1 (machine default; ~1.1-3.5x the bytes of x264/x265 "
|
|
75
|
+
"at matched quality); rerun with --no-hw for a final deliverable")
|
|
76
|
+
ENV_NOT_FOR_DELIVERY_NOTE = ("FFMPEG_SKILL_HW=1 is not applied to export.py's delivery presets (a delivered file is where "
|
|
77
|
+
"VideoToolbox's larger files cost most); pass --hw to put this export on the GPU")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _video_encoders(commands: Sequence[str]) -> "Tuple[Optional[str], Optional[str]]":
|
|
81
|
+
"""(the last video encoder the command lines name, or `copy` for a stream copy; that encoder's
|
|
82
|
+
command line). A later copy (loudness.py after export's encode) keeps the encode before it."""
|
|
83
|
+
import re as _re
|
|
84
|
+
enc, line, copied = None, None, False
|
|
85
|
+
for c in commands:
|
|
86
|
+
for name in _re.findall(r"(?:^|\s)-(?:c:v|vcodec|codec:v)\s+(\S+)", c):
|
|
87
|
+
if name == "copy":
|
|
88
|
+
copied = True
|
|
89
|
+
else:
|
|
90
|
+
enc, line = name, c
|
|
91
|
+
if _re.search(r"(?:^|\s)-(?:c|codec)\s+copy(?:\s|$)", c):
|
|
92
|
+
copied = True
|
|
93
|
+
if enc is None and copied:
|
|
94
|
+
return "copy", None
|
|
95
|
+
return enc, line
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def hw_report(ctx: "Context", enc: Optional[str]) -> Dict[str, Any]:
|
|
99
|
+
"""The `hw` object for a run whose last video encoder was `enc` (None: this process recorded
|
|
100
|
+
no encode -- batch.py, whose stages are child processes). `requested`: VideoToolbox was asked
|
|
101
|
+
for this run's encodes (by --hw, by $FFMPEG_SKILL_HW where it applies, or for a stage);
|
|
102
|
+
`source`: flag|env; `used`: the encoder that ran last is VideoToolbox; `fallback`: a job
|
|
103
|
+
VideoToolbox refused was re-encoded on the CPU, here or in a stage; `notes`: why. Under
|
|
104
|
+
--dry-run `used` is null and `fallback` false: nothing ran."""
|
|
105
|
+
stages = list(ctx.hw_stages)
|
|
106
|
+
requested = bool(ctx.hw) or any(st.get("requested") for st in stages)
|
|
107
|
+
source = ctx.hw_source if ctx.hw else next((st.get("source") for st in stages if st.get("requested")), None)
|
|
108
|
+
if not requested and ctx.hw_env_ignored:
|
|
109
|
+
source = "env"
|
|
110
|
+
# a stage that reported its source without asking (export.py under the variable: requested
|
|
111
|
+
# false, source env) still names where the request came from
|
|
112
|
+
source = source or next((st.get("source") for st in stages if st.get("source")), None)
|
|
113
|
+
vt = enc is not None and enc.endswith("_videotoolbox")
|
|
114
|
+
# `used` is what ran; a dry run ran nothing (the planned encoder is `encoder`), so null --
|
|
115
|
+
# as waveform.py's `silent` is null when nothing was measured
|
|
116
|
+
used = None if enc is None or ctx.dry_run else vt
|
|
117
|
+
notes = list(ctx.hw_notes)
|
|
118
|
+
if vt and source == "env" and not any(n.endswith(ENV_HW_NOTE) for n in notes):
|
|
119
|
+
# the machine default, not this call, chose the GPU: say what it costs and how to opt out
|
|
120
|
+
# (once: a stage that ran on it already said so)
|
|
121
|
+
notes.append(ENV_HW_NOTE)
|
|
122
|
+
if ctx.hw_env_ignored and enc not in (None, "copy"):
|
|
123
|
+
notes.append(ENV_NOT_FOR_DELIVERY_NOTE)
|
|
124
|
+
return {"requested": requested, "source": source, "used": used,
|
|
125
|
+
"fallback": bool(ctx.hw_fallback) or any(st.get("fallback") for st in stages), "notes": notes}
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def hw_quality_note(enc: Optional[str], line: Optional[str]) -> Optional[str]:
|
|
129
|
+
"""The `notes` line every VideoToolbox encode carries: its quality is not the CRF encode's."""
|
|
130
|
+
import re as _re
|
|
131
|
+
if not enc or not enc.endswith("_videotoolbox"):
|
|
132
|
+
return None
|
|
133
|
+
if enc == "prores_videotoolbox":
|
|
134
|
+
return ("prores_videotoolbox is not prores_ks: the same 422 HQ profile from a different encoder, so size and detail "
|
|
135
|
+
"differ from the CPU encode; `verified` covers the output's measured properties, not parity with the CPU "
|
|
136
|
+
"encode; --no-hw for prores_ks")
|
|
137
|
+
m = _re.search(r"(?:^|\s)-q:v\s+(\d+)", line or "")
|
|
138
|
+
return (f"{enc}{' -q:v ' + m.group(1) if m else ''} is not CRF-equivalent: --quality (or the preset's CRF) is mapped "
|
|
139
|
+
"to -q:v by an approximate SSIM fit (docs/design-decisions.md), so size and detail differ from the x264/x265 "
|
|
140
|
+
"encode (~1.1-3.5x the bytes at matched SSIM); `verified` covers the output's measured properties, not quality "
|
|
141
|
+
"parity with the CPU encode; --no-hw for the CRF encode")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _encoder_report(ctx: "Context") -> Dict[str, Any]:
|
|
145
|
+
"""`encoder`: the last video encoder this run's ffmpeg commands name (after any GPU->CPU
|
|
146
|
+
fallback, since run() rewrites the recorded command), `copy` for a stream copy; absent when no
|
|
147
|
+
command encoded video. `hw` (hw_report): present whenever VideoToolbox was asked for (--hw or
|
|
148
|
+
$FFMPEG_SKILL_HW, a stage's, or the variable a delivery preset does not take)."""
|
|
149
|
+
enc, _line = _video_encoders(ctx.commands)
|
|
150
|
+
out: Dict[str, Any] = {"encoder": enc} if enc else {}
|
|
151
|
+
if ctx.hw or ctx.hw_stages or ctx.hw_env_ignored:
|
|
152
|
+
out["hw"] = hw_report(ctx, enc)
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _hw_notes_for(ctx: "Context") -> List[str]:
|
|
157
|
+
"""Top-level `notes` lines for the GPU encodes this result stands on: this tool's last encode,
|
|
158
|
+
and (render.py) every stage that ran on VideoToolbox."""
|
|
159
|
+
enc, line = _video_encoders(ctx.commands)
|
|
160
|
+
out: List[str] = []
|
|
161
|
+
for note in [hw_quality_note(enc, line)] + [st.get("quality_note") for st in ctx.hw_stages]:
|
|
162
|
+
if note and note not in out:
|
|
163
|
+
out.append(note)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def absorb_stage_hw(stage: str, doc: Any, ctx: "Optional[Context]" = None) -> None:
|
|
168
|
+
"""render.py runs each stage as a child process: carry the stage's `hw` facts (fell back, and
|
|
169
|
+
why; ran on VideoToolbox, with its quality note) into this result, which records only the
|
|
170
|
+
command lines."""
|
|
171
|
+
ctx = ctx or STATE
|
|
172
|
+
hw = doc.get("hw") if isinstance(doc, dict) else None
|
|
173
|
+
if not isinstance(hw, dict):
|
|
174
|
+
return
|
|
175
|
+
for n in hw.get("notes") or []:
|
|
176
|
+
# once: this process may have said it already (waveform.py's own encode), or another stage
|
|
177
|
+
if n not in ctx.hw_notes and not any(existing.endswith(": " + n) for existing in ctx.hw_notes):
|
|
178
|
+
ctx.hw_notes.append(f"{stage}: {n}")
|
|
179
|
+
enc, line = _video_encoders([str(c) for c in (doc.get("commands") or [])])
|
|
180
|
+
quality = hw_quality_note(enc, line) if hw.get("used") else None
|
|
181
|
+
ctx.hw_stages.append({"stage": stage, "requested": bool(hw.get("requested")), "source": hw.get("source"),
|
|
182
|
+
"used": hw.get("used"), "fallback": bool(hw.get("fallback")), "quality_note": quality})
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def steps_encoder_report(steps: Sequence["Tuple[str, Any]"]) -> Dict[str, Any]:
|
|
186
|
+
"""batch.py's per-item `encoder`, `hw` and `notes`, from the --json documents of the steps
|
|
187
|
+
(child processes) that made the item: the last encoder a step ran (a later stream copy keeps
|
|
188
|
+
the encode before it), and their GPU facts as render.py's result carries its stages'."""
|
|
189
|
+
ctx = Context()
|
|
190
|
+
ctx.dry_run = STATE.dry_run
|
|
191
|
+
enc: Optional[str] = None
|
|
192
|
+
for name, doc in steps:
|
|
193
|
+
step_enc = doc.get("encoder") if isinstance(doc, dict) else None
|
|
194
|
+
if step_enc and (step_enc != "copy" or enc is None):
|
|
195
|
+
enc = step_enc
|
|
196
|
+
absorb_stage_hw(name, doc, ctx)
|
|
197
|
+
out: Dict[str, Any] = {"encoder": enc} if enc else {}
|
|
198
|
+
if ctx.hw_stages:
|
|
199
|
+
out["hw"] = hw_report(ctx, enc)
|
|
200
|
+
quality = [st["quality_note"] for st in ctx.hw_stages if st.get("quality_note")]
|
|
201
|
+
if quality:
|
|
202
|
+
out["notes"] = list(dict.fromkeys(quality))
|
|
203
|
+
return out
|
|
204
|
+
|
|
205
|
+
|
|
66
206
|
def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any) -> None:
|
|
67
207
|
"""Final stdout line: the output path, or a JSON document with --json.
|
|
68
208
|
|
|
@@ -88,7 +228,11 @@ def emit(output: Optional[str], *, ctx: "Optional[Context]" = None, **extra: Any
|
|
|
88
228
|
steps.insert(0, {"step": "exists", "ok": True})
|
|
89
229
|
doc["verified"] = not ctx.dry_run and bool(steps) and all(s.get("ok") for s in steps)
|
|
90
230
|
doc["verification"] = steps
|
|
231
|
+
doc.update(_encoder_report(ctx))
|
|
91
232
|
doc.update(extra)
|
|
233
|
+
hw_notes = [n for n in _hw_notes_for(ctx) if n not in (doc.get("notes") or [])]
|
|
234
|
+
if hw_notes:
|
|
235
|
+
doc["notes"] = list(doc.get("notes") or []) + hw_notes
|
|
92
236
|
if ctx.plan:
|
|
93
237
|
doc["plan"] = write_plan(ctx.plan, output, extra, ctx=ctx)
|
|
94
238
|
print_json(_brief(doc, meta) if ctx.json_brief else doc)
|