ffmpeg-skill 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/SKILL.md +3 -3
- package/docs/contract.md +30 -8
- package/package.json +1 -1
- package/references/gotchas.md +7 -1
- package/references/scripts.md +1 -1
- package/scripts/_common/__init__.py +2 -2
- package/scripts/_common/decision.py +125 -2
- package/scripts/_common/drawtext.py +14 -3
- package/scripts/_common/emit.py +145 -1
- package/scripts/_common/runner.py +258 -13
- package/scripts/_common/text.py +1 -1
- package/scripts/_contract.py +30 -5
- package/scripts/batch.py +30 -1
- package/scripts/export.py +10 -1
- package/scripts/graphics.py +6 -6
- package/scripts/render.py +23 -1
- package/scripts/waveform.py +19 -8
|
@@ -155,7 +155,8 @@ class Context:
|
|
|
155
155
|
makes it obvious what run()/emit() depend on and lets tests reset it with ``STATE.reset()``.
|
|
156
156
|
"""
|
|
157
157
|
|
|
158
|
-
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec"
|
|
158
|
+
__slots__ = ("dry_run", "json", "json_brief", "progress", "fast", "duration_hint", "commands", "timeout", "overwrite", "written", "preexisting", "plan", "plan_written", "plan_inputs", "codec",
|
|
159
|
+
"hw", "hw_source", "hw_notes", "hw_swaps", "hw_fallback", "hw_env_ignored", "hw_stages")
|
|
159
160
|
|
|
160
161
|
def __init__(self) -> None:
|
|
161
162
|
self.reset()
|
|
@@ -176,6 +177,13 @@ class Context:
|
|
|
176
177
|
self.plan_written = False # write_plan() ran (emit or the exit hook), so the hook does not write twice
|
|
177
178
|
self.plan_inputs: List[str] = [] # side inputs (srt/ass/lut/font files) a tool named through escape_filter_path
|
|
178
179
|
self.codec: Optional[str] = None # --codec: encoder for the re-encode (None = x264 for SDR, x265 for HDR)
|
|
180
|
+
self.hw = False # --hw / $FFMPEG_SKILL_HW: VideoToolbox encoders where they apply
|
|
181
|
+
self.hw_source: Optional[str] = None # "flag" (--hw/--no-hw given) or "env" (FFMPEG_SKILL_HW), None = off by default
|
|
182
|
+
self.hw_notes: List[str] = [] # why an encode stayed on (or fell back to) the CPU, for the result document
|
|
183
|
+
self.hw_swaps: List[Tuple[List[str], List[str]]] = [] # (VideoToolbox args, the CPU args they replaced) for run()'s fallback
|
|
184
|
+
self.hw_fallback = False # run() re-encoded a job VideoToolbox refused on the CPU (or a stage reported one)
|
|
185
|
+
self.hw_env_ignored = False # $FFMPEG_SKILL_HW=1 was set but this tool's delivery preset does not take it (export.py)
|
|
186
|
+
self.hw_stages: List[Dict[str, Any]] = [] # render.py: the `hw` facts each stage (a child process) reported
|
|
179
187
|
|
|
180
188
|
|
|
181
189
|
STATE = Context()
|
|
@@ -206,6 +214,31 @@ def add_common(ap: "argparse.ArgumentParser", codec: bool = True) -> None:
|
|
|
206
214
|
help="video encoder for the re-encode: h264 (x264, the default for SDR), hevc (x265, the default for HDR), av1 (SVT-AV1 or libaom), prores (422 HQ, needs a .mov/.mkv output); HDR sources keep their colour on hevc/av1/prores")
|
|
207
215
|
g.add_argument("--quality", type=int, default=ap._defaults["crf"], metavar="N",
|
|
208
216
|
help="encoder quality on the CRF scale (default %(default)s; lower = better; 18 visually lossless for x264/x265, up to 63 for av1); ignored by prores")
|
|
217
|
+
if (codec and "crf" in ap._defaults) or not codec:
|
|
218
|
+
_add_hw_flags(g, "encode on the GPU with Apple VideoToolbox (h264/hevc/prores; Apple Silicon only): several times faster, "
|
|
219
|
+
"larger files at the same quality; falls back to the CPU encoder, reported, where it cannot apply"
|
|
220
|
+
+ ("" if codec else ". The only way to put a delivery preset on the GPU: $FFMPEG_SKILL_HW does not apply here"),
|
|
221
|
+
"encode on the CPU (x264/x265/SVT-AV1/prores_ks) even when $FFMPEG_SKILL_HW=1")
|
|
222
|
+
ap.set_defaults(_hw_env=bool(codec))
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _add_hw_flags(g: Any, hw_help: str, no_hw_help: str) -> None:
|
|
226
|
+
"""--hw and --no-hw, each its own boolean (dests `hw` and `no_hw`). They are not one dest
|
|
227
|
+
with store_true/store_false: the contract's input_schema keeps one property per dest and the
|
|
228
|
+
MCP transport turns `true` into the flag, so a shared dest published --no-hw under the name
|
|
229
|
+
`hw` and an MCP `hw: true` ran on the CPU. hw_flag() reads the pair back as True/False/None."""
|
|
230
|
+
hw = g.add_mutually_exclusive_group()
|
|
231
|
+
hw.add_argument("--hw", action="store_true", help=hw_help)
|
|
232
|
+
hw.add_argument("--no-hw", action="store_true", help=no_hw_help)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def hw_flag(args: "argparse.Namespace") -> Optional[bool]:
|
|
236
|
+
"""--hw -> True, --no-hw -> False, neither (or a tool without the flags) -> None."""
|
|
237
|
+
if getattr(args, "hw", False):
|
|
238
|
+
return True
|
|
239
|
+
if getattr(args, "no_hw", False):
|
|
240
|
+
return False
|
|
241
|
+
return None
|
|
209
242
|
|
|
210
243
|
|
|
211
244
|
def apply_common(args: "argparse.Namespace") -> None:
|
|
@@ -228,6 +261,25 @@ def apply_common(args: "argparse.Namespace") -> None:
|
|
|
228
261
|
if STATE.fast and getattr(args, "preset", None) in X264_PRESETS:
|
|
229
262
|
args.preset = "veryfast"
|
|
230
263
|
STATE.codec = getattr(args, "codec", None) or None
|
|
264
|
+
STATE.hw_notes, STATE.hw_swaps, STATE.hw_fallback, STATE.hw_env_ignored, STATE.hw_stages = [], [], False, False, []
|
|
265
|
+
flag = hw_flag(args)
|
|
266
|
+
if flag is not None:
|
|
267
|
+
STATE.hw, STATE.hw_source = bool(flag), "flag"
|
|
268
|
+
if getattr(args, "_hw_orchestrator", False):
|
|
269
|
+
# render.py / batch.py --hw|--no-hw: every stage they run inherits it as an explicit
|
|
270
|
+
# choice -- export.py's delivery presets included, which $FFMPEG_SKILL_HW never reaches
|
|
271
|
+
os.environ[HW_ENV] = "1" if flag else "0"
|
|
272
|
+
os.environ[HW_FORCED_ENV] = "1"
|
|
273
|
+
elif hasattr(args, "hw") and os.environ.get(HW_FORCED_ENV) == "1":
|
|
274
|
+
STATE.hw, STATE.hw_source = env_hw(), "flag"
|
|
275
|
+
elif hasattr(args, "hw") and getattr(args, "_hw_env", False) and env_hw():
|
|
276
|
+
STATE.hw, STATE.hw_source = True, "env"
|
|
277
|
+
else:
|
|
278
|
+
STATE.hw, STATE.hw_source = False, None
|
|
279
|
+
# export.py (a delivery preset decides the encoder): the machine default is not applied,
|
|
280
|
+
# and the result says so rather than leaving a caller to wonder why the GPU was not used
|
|
281
|
+
STATE.hw_env_ignored = (hasattr(args, "hw") and env_hw() and not getattr(args, "_hw_env", False)
|
|
282
|
+
and not getattr(args, "_hw_orchestrator", False))
|
|
231
283
|
quality = getattr(args, "quality", None)
|
|
232
284
|
if quality is not None:
|
|
233
285
|
top = 63 if STATE.codec == "av1" else 51
|
|
@@ -354,15 +406,48 @@ def _cleanup_partial_output(cmd: Sequence[str]) -> None:
|
|
|
354
406
|
pass
|
|
355
407
|
|
|
356
408
|
|
|
357
|
-
def _fail(cmd: Sequence[str], returncode: int, stderr: str) -> None:
|
|
409
|
+
def _fail(cmd: Sequence[str], returncode: int, stderr: str, ctx: "Optional[Context]" = None, retried: bool = False) -> None:
|
|
358
410
|
# Partial-output cleanup already ran in the caller (_run_captured/_run_with_progress) for
|
|
359
411
|
# every failed ffmpeg invocation, not just this check=True path -- see _cleanup_partial_output.
|
|
360
412
|
# The process exit code is always 1 for an ffmpeg failure: ffmpeg's own code (1, 69, 218, 234,
|
|
361
413
|
# a negative signal number...) varies by build and by the failing stage, and 124/127/130/143
|
|
362
414
|
# are reserved for timeout, missing tool and interrupts. The raw code is kept in the JSON
|
|
363
415
|
# document as `ffmpeg_returncode` for a caller that wants it. docs/design-decisions.md.
|
|
416
|
+
#
|
|
417
|
+
# `cmd` is the command that ran last: after run()'s GPU->CPU retry (`retried`), the CPU one.
|
|
418
|
+
# Under --hw / $FFMPEG_SKILL_HW the message names its video encoder and the document carries
|
|
419
|
+
# `encoder` and `hw`, because two encoders may have run and a caller must know whose stderr
|
|
420
|
+
# this is.
|
|
421
|
+
ctx = ctx or STATE
|
|
364
422
|
tail = "\n".join(stderr.strip().splitlines()[-15:])
|
|
365
|
-
|
|
423
|
+
what, extra = str(cmd[0]), {}
|
|
424
|
+
enc = video_encoder_of(cmd) if ctx.hw else None
|
|
425
|
+
if enc:
|
|
426
|
+
on_gpu = enc.endswith("_videotoolbox")
|
|
427
|
+
why = videotoolbox_failure(stderr) if on_gpu else None
|
|
428
|
+
if not on_gpu and retried:
|
|
429
|
+
what += f" (video encoder {enc}, the CPU retry after VideoToolbox refused the job)"
|
|
430
|
+
elif on_gpu and why is None:
|
|
431
|
+
what += f" (video encoder {enc}; not retried on the CPU: ffmpeg reported no VideoToolbox error)"
|
|
432
|
+
else:
|
|
433
|
+
what += f" (video encoder {enc})"
|
|
434
|
+
report = hw_report(ctx, enc)
|
|
435
|
+
if on_gpu:
|
|
436
|
+
report["notes"].append(
|
|
437
|
+
"the VideoToolbox encode failed and was not retried on the CPU: " +
|
|
438
|
+
("ffmpeg reported no encoder-open or compression-session error, so the cause is not the GPU (see the message)"
|
|
439
|
+
if why is None else f"VideoToolbox refused it ({why}) and no CPU encoder line was recorded to retry with"))
|
|
440
|
+
extra = {"encoder": enc, "hw": report}
|
|
441
|
+
die(f"command failed ({returncode}): {what}\n{tail}", code=1, kind="ffmpeg", ffmpeg_returncode=returncode, **extra)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def video_encoder_of(cmd: Sequence[str]) -> Optional[str]:
|
|
445
|
+
"""The video encoder an ffmpeg argv names last (-c:v / -vcodec / -codec:v), or None."""
|
|
446
|
+
enc = None
|
|
447
|
+
for i, a in enumerate(cmd[:-1]):
|
|
448
|
+
if a in ("-c:v", "-vcodec", "-codec:v"):
|
|
449
|
+
enc = str(cmd[i + 1])
|
|
450
|
+
return enc
|
|
366
451
|
|
|
367
452
|
|
|
368
453
|
def _check_no_overwrite_input(cmd: Sequence[str]) -> None:
|
|
@@ -621,6 +706,126 @@ def _stage_existing_output(cmd: Sequence[str]) -> Tuple[List[str], Optional[str]
|
|
|
621
706
|
return list(cmd[:-1]) + [tmp], output, tmp
|
|
622
707
|
|
|
623
708
|
|
|
709
|
+
HW_ENV = "FFMPEG_SKILL_HW"
|
|
710
|
+
# set only by render.py/batch.py --hw/--no-hw for the stages they run: "the outer command chose
|
|
711
|
+
# explicitly", so a child resolves $FFMPEG_SKILL_HW as a flag (export.py included), not a default
|
|
712
|
+
HW_FORCED_ENV = "_FFMPEG_SKILL_HW_EXPLICIT"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def add_hw_orchestrator_args(ap: "argparse.ArgumentParser") -> None:
|
|
716
|
+
"""--hw / --no-hw on a tool that runs other tools (render.py, batch.py) and encodes nothing itself."""
|
|
717
|
+
_add_hw_flags(ap, "run every stage's encode on Apple VideoToolbox, the final delivery export included (Apple Silicon; "
|
|
718
|
+
"falls back to the CPU, reported, where it cannot apply). Without it, $FFMPEG_SKILL_HW=1 still "
|
|
719
|
+
"puts the intermediate stages on the GPU and leaves the delivery export on the CPU",
|
|
720
|
+
"every stage on the CPU encoders, whatever $FFMPEG_SKILL_HW says")
|
|
721
|
+
ap.set_defaults(_hw_orchestrator=True)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def env_hw() -> bool:
|
|
725
|
+
"""$FFMPEG_SKILL_HW asks for VideoToolbox by default (1/true/yes/on); anything else is off."""
|
|
726
|
+
return os.environ.get(HW_ENV, "").strip().lower() in ("1", "true", "yes", "on")
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def hw_platform_reason() -> Optional[str]:
|
|
730
|
+
"""None when this machine can take a VideoToolbox constant-quality encode, else why not.
|
|
731
|
+
`-q:v` on VideoToolbox is Apple-Silicon-only in FFmpeg: an Intel Mac lists the encoders but
|
|
732
|
+
rejects the quality setting, so the machine check is macOS AND Apple Silicon hardware."""
|
|
733
|
+
if platform.system() != "Darwin":
|
|
734
|
+
return "VideoToolbox is macOS-only"
|
|
735
|
+
if not apple_silicon():
|
|
736
|
+
return "VideoToolbox constant-quality encoding needs Apple Silicon"
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
|
|
740
|
+
_APPLE_SILICON: Optional[bool] = None
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def apple_silicon() -> bool:
|
|
744
|
+
"""Whether this Mac's hardware is Apple Silicon -- not this interpreter's architecture: an
|
|
745
|
+
x86_64 Python under Rosetta reports platform.machine() "x86_64" on an M-series Mac, where
|
|
746
|
+
`sysctl -n hw.optional.arm64` still reads 1 (an Intel Mac has no such key). What decides -q:v
|
|
747
|
+
is the ffmpeg binary's own build; an x86_64 ffmpeg there refuses it, and run() re-encodes that
|
|
748
|
+
job on the CPU with ffmpeg's line in the note. Read once."""
|
|
749
|
+
global _APPLE_SILICON
|
|
750
|
+
if _APPLE_SILICON is None:
|
|
751
|
+
_APPLE_SILICON = platform.machine() == "arm64"
|
|
752
|
+
if not _APPLE_SILICON and platform.system() == "Darwin":
|
|
753
|
+
try:
|
|
754
|
+
out = subprocess.run([shutil.which("sysctl") or "/usr/sbin/sysctl", "-n", "hw.optional.arm64"],
|
|
755
|
+
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8",
|
|
756
|
+
errors="replace", timeout=10).stdout
|
|
757
|
+
_APPLE_SILICON = out.strip() == "1"
|
|
758
|
+
except (OSError, subprocess.SubprocessError):
|
|
759
|
+
_APPLE_SILICON = False
|
|
760
|
+
return _APPLE_SILICON
|
|
761
|
+
|
|
762
|
+
|
|
763
|
+
# What FFmpeg prints when a VideoToolbox encoder cannot take a job, read from the sources of 5.1,
|
|
764
|
+
# 6.0, 6.1, 7.0, 7.1, 8.0 and master (fftools/ffmpeg*.c, libavcodec/videotoolboxenc.c):
|
|
765
|
+
# - the CLI's encoder-open failure, "Error while opening encoder", on every version. From 6.0 the
|
|
766
|
+
# line carries the output stream's log context, which names the encoder ("[vost#0:0/
|
|
767
|
+
# h264_videotoolbox @ 0x...]", from 8.0 followed by "[enc:h264_videotoolbox @ 0x...]"). 5.1
|
|
768
|
+
# prints "Error initializing output stream 0:0 -- Error while opening encoder for output stream
|
|
769
|
+
# #0:0 ...", which names no encoder, so there it counts only beside an error line the
|
|
770
|
+
# VideoToolbox encoder logged in its own context;
|
|
771
|
+
# - the encoder's own session failures, logged in its codec context ("[h264_videotoolbox @
|
|
772
|
+
# 0x...]"): "cannot create compression session", "cannot prepare encoder", "cannot encode
|
|
773
|
+
# frame", "Error encoding frame". Each of them fails the encode. Matched without case and
|
|
774
|
+
# without the "Error: " prefix, which master drops ("Cannot create compression session").
|
|
775
|
+
# Deliberately not counted, so the job is not run twice and the GPU is not blamed:
|
|
776
|
+
# - "Could not open encoder before EOF" (7.0+). It names the encoder, but it follows a filter
|
|
777
|
+
# graph that failed before the first frame -- a missing font, a bad filter argument -- so the
|
|
778
|
+
# encoder never got as far as opening;
|
|
779
|
+
# - the encoder's ERROR-level lines that do not fail the encode ("Error setting profile/level
|
|
780
|
+
# property ... Output will be encoded using a supported profile/level combination", the
|
|
781
|
+
# frames_before/frames_after/entropy/realtime/pixel-aspect properties), which a job that fails
|
|
782
|
+
# for another reason (a full disk: "Error submitting a packet to the muxer") can still carry.
|
|
783
|
+
_VT_ENCODER_RE = re.compile(r"(?:h264|hevc|prores)_videotoolbox")
|
|
784
|
+
_VT_OWN_CONTEXT_RE = re.compile(r"\[(?:h264|hevc|prores)_videotoolbox @ [^\]]*\]")
|
|
785
|
+
_VT_SESSION_RE = re.compile(r"create compression session|prepare encoder|encode frame|encoding frame", re.I)
|
|
786
|
+
# the encoder's own ERROR-level lines that do not fail the encode (listed above)
|
|
787
|
+
_VT_NONFATAL_RE = re.compile(r"profile/level|entropy|frames_before|frames_after|realtime|pixel aspect|aspect ratio", re.I)
|
|
788
|
+
_STREAM_CONTEXT_RE = re.compile(r"\[[a-z]ost#\d+:\d+")
|
|
789
|
+
_ENCODER_OPEN_FAILED = "Error while opening encoder"
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def videotoolbox_failure(stderr: str) -> Optional[str]:
|
|
793
|
+
"""The stderr line that shows a VideoToolbox encoder refused the job (could not open, or its
|
|
794
|
+
compression session failed), with the log contexts' pointers dropped; None when the failure
|
|
795
|
+
is something else. Only such a failure is retried on the CPU (see the notes above)."""
|
|
796
|
+
lines = [ln.strip() for ln in (stderr or "").splitlines() if ln.strip()]
|
|
797
|
+
own = [ln for ln in lines if _VT_OWN_CONTEXT_RE.search(ln)]
|
|
798
|
+
# the lines that can explain a refusal: not the non-fatal property errors, which a job that
|
|
799
|
+
# failed elsewhere (5.1: an audio encoder that could not open) still carries
|
|
800
|
+
fatal = [ln for ln in own if not _VT_NONFATAL_RE.search(ln)]
|
|
801
|
+
found = next((ln for ln in own if _VT_SESSION_RE.search(ln)), None)
|
|
802
|
+
if found is None:
|
|
803
|
+
for ln in lines:
|
|
804
|
+
if _ENCODER_OPEN_FAILED not in ln:
|
|
805
|
+
continue
|
|
806
|
+
if _VT_ENCODER_RE.search(ln): # 6.0+: the line names the encoder that failed to open
|
|
807
|
+
found = fatal[-1] if fatal else ln
|
|
808
|
+
break
|
|
809
|
+
if fatal and not _STREAM_CONTEXT_RE.search(ln): # 5.1: no encoder named; the encoder's own error line is
|
|
810
|
+
found = fatal[-1]
|
|
811
|
+
break
|
|
812
|
+
return re.sub(r" @ 0x[0-9a-fA-F]+", "", found)[:200] if found else None
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
def _hw_fallback(cmd: List[str], ctx: "Context") -> Optional[List[str]]:
|
|
816
|
+
"""`cmd` with every VideoToolbox encoder line this process built swapped back to the CPU line
|
|
817
|
+
it replaced, or None when it has none -- for run() when the GPU refused an encode."""
|
|
818
|
+
out, changed = list(cmd), False
|
|
819
|
+
for vt, cpu in ctx.hw_swaps:
|
|
820
|
+
n = len(vt)
|
|
821
|
+
for i in range(len(out) - n + 1):
|
|
822
|
+
if out[i:i + n] == vt:
|
|
823
|
+
out[i:i + n] = cpu
|
|
824
|
+
changed = True
|
|
825
|
+
break
|
|
826
|
+
return out if changed else None
|
|
827
|
+
|
|
828
|
+
|
|
624
829
|
def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Optional[Context]" = None) -> subprocess.CompletedProcess:
|
|
625
830
|
"""Run a command, echoing it to stderr unless quiet. Exits on failure when check=True.
|
|
626
831
|
|
|
@@ -648,19 +853,50 @@ def run(cmd: Sequence[str], *, quiet: bool = False, check: bool = True, ctx: "Op
|
|
|
648
853
|
flush_drawtext_textfiles(cmd)
|
|
649
854
|
with _OutputLock(cmd[-1] if is_ffmpeg else "-"):
|
|
650
855
|
exec_cmd, final, tmp = _stage_existing_output(cmd) if is_ffmpeg else (list(cmd), None, None)
|
|
651
|
-
proc
|
|
652
|
-
|
|
653
|
-
retry
|
|
856
|
+
def even_retry(proc: subprocess.CompletedProcess, ran: List[str]) -> "Tuple[subprocess.CompletedProcess, List[str]]":
|
|
857
|
+
"""An attempt that failed on odd dimensions, run again with an even scale: after the
|
|
858
|
+
first attempt, and after a GPU->CPU retry (VideoToolbox may refuse an odd-sized job
|
|
859
|
+
for another reason before x264 ever reports the size)."""
|
|
860
|
+
if proc.returncode == 0 or not is_ffmpeg:
|
|
861
|
+
return proc, ran
|
|
862
|
+
retry = _odd_dimension_retry(ran, proc.stderr or "")
|
|
654
863
|
if retry is not None:
|
|
655
864
|
info("source has odd dimensions; scaling to even before encoding (yuv420p needs it)")
|
|
656
865
|
ctx.commands[-1] = _cmdline(retry[:-1] + [cmd[-1]])
|
|
657
|
-
|
|
658
|
-
|
|
866
|
+
return _execute(retry), retry
|
|
867
|
+
if "not divisible by 2" in (proc.stderr or ""):
|
|
659
868
|
die("the source has odd dimensions (width or height not divisible by 2) and this tool's filter graph "
|
|
660
869
|
"cannot pad them itself; make them even first, e.g. fit.py --width/--height, then retry",
|
|
661
870
|
kind="input")
|
|
871
|
+
return proc, ran
|
|
872
|
+
|
|
873
|
+
# `tried` is the last command run: the CPU fallback swaps its encoder line in
|
|
874
|
+
proc, tried = even_retry(_execute(exec_cmd), exec_cmd)
|
|
875
|
+
retried = False
|
|
876
|
+
if proc.returncode != 0 and is_ffmpeg and ctx.hw_swaps:
|
|
877
|
+
cpu_cmd = _hw_fallback(tried, ctx)
|
|
878
|
+
if cpu_cmd is not None:
|
|
879
|
+
# Only a failure VideoToolbox itself reported is retried on the CPU: a bad filter
|
|
880
|
+
# graph, a missing font or a full disk would fail the same way twice and be
|
|
881
|
+
# reported as the GPU's fault (videotoolbox_failure() has the strings and why).
|
|
882
|
+
# A failure that is not one stays a failure of this command: with check=True,
|
|
883
|
+
# _fail() names it; with check=False the caller's own retry decides (a success
|
|
884
|
+
# after that must not carry a note about it).
|
|
885
|
+
why = videotoolbox_failure(proc.stderr or "")
|
|
886
|
+
if why is not None:
|
|
887
|
+
reason = f"VideoToolbox refused the encode: {why}"
|
|
888
|
+
info(reason + "; retrying on the CPU encoder")
|
|
889
|
+
ctx.hw_notes.append(reason + "; re-encoded on the CPU")
|
|
890
|
+
ctx.hw_fallback = True
|
|
891
|
+
ctx.commands[-1] = _cmdline(cpu_cmd[:-1] + [cmd[-1]])
|
|
892
|
+
if not quiet:
|
|
893
|
+
# echoed like the first attempt, so a caller that reads the "$ " lines
|
|
894
|
+
# (render.py's stages) records the command that really ran
|
|
895
|
+
info("$ " + ctx.commands[-1], ctx=ctx)
|
|
896
|
+
proc, tried = even_retry(_execute(cpu_cmd), cpu_cmd)
|
|
897
|
+
retried = True
|
|
662
898
|
if proc.returncode != 0 and check:
|
|
663
|
-
_fail(
|
|
899
|
+
_fail(tried, proc.returncode, proc.stderr or "", ctx=ctx, retried=retried)
|
|
664
900
|
if final and tmp:
|
|
665
901
|
if proc.returncode == 0:
|
|
666
902
|
try:
|
|
@@ -1005,13 +1241,22 @@ def flush_drawtext_textfiles(cmd: "Sequence[str]") -> "List[str]":
|
|
|
1005
1241
|
|
|
1006
1242
|
|
|
1007
1243
|
def ffmpeg_encoders() -> set:
|
|
1008
|
-
"""Names from `ffmpeg -encoders
|
|
1009
|
-
an AV1 encoder
|
|
1244
|
+
"""Names from `ffmpeg -encoders` -- the build that will run the encode -- read once; empty when
|
|
1245
|
+
it cannot be read. Used to pick an AV1 encoder, to refuse --codec av1 / prores before ffmpeg
|
|
1246
|
+
would, and to see whether --hw's VideoToolbox encoders are built in.
|
|
1247
|
+
|
|
1248
|
+
Under --dry-run only, the list comes from `ffprobe -encoders` instead: a dry run promises
|
|
1249
|
+
never to run ffmpeg (docs/contract.md: ffmpeg_execution "none") while ffprobe always may, as in
|
|
1250
|
+
ffmpeg_version(). That is the one case for ffprobe. On a mixed install (a Homebrew ffprobe next
|
|
1251
|
+
to a static ffmpeg) its list is another build's, so a real run never uses it: the AV1/ProRes
|
|
1252
|
+
refusals and the VideoToolbox check must describe the binary that encodes, and a plan that
|
|
1253
|
+
read ffprobe's list is re-decided against ffmpeg's when it runs."""
|
|
1010
1254
|
global _ENCODERS
|
|
1011
1255
|
if _ENCODERS is None:
|
|
1012
1256
|
_ENCODERS = set()
|
|
1257
|
+
tool = "ffprobe" if STATE.dry_run else "ffmpeg"
|
|
1013
1258
|
try:
|
|
1014
|
-
out = subprocess.run([shutil.which(
|
|
1259
|
+
out = subprocess.run([shutil.which(tool) or tool, "-hide_banner", "-encoders"], stdout=subprocess.PIPE,
|
|
1015
1260
|
stderr=subprocess.DEVNULL, text=True, encoding="utf-8", errors="replace", timeout=PROBE_TIMEOUT).stdout
|
|
1016
1261
|
_ENCODERS = set(re.findall(r"^\s*[VAS][.\w]{5}\s+(\S+)", out, re.M))
|
|
1017
1262
|
except (OSError, subprocess.SubprocessError):
|
|
@@ -1043,4 +1288,4 @@ def read_text_or_die(path: str, flag: str) -> str:
|
|
|
1043
1288
|
# emit needs runner's Context/STATE/ERROR_CODE, so the two form a cycle that has to be cut
|
|
1044
1289
|
# somewhere: by the time this line runs every name emit reads from runner above is defined, and
|
|
1045
1290
|
# every name runner reads from emit is only ever read inside a function body, never at import.
|
|
1046
|
-
from _common.emit import _plan_at_exit, die, info # noqa: E402
|
|
1291
|
+
from _common.emit import _plan_at_exit, die, hw_report, info # noqa: E402
|
package/scripts/_common/text.py
CHANGED
|
@@ -31,7 +31,7 @@ from _common.emoji import (
|
|
|
31
31
|
)
|
|
32
32
|
from _common.drawtext import (
|
|
33
33
|
drawtext_boxborderw, SHAPING_SCRIPTS, BIDI_SCRIPTS, _SHAPING_BUILD_CACHE, drawtext_shaping, needs_shaping,
|
|
34
|
-
escape_drawtext, drawtext_text_opts,
|
|
34
|
+
escape_drawtext, drawtext_text_opts, drawtext_center_align,
|
|
35
35
|
)
|
|
36
36
|
from _common.wrap import (
|
|
37
37
|
ADVANCE_EM, NO_SPACE_SCRIPTS, NO_BOUNDARY_SCRIPTS, LATIN_EM, LEADING_VOWELS, _is_mark, _char_em,
|
package/scripts/_contract.py
CHANGED
|
@@ -295,7 +295,8 @@ REENCODE_META: Dict[str, Dict[str, str]] = {
|
|
|
295
295
|
}
|
|
296
296
|
|
|
297
297
|
IDEMPOTENCY = {
|
|
298
|
-
"bit_exact": "same inputs and flags give byte-identical output"
|
|
298
|
+
"bit_exact": "same inputs and flags give byte-identical output (with the CPU encoders: under --hw / $FFMPEG_SKILL_HW a "
|
|
299
|
+
"VideoToolbox encode is content_equivalent at best)",
|
|
299
300
|
"content_equivalent": "same inputs and flags give the same media content; bytes may differ between encoder builds",
|
|
300
301
|
"cached": "re-runs skip inputs whose content hash and recipe are unchanged",
|
|
301
302
|
"environment_dependent": "output includes timings or machine state and differs between runs",
|
|
@@ -348,7 +349,7 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
348
349
|
props: Dict[str, Any] = {}
|
|
349
350
|
required: List[str] = []
|
|
350
351
|
positional: List[str] = []
|
|
351
|
-
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality"}
|
|
352
|
+
common = {"dry_run", "json", "progress", "fast", "timeout", "overwrite", "plan", "codec", "quality", "hw", "no_hw"}
|
|
352
353
|
for action in parser._actions:
|
|
353
354
|
if isinstance(action, argparse._HelpAction):
|
|
354
355
|
continue
|
|
@@ -371,6 +372,13 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
371
372
|
required.append(action.dest)
|
|
372
373
|
if action.dest in common:
|
|
373
374
|
prop["common"] = True
|
|
375
|
+
if action.dest in props:
|
|
376
|
+
# one property per dest: a second action (a store_false --no-x beside a store_true --x)
|
|
377
|
+
# would replace the first, and the contract and MCP would publish only the second flag
|
|
378
|
+
# under the first one's name -- an MCP `x: true` would then send --no-x
|
|
379
|
+
first = props[action.dest]["cli"]
|
|
380
|
+
raise RuntimeError(f"{parser.prog}: {'/'.join(action.option_strings) or action.dest} shares dest {action.dest!r} "
|
|
381
|
+
f"with {'/'.join(first) if isinstance(first, list) else 'a positional'}; give each flag its own dest")
|
|
374
382
|
props[action.dest] = prop
|
|
375
383
|
groups = [g for g in getattr(parser, "_mutually_exclusive_groups", []) if g._group_actions]
|
|
376
384
|
schema: Dict[str, Any] = {"type": "object", "properties": props, "required": required, "positional": positional, "additionalProperties": False}
|
|
@@ -380,8 +388,9 @@ def input_schema(parser: argparse.ArgumentParser) -> Dict[str, Any]:
|
|
|
380
388
|
return schema
|
|
381
389
|
|
|
382
390
|
|
|
383
|
-
def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
384
|
-
"""What the tool prints on stdout with --json (keys observed in the implementation).
|
|
391
|
+
def output_schema(name: str, meta: Dict[str, Any], hw: bool = False) -> Dict[str, Any]:
|
|
392
|
+
"""What the tool prints on stdout with --json (keys observed in the implementation). `hw`: the
|
|
393
|
+
tool takes --hw, so its `notes` can also carry a VideoToolbox encode's quality line."""
|
|
385
394
|
if name == "probe":
|
|
386
395
|
return {"type": "object", "description": "one probe document, or an array of them for several inputs",
|
|
387
396
|
"properties": {"file": {"type": "string"}, "format": {"type": "string"}, "duration": {"type": "number"}, "size_bytes": {"type": "integer"},
|
|
@@ -389,6 +398,8 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
389
398
|
base = {"status": {"enum": ["completed"]}, "output": {"type": ["string", "null"], "description": "path written, or null"},
|
|
390
399
|
"dry_run": {"type": "boolean"}, "commands": {"type": "array", "items": {"type": "string"}, "description": "every ffmpeg command line planned or run"},
|
|
391
400
|
"probe": {"type": "object", "description": "probe of the output when a file was written"},
|
|
401
|
+
"encoder": {"type": "string", "description": "the video encoder the last ffmpeg command used (after any GPU->CPU fallback; under --dry-run the planned one; render.py and waveform.py: their stages' commands included), `copy` for a stream copy; absent when no command encoded video"},
|
|
402
|
+
"hw": {"type": "object", "description": "present when VideoToolbox was asked for (--hw, $FFMPEG_SKILL_HW, a render.py stage, a batch.py item) and on export.py when $FFMPEG_SKILL_HW=1 is set but its delivery preset does not take it: {requested: whether this run's encodes were asked onto VideoToolbox (false for that export), source: flag|env, used: true|false whether the encoder that ran last is VideoToolbox, null under --dry-run (nothing ran) and when this process ran no encode itself (batch.py: its stages are child processes; its top-level hw gathers the items' requested/source/fallback/notes and each results row carries its item's encoder and hw), fallback: a job VideoToolbox refused (encoder open or compression session failure; nothing else is retried) was re-encoded on the CPU, notes: why an encode stayed on or fell back to the CPU, and, when $FFMPEG_SKILL_HW chose the GPU and it ran, what that costs and that --no-hw opts out}. A VideoToolbox encode also adds a top-level notes line: its quality is not CRF-equivalent. A kind: ffmpeg failure under --hw / $FFMPEG_SKILL_HW carries encoder and hw too"},
|
|
392
403
|
"plan": {"type": "string", "description": "with --plan FILE: the plan document written (the run itself is a dry run)"},
|
|
393
404
|
"verified": {"type": "boolean", "description": "true only when the artifact was written, probed, and every self-check the tool ran (verification) met its target; false under --dry-run"},
|
|
394
405
|
"verification": {"type": "array", "items": {"type": "object", "properties": {"step": {"type": "string"}, "ok": {"type": "boolean"}}}, "description": "what the tool itself verified: probe, plus loudness (loudness.py, export platform presets) or check (render)"}}
|
|
@@ -481,6 +492,11 @@ def output_schema(name: str, meta: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
481
492
|
"audio": {"type": "object", "description": "what the mix was built from: voice (null | light | medium | strong), stereo_widen, effects/effects_volume, and with --music the music_volume plus duck (null when --duck was not given, else the threshold in dB and linear, ratio, attack_ms, release_ms, amount_db actually used)"}}
|
|
482
493
|
props = dict(base)
|
|
483
494
|
props.update(extra)
|
|
495
|
+
notes = props.get("notes")
|
|
496
|
+
if hw and isinstance(notes, dict) and notes.get("description"):
|
|
497
|
+
# a tool-specific condition is not the only one: emit() adds the GPU line to any tool
|
|
498
|
+
props["notes"] = dict(notes, description=notes["description"] + "; also, on a VideoToolbox encode (--hw / "
|
|
499
|
+
"$FFMPEG_SKILL_HW), a line that its quality is not CRF-equivalent (see hw)")
|
|
484
500
|
required = ["status", "output", "dry_run", "commands"]
|
|
485
501
|
return {"type": "object", "properties": props, "required": required, "additionalProperties": True}
|
|
486
502
|
|
|
@@ -656,6 +672,14 @@ def _whisper_available() -> bool:
|
|
|
656
672
|
return importlib.util.find_spec("faster_whisper") is not None or importlib.util.find_spec("whisper") is not None
|
|
657
673
|
|
|
658
674
|
|
|
675
|
+
def _hw_default() -> Dict[str, Any]:
|
|
676
|
+
"""--hw's machine facts, normalised (never the raw environment value): whether VideoToolbox
|
|
677
|
+
constant-quality encoding can run here (the tools' own check: Apple Silicon hardware, also
|
|
678
|
+
under a Rosetta Python), and whether $FFMPEG_SKILL_HW makes it the default."""
|
|
679
|
+
from _common.runner import env_hw, hw_platform_reason
|
|
680
|
+
return {"platform_ok": hw_platform_reason() is None, "default_on": env_hw()}
|
|
681
|
+
|
|
682
|
+
|
|
659
683
|
def _default_font() -> str:
|
|
660
684
|
from _common import BRAND_DEFAULTS
|
|
661
685
|
return str(BRAND_DEFAULTS["font"])
|
|
@@ -872,6 +896,7 @@ def doctor() -> Dict[str, Any]:
|
|
|
872
896
|
"ok": not missing_required and not unknown_required,
|
|
873
897
|
"tools": _tool_usability(state),
|
|
874
898
|
"gpu_encoders": _gpu_encoders(listings["encoders"]),
|
|
899
|
+
"hw": _hw_default(),
|
|
875
900
|
"fonts": _fonts_capability(probe=True),
|
|
876
901
|
}
|
|
877
902
|
|
|
@@ -1101,7 +1126,7 @@ def tool_spec(name: str, version: str) -> Dict[str, Any]:
|
|
|
1101
1126
|
"inputs": list(meta["inputs"]),
|
|
1102
1127
|
"outputs": list(meta["outputs"]),
|
|
1103
1128
|
"input_schema": schema,
|
|
1104
|
-
"output_schema": output_schema(name, meta),
|
|
1129
|
+
"output_schema": output_schema(name, meta, hw="hw" in schema["properties"]),
|
|
1105
1130
|
"supports_dry_run": supports_dry_run,
|
|
1106
1131
|
"dry_run": {"supported": supports_dry_run,
|
|
1107
1132
|
"ffmpeg_execution": "full" if not supports_dry_run else "analysis_only" if name in DRY_RUN_ANALYSIS else "none",
|
package/scripts/batch.py
CHANGED
|
@@ -33,6 +33,8 @@ from pathlib import Path
|
|
|
33
33
|
from typing import Any, Dict, List, Optional
|
|
34
34
|
|
|
35
35
|
from _common import STATE, add_common, apply_common, child_args, die, emit, info, run_tool, read_text_or_die, MEDIA_EXT as _MEDIA_EXT
|
|
36
|
+
from _common.runner import HW_FORCED_ENV, add_hw_orchestrator_args, env_hw
|
|
37
|
+
from _common.emit import absorb_stage_hw, steps_encoder_report
|
|
36
38
|
|
|
37
39
|
HERE = Path(__file__).resolve().parent
|
|
38
40
|
MEDIA_EXT = {e for e in _MEDIA_EXT if e not in (".png", ".jpg", ".jpeg", ".webp")} # one list (_common); a batch walks media, not stills
|
|
@@ -73,6 +75,18 @@ def recipe_key(recipe: Dict[str, Any]) -> str:
|
|
|
73
75
|
return hashlib.sha1((json.dumps(recipe, sort_keys=True) + "\0" + project_content).encode()).hexdigest()[:12]
|
|
74
76
|
|
|
75
77
|
|
|
78
|
+
def gpu_key() -> str:
|
|
79
|
+
"""The GPU setting the steps resolve, as a suffix for the item cache key: a VideoToolbox file is
|
|
80
|
+
not the CPU's bytes, so a --hw output is never served to a --no-hw run or the other way round
|
|
81
|
+
(render.py's cache_key folds the same two variables in). Read after apply_common(), which has
|
|
82
|
+
exported --hw/--no-hw to the steps. '' when every step encodes on the CPU -- no flag and no
|
|
83
|
+
$FFMPEG_SKILL_HW, or --no-hw -- so the keys of a cache written before --hw existed still hit."""
|
|
84
|
+
if not env_hw():
|
|
85
|
+
return ""
|
|
86
|
+
# the variable alone leaves export.py's delivery presets on the CPU; --hw puts them on the GPU
|
|
87
|
+
return ":hw" if os.environ.get(HW_FORCED_ENV) == "1" else ":hw-env"
|
|
88
|
+
|
|
89
|
+
|
|
76
90
|
JOBS_CAP = 8 # beyond this, concurrent encodes contend for the same cores and memory
|
|
77
91
|
|
|
78
92
|
_LOG = threading.local()
|
|
@@ -157,6 +171,7 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
157
171
|
pj = work / f"{src.stem}_project.json"
|
|
158
172
|
pj.write_text(json.dumps(proj, indent=2), encoding="utf-8")
|
|
159
173
|
ok, _doc = run_step(["render.py", str(pj)], budget())
|
|
174
|
+
step_docs = [("render", _doc)]
|
|
160
175
|
cut_reencoded: List[bool] = []
|
|
161
176
|
else:
|
|
162
177
|
steps = recipe.get("steps") or []
|
|
@@ -165,11 +180,13 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
165
180
|
cur = str(src)
|
|
166
181
|
ok = True
|
|
167
182
|
cut_reencoded = []
|
|
183
|
+
step_docs = []
|
|
168
184
|
for i, step in enumerate(steps):
|
|
169
185
|
last = i == len(steps) - 1
|
|
170
186
|
out = str(final) if last else str(work / f"{src.stem}_step{i}.{'mp4' if src.suffix.lower() not in ('.wav', '.mp3', '.m4a', '.flac') else src.suffix.lstrip('.')}")
|
|
171
187
|
argv = [str(a).replace("{in}", cur).replace("{out}", out) for a in step]
|
|
172
188
|
step_ok, doc = run_step(argv, budget())
|
|
189
|
+
step_docs.append((Path(str(argv[0])).stem if argv else "step", doc))
|
|
173
190
|
# only cut.py's own doc carries `reencoded` -- true if ANY range this call cut needed
|
|
174
191
|
# the tolerance-triggered hybrid re-encode fallback (cut.py ORs its per-segment results
|
|
175
192
|
# into one top-level field; it doesn't report which range, so this is per cut.py call,
|
|
@@ -183,6 +200,9 @@ def process(src: Path, recipe: Dict[str, Any], outdir: Path, work: Path,
|
|
|
183
200
|
result: Dict[str, Any] = {"file": str(src), "output": str(final), "ok": ok, "seconds": round(time.time() - t0, 1)}
|
|
184
201
|
if cut_reencoded:
|
|
185
202
|
result["cut_reencoded"] = cut_reencoded
|
|
203
|
+
# the encoder each item really ran and its GPU facts (--hw / FFMPEG_SKILL_HW: on VideoToolbox,
|
|
204
|
+
# fell back and why): this process encodes nothing itself, so its top-level hw.used is null
|
|
205
|
+
result.update(steps_encoder_report(step_docs))
|
|
186
206
|
return result
|
|
187
207
|
|
|
188
208
|
|
|
@@ -199,6 +219,7 @@ def main() -> int:
|
|
|
199
219
|
"Default 1, which is 1.16's behaviour exactly.")
|
|
200
220
|
ap.add_argument("--work", help="work directory for intermediates (default: <output_dir>/.work)")
|
|
201
221
|
add_common(ap)
|
|
222
|
+
add_hw_orchestrator_args(ap)
|
|
202
223
|
args = ap.parse_args()
|
|
203
224
|
apply_common(args)
|
|
204
225
|
|
|
@@ -240,7 +261,7 @@ def main() -> int:
|
|
|
240
261
|
cache = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
241
262
|
except ValueError:
|
|
242
263
|
cache = {}
|
|
243
|
-
rkey = recipe_key(recipe)
|
|
264
|
+
rkey = recipe_key(recipe) + gpu_key()
|
|
244
265
|
glob = recipe.get("glob") or "*"
|
|
245
266
|
|
|
246
267
|
# --jobs: every item is itself an ffmpeg that already threads across cores, so beyond a few
|
|
@@ -303,6 +324,10 @@ def main() -> int:
|
|
|
303
324
|
# write is already atomic: two finishers could otherwise serialise from two different
|
|
304
325
|
# snapshots and lose an entry.
|
|
305
326
|
with cache_lock:
|
|
327
|
+
# this run just rewrote r["output"]: an entry under another key (another recipe,
|
|
328
|
+
# the other GPU setting) that names the same file now describes bytes that are gone
|
|
329
|
+
for stale in [k for k, v in cache.items() if k != key and isinstance(v, dict) and v.get("output") == r["output"]]:
|
|
330
|
+
del cache[stale]
|
|
306
331
|
cache[key] = r
|
|
307
332
|
# write_text isn't atomic -- a process killed mid-write (or a --watch loop racing
|
|
308
333
|
# a concurrent manual run) could leave a truncated file that json.loads() above
|
|
@@ -507,6 +532,10 @@ def main() -> int:
|
|
|
507
532
|
results = one_pass()
|
|
508
533
|
except KeyboardInterrupt:
|
|
509
534
|
info("watch stopped")
|
|
535
|
+
# the items' GPU facts (asked for, fell back and why) reach the top-level `hw`, as render.py's
|
|
536
|
+
# stages' do; `used` stays null there, because this process encoded nothing itself
|
|
537
|
+
for r in results:
|
|
538
|
+
absorb_stage_hw(Path(r["file"]).name, r)
|
|
510
539
|
done = sum(1 for r in results if r["ok"])
|
|
511
540
|
info(f"{done}/{len(results)} processed, {sum(1 for r in results if r.get('cached'))} from cache")
|
|
512
541
|
if not args.json:
|
package/scripts/export.py
CHANGED
|
@@ -37,6 +37,7 @@ from pathlib import Path
|
|
|
37
37
|
from typing import Dict, List
|
|
38
38
|
|
|
39
39
|
from _common import STATE, add_common, apply_common, bt709_tag_args, child_args, emit, cfr_args, default_output, die, encoder_args, ffmpeg_base, info, probe, run, run_tool, validate_color, pad_filters, add_pad_fill_args, fmt_secs
|
|
40
|
+
from _common.decision import hw_preset_video, restate_last_swap
|
|
40
41
|
from check import SPECS as PLATFORMS, measure_loudness
|
|
41
42
|
from _platforms import ALIASES as _ALIASES, PLATFORMS as PLATFORM_TABLE, resolve as resolve_platform
|
|
42
43
|
# Frame and duration limit come from the one platform table (scripts/_platforms.py) rather than
|
|
@@ -190,20 +191,28 @@ def main() -> int:
|
|
|
190
191
|
"veryfast" if STATE.fast else "medium", meta)
|
|
191
192
|
# encoder_args() already applied --fast (its own preset scale per encoder: SVT-AV1 counts
|
|
192
193
|
# 1..12, not x264's names) and appends +faststart, which this tool adds again for mp4
|
|
194
|
+
as_built = list(video)
|
|
193
195
|
while "-movflags" in video:
|
|
194
196
|
i = video.index("-movflags")
|
|
195
197
|
del video[i:i + 2]
|
|
198
|
+
restate_last_swap(as_built, video)
|
|
196
199
|
if args.crf is not None and "-crf" in video:
|
|
197
200
|
video[video.index("-crf") + 1] = str(args.crf)
|
|
198
201
|
if STATE.fast and "-preset" in video and not p.get("codec"):
|
|
199
202
|
video[video.index("-preset") + 1] = "veryfast"
|
|
203
|
+
# a fixed preset's line is tagged BT.709 (prores, an editing master, keeps the source's tags)
|
|
204
|
+
tag_bt709 = args.preset not in ("prores",) and not p.get("codec")
|
|
205
|
+
if STATE.hw and not p.get("codec") and video:
|
|
206
|
+
# --hw (never $FFMPEG_SKILL_HW here) puts a fixed preset on VideoToolbox: the same codec,
|
|
207
|
+
# its CRF mapped to -q:v, the preset's frame rate kept; tags come with the VT line itself
|
|
208
|
+
video = hw_preset_video(video, meta, bt709=tag_bt709)
|
|
200
209
|
cmd += video
|
|
201
210
|
if args.preset != "copy":
|
|
202
211
|
# a stream copy can't be frame-rate-conformed or retagged without decoding it — that would
|
|
203
212
|
# no longer be a copy, and would silently mislabel colour the agent never actually looked at
|
|
204
213
|
if "-r" not in video:
|
|
205
214
|
cmd += cfr_args(meta)
|
|
206
|
-
if
|
|
215
|
+
if tag_bt709 and not video[video.index("-c:v") + 1].endswith("_videotoolbox"):
|
|
207
216
|
cmd += bt709_tag_args(video[video.index("-c:v") + 1])
|
|
208
217
|
if out_ext == "mp4":
|
|
209
218
|
cmd += ["-movflags", "+faststart"]
|