Files
adminandClaude Opus 4.8 4ee945ca0d feat: encode on the GPU and restart individual renditions
Hardware encoding
- Detect the best usable encoder at startup by encoding a test frame with each
  candidate in turn (nvenc, qsv, amf, then libx264). Being compiled into ffmpeg
  is not enough: h264_amf fails without amfrt64.dll and h264_qsv fails without an
  Intel iGPU, so the probe is the only reliable answer.
- Translate the quality target into the chosen encoder's own vocabulary. libx264's
  -crf and "ultrafast" mean nothing to h264_nvenc, which takes -cq and p1..p7, so
  passing the configured values straight through would have been silently wrong.
- On the reference GTX 1060 a 1080p->720p rendition costs ~62% of a core on NVENC
  versus ~109% on libx264 ultrafast.

Per-rendition supervision
- Track encoder processes per rendition, not per stream. is_running answered
  "any alive", so when the source ("原画") encoder died while 1080p and 720p kept
  running, the supervisor saw a healthy stream and never restarted it — the
  source URL then 404'd permanently. It now requires every rendition to be alive
  and reports which ones are down.
- Restart only the dead renditions, so a single failure no longer interrupts
  viewers on the renditions that were fine.

Production serving
- Serve the built frontend from FastAPI with SPA fallback, so production is one
  process and one port with no Node runtime. Node becomes a build-time dependency.
- start.ps1/start.sh build the frontend on first run instead of failing to a 503.

Cleanup
- Drop the duplicated top-left LIVE badge; the control strip already carries it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-09-27 18:56:21 +08:00

118 lines
4.2 KiB
Python

"""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)