"""Pick the best available video encoder for the host. Transcoding on the CPU is the fallback, not the default: a GPU encoder handles the same ladder at a fraction of the CPU and leaves the cores free for the rest of the server. Which encoder is usable cannot be decided from `ffmpeg -encoders` alone — that lists everything the *build* supports, and a build with nvenc, amf and qsv compiled in still fails at runtime on a machine with the wrong hardware (no NVIDIA card, no Intel iGPU, a missing amfrt64.dll). So each candidate is probed by actually encoding a frame. The result is cached: probing costs a subprocess per candidate, and the answer cannot change while the process is running. """ from __future__ import annotations import subprocess from dataclasses import dataclass @dataclass(frozen=True) class Encoder: """A usable ffmpeg video encoder plus the flags that suit it.""" name: str # True when the encoder came from hardware rather than the CPU. hardware: bool # ffmpeg `-preset` value, already mapped to this encoder's own scale. preset: str # How a quality target is expressed for this encoder. quality_flag: str # "-crf" or "-cq" quality_value: int # Value that means "encode every frame" for -sc_threshold where it applies. supports_sc_threshold: bool # Ranked by preference. nvenc is the only one that realistically serves this # project today, but the others cost nothing to try and make the code portable to # an Intel or AMD host. # rate-control / preset translation between the software encoders and each family. # The libraries share almost no vocabulary: libx264's -crf 23 is not h264_nvenc's # -cq 23, and "ultrafast" is not a value nvenc accepts at all. _SOFTWARE = { "libx264": ("-crf", 23, "ultrafast"), "libx265": ("-crf", 28, "ultrafast"), } _CANDIDATES = [ # (encoder, hardware, quality flag, quality value, preset, honours -sc_threshold) ("h264_nvenc", True, "-cq", 26, "p1", False), ("hevc_nvenc", True, "-cq", 28, "p1", False), ("h264_qsv", True, "-global_quality", 26, "veryfast", False), ("h264_amf", True, "-qp_i", 26, "speed", False), ("libx264", False, "-crf", 23, "ultrafast", True), ] _cache: Encoder | None = None _probe_log: list[str] = [] def _probe(encoder: str, quality_flag: str, quality_value: int, preset: str, sc_threshold: bool) -> bool: """Encode one synthetic frame; the only reliable test of "does this work here".""" cmd = [ "ffmpeg", "-hide_banner", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc=size=320x240:rate=25", "-frames:v", "5", "-c:v", encoder, ] if preset: cmd += ["-preset", preset] cmd += [quality_flag, str(quality_value)] if sc_threshold: cmd += ["-sc_threshold", "0"] cmd += ["-f", "null", "-"] try: result = subprocess.run(cmd, capture_output=True, timeout=30) except (OSError, subprocess.SubprocessError): return False return result.returncode == 0 def detect_encoder() -> Encoder: """Return the highest-priority encoder that actually works on this host.""" global _cache if _cache is not None: return _cache for name, hardware, quality_flag, quality_value, preset, sc_threshold in _CANDIDATES: if _probe(name, quality_flag, quality_value, preset, sc_threshold): _cache = Encoder(name, hardware, preset, quality_flag, quality_value, sc_threshold) if hardware: print(f"[transcoder] hardware encoder: {name}", flush=True) else: print( f"[transcoder] no working hardware encoder; falling back to {name} " f"(probed: {', '.join(_probe_log)})", flush=True, ) return _cache _probe_log.append(name) # libx264 ships in every ffmpeg build; reaching here means ffmpeg is missing. _cache = Encoder("libx264", False, "ultrafast", "-crf", 23, True) return _cache def clear_encoder_cache() -> None: """Forget the probe result (used by tests).""" global _cache _cache = None _probe_log.clear() def probe_report() -> list[str]: """Encoders that were tried and rejected, in order.""" return list(_probe_log)