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]>
This commit is contained in:
adminandClaude Opus 4.8 committed 2026-09-27 18:56:21 +08:00
1 parent 996e3fea39
commit 4ee945ca0d
7 files changed
+337 -54

No files matched your search

+43 -6
View File
@@ -22,27 +22,64 @@ Required on PATH: Python 3.11+, FFmpeg, and MediaMTX. MediaMTX may instead be le
powershell -ExecutionPolicy Bypass -File .\start.ps1 -InstallDeps
```
### 3. Start the stack
### 3. Build the frontend
Once per deployment, and again whenever the frontend changes. The build output is
committed to the server by the build step and then served by the backend itself,
so **Node.js is a build-time dependency only** — production runs without it and
without a Vite dev server.
```powershell
cd frontend
npm install
npm run build
cd ..
```
Skipping this is not silent: the backend answers every page with HTTP 503 and
`s Frontend not built` until `frontend\dist` exists.
### 4. Start the stack
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1
```
This starts the FastAPI backend on 0.0.0.0:8080. The backend owns everything else:
This starts one process on 0.0.0.0:8080 that owns everything:
- **The frontend** is served from `frontend\dist` on the same port. Unknown paths
return the app shell so client-side routes survive a refresh.
- **MediaMTX** is started at boot if nothing is already answering its API on :9997, and restarted by the supervisor if it dies. Set `LS_MEDIA_ORIGIN_AUTOSTART=false` to manage it externally; the backend then only supervises transcoders.
- **Transcoders** are started for every live stream whose MediaMTX path is publishing, and restarted within ~10s of an ffmpeg process exiting.
- **Transcoders** are started for every live stream whose MediaMTX path is publishing, and restarted within ~10s of an ffmpeg process exiting. They encode on the GPU when one is available (see below).
- **HLS** is written under `runtime\hls` and served through `GET /hls/...`, which falls back to proxying MediaMTX's own HLS when the local files are absent.
Publish RTMP to rtmp://127.0.0.1:1935/live/<stream_key>. Backend docs are at http://127.0.0.1:8080/docs.
Open http://127.0.0.1:8080/ for the site and /docs for the API. Publish RTMP to
rtmp://127.0.0.1:1935/live/<stream_key>.
### 4. Useful variants
### Hardware encoding
The transcoders detect the best encoder the host actually has, at startup, by
encoding a test frame with each candidate in turn:
1. `h264_nvenc` / `hevc_nvenc` (NVIDIA)
2. `h264_qsv` (Intel iGPU)
3. `h264_amf` (AMD)
4. `libx264` (CPU fallback)
Only encoders that really work are used — being compiled into ffmpeg is not
enough, since `h264_amf` fails without `amfrt64.dll` and `h264_qsv` fails without
an Intel iGPU. The choice is logged at startup as `[transcoder] hardware encoder:
…`, or `[transcoder] no working hardware encoder; falling back to libx264` with
the probed candidates listed. On the reference GTX 1060, one 1080p→720p
rendition costs ~62% of a core on NVENC versus ~109% on libx264 ultrafast.
### 5. Useful variants
```powershell
powershell -ExecutionPolicy Bypass -File .\start.ps1 -HostAddress 0.0.0.0 -BackendPort 8080
```
### 5. Smoke test commands
### 6. Smoke test commands
```powershell
curl.exe http://127.0.0.1:8080/api/health
+35 -2
View File
@@ -64,12 +64,16 @@ async def supervise_transcoders() -> list[str]:
# passing a different list here would restart the encoders every tick.
qualities = service.transcode_renditions(stream)
if transcode_mgr.needs_restart(stream.stream_key, qualities):
transcode_mgr.start_transcode(
# Report which ones were down: a whole-stream "restarted" message
# hides the case that matters most, one rendition dying while its
# siblings keep running (a dead 原画 was invisible this way).
dead = transcode_mgr.dead_renditions(stream.stream_key)
started = transcode_mgr.start_transcode(
stream.stream_key,
media_service.get_rtmp_url(stream.stream_key),
qualities,
)
restarted.append(stream.stream_key)
restarted.append(f"{stream.stream_key} ({started} up, was down: {', '.join(dead)})")
return restarted
@@ -278,3 +282,32 @@ async def serve_hls(path: str):
headers=headers,
media_type=response.headers.get("content-type"),
)
# The built frontend is served by this process, so production is one command and
# one port — no Node runtime on the server. Declared last on purpose: a catch-all
# would otherwise shadow every API and /hls route registered above it.
#
# The app uses client-side routing (/watch/1, /admin/streams/…), so requests that
# match no file must return index.html rather than a 404, or a page refresh lands
# on "not found" instead of the route the user was on.
_FRONTEND_DIST = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "frontend", "dist")
@app.get("/{full_path:path}", include_in_schema=False)
async def serve_frontend(full_path: str):
if full_path.startswith(("api/", "hls/", "docs", "redoc", "openapi.json")):
raise HTTPException(status_code=404)
dist = os.path.abspath(_FRONTEND_DIST)
index = os.path.join(dist, "index.html")
if not os.path.isfile(index):
raise HTTPException(
status_code=503,
detail="前端尚未构建:请先在 frontend 目录执行 npm run build",
)
# Resolve inside dist only; a crafted path must not escape it.
candidate = os.path.abspath(os.path.join(dist, full_path))
if full_path and candidate.startswith(dist) and os.path.isfile(candidate):
return FileResponse(candidate)
return FileResponse(index)
+117
View File
@@ -0,0 +1,117 @@
"""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)
+104 -33
View File
@@ -1,34 +1,66 @@
import subprocess
import os
from dataclasses import dataclass
from config import settings
from services.hardware import detect_encoder
def _rendition_slug(quality: dict) -> str:
"""Path slug for a rendition, falling back to its label."""
return quality.get("path") or quality["label"]
@dataclass
class _Rendition:
"""One encoder process, remembered per rendition rather than per stream."""
slug: str
process: subprocess.Popen
class TranscodeManager:
_processes: dict[str, list[subprocess.Popen]] = {}
# Keyed by stream, then by rendition slug. Per-rendition tracking is what lets
# a single dead encoder be noticed: a stream-level list made a dead 原画
# invisible as long as any sibling encoder was still alive.
_renditions: dict[str, dict[str, _Rendition]] = {}
# Signature of the last successful start, so a supervisor can tell whether a
# running set of processes still matches the stream's current configuration.
_signatures: dict[str, str] = {}
# Number of renditions that failed to launch on the last attempt.
_failed_starts: dict[str, int] = {}
@staticmethod
def _signature(qualities: list[dict]) -> str:
# The hardware encoder is part of the signature: if detection ever changes
# (a driver appears, the GPU is removed), running processes are stale.
encoder = detect_encoder().name
return "|".join(
f"{q.get('label')}:{q.get('width')}x{q.get('height')}:{q.get('bitrate')}:"
f"{'copy' if q.get('copy') else q.get('codec', 'libx264')}"
f"{'copy' if q.get('copy') else encoder}"
for q in qualities
)
def is_running(self, stream_key: str) -> bool:
"""True when at least one transcode process exists and none have exited."""
procs = self._processes.get(stream_key)
if not procs:
return False
return any(proc.poll() is None for proc in procs)
"""True when every rendition this stream should have is still alive."""
return not self.dead_renditions(stream_key)
def dead_renditions(self, stream_key: str) -> list[str]:
"""Slugs of renditions that have exited. Empty when all are healthy.
Missing sessions count as dead, so a stream that was never started
reports every rendition as needing one.
"""
renditions = self._renditions.get(stream_key)
if not renditions:
return ["*"]
return [slug for slug, r in renditions.items() if r.process.poll() is not None]
def needs_restart(self, stream_key: str, qualities: list[dict]) -> bool:
if not qualities:
return False
return self._signatures.get(stream_key) != self._signature(qualities) or not self.is_running(stream_key)
if self._signatures.get(stream_key) != self._signature(qualities):
return True
return bool(self.dead_renditions(stream_key))
def _clear_stale_output(self, hls_dir: str) -> None:
"""Drop the previous run's playlist and segments.
@@ -51,13 +83,49 @@ class TranscodeManager:
pass
def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]):
if stream_key in self._processes and self.is_running(stream_key):
return
self.stop_transcode(stream_key)
"""Start whichever renditions are not already alive.
procs = []
Restarting only the dead ones keeps the survivors streaming: tearing down
every encoder because one of them exited would black out the whole stream,
and a viewer on a healthy rendition has no reason to be interrupted.
"""
signature = self._signature(qualities)
if self._signatures.get(stream_key) != signature:
# The configuration changed, so nothing on disk is known-good.
self.stop_transcode(stream_key)
else:
self._reap_dead(stream_key)
live = self._renditions.setdefault(stream_key, {})
started = 0
segment_seconds = max(1, settings.hls_segment_seconds)
for quality in qualities:
slug = _rendition_slug(quality)
if slug in live:
continue # still running; leave it alone
cmd = self._build_command(stream_key, quality, input_url, segment_seconds)
try:
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except FileNotFoundError:
continue
live[slug] = _Rendition(slug=slug, process=proc)
started += 1
self._signatures[stream_key] = signature
self._failed_starts[stream_key] = max(0, len(qualities) - len(live))
return started
def _reap_dead(self, stream_key: str) -> None:
"""Forget exited renditions so the next start can replace them."""
live = self._renditions.get(stream_key)
if not live:
return
for slug, rendition in list(live.items()):
if rendition.process.poll() is not None:
del live[slug]
def _build_command(self, stream_key: str, quality: dict, input_url: str, segment_seconds: int) -> list[str]:
label = quality["label"]
# The stream-copy ("原画") rendition carries the top rendition's numbers
# only so the signature stays complete; ffmpeg copies the input rather
@@ -65,7 +133,7 @@ class TranscodeManager:
is_copy = bool(quality.get("copy"))
# Files land under the rendition's path slug, never its label: MediaMTX
# refuses non-alphanumeric HLS paths and the label may be Chinese.
path_slug = quality.get("path") or label
path_slug = _rendition_slug(quality)
width = quality["width"]
height = quality["height"]
fps = quality.get("fps", 30)
@@ -98,7 +166,7 @@ class TranscodeManager:
# frame rate and bitrate. Video copy costs no CPU; audio is re-encoded
# because MPEG-TS cannot carry every codec a publisher might send, and
# one AAC track is a rounding error next to the video encoders.
cmd = (
return (
["ffmpeg", "-y"] + hls_input
+ [
"-c:v", "copy",
@@ -108,14 +176,28 @@ class TranscodeManager:
]
+ hls_output
)
# Encode on the GPU when the host has one. The configured codec/preset
# describe the *intended* quality, but the values themselves are
# encoder-specific: libx264's -crf and "ultrafast" mean nothing to
# h264_nvenc, which wants -cq and p1..p7. So the target is re-expressed
# in the chosen encoder's own terms rather than passed through.
encoder = detect_encoder()
encode_args = ["-c:v", encoder.name]
if encoder.name == codec:
# The configured encoder is the one we picked: honour its settings.
encode_args += ["-preset", preset, encoder.quality_flag, str(crf)]
else:
cmd = (
encode_args += ["-preset", encoder.preset, encoder.quality_flag, str(encoder.quality_value)]
if encoder.supports_sc_threshold:
# Only the software encoders take this; nvenc rejects it.
encode_args += ["-sc_threshold", "0"]
return (
["ffmpeg", "-y"] + hls_input
+ encode_args
+ [
"-c:v", codec, "-preset", preset,
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
"-r", str(fps), "-b:v", bitrate,
"-s", f"{width}x{height}",
"-sc_threshold", "0",
"-g", str(keyframe_frames),
"-keyint_min", str(keyframe_frames),
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
@@ -125,21 +207,10 @@ class TranscodeManager:
+ hls_output
)
try:
proc = subprocess.Popen(
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
)
procs.append(proc)
except FileNotFoundError:
pass
self._processes[stream_key] = procs
self._signatures[stream_key] = self._signature(qualities)
self._failed_starts[stream_key] = 0 if procs else self._failed_starts.get(stream_key, 0) + 1
def stop_transcode(self, stream_key: str):
procs = self._processes.pop(stream_key, [])
for proc in procs:
live = self._renditions.pop(stream_key, {})
for rendition in live.values():
proc = rendition.process
try:
proc.terminate()
proc.wait(timeout=5)
@@ -150,7 +221,7 @@ class TranscodeManager:
pass
def stop_all(self):
for key in list(self._processes.keys()):
for key in list(self._renditions.keys()):
self.stop_transcode(key)
@@ -529,14 +529,6 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(function Vid
{/* Persistent live badge — the stream state should be readable without
hovering, since "is this live?" is the first question a viewer asks. */}
{live && (
<div className="pointer-events-none absolute left-3 top-3 flex items-center gap-2">
<span className="flex items-center gap-1.5 rounded-full bg-black/60 px-2.5 py-1 text-xs font-semibold text-white backdrop-blur">
<span className="h-1.5 w-1.5 rounded-full bg-red-500" />
LIVE
</span>
</div>
)}
{loading && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
+20 -1
View File
@@ -1,5 +1,6 @@
param(
[switch]$InstallDeps,
[switch]$BuildFrontend,
[string]$HostAddress = "0.0.0.0",
[int]$BackendPort = 8080
)
@@ -41,11 +42,29 @@ if ($InstallDeps) {
Pop-Location
}
# The frontend is built once and then served by the backend, so Node is only
# needed here — production never runs npm. Done automatically on a first start so
# a fresh checkout does not come up as a 503 with no obvious cause.
$FrontendDir = Join-Path $Base "frontend"
$DistDir = Join-Path $FrontendDir "dist"
if ($BuildFrontend -or -not (Test-Path (Join-Path $DistDir "index.html"))) {
if (Get-Command "npm" -ErrorAction SilentlyContinue) {
Write-Host "[StreamCore] Building frontend..."
Push-Location $FrontendDir
if (-not (Test-Path "node_modules")) { npm install }
npm run build
Pop-Location
} else {
Write-Warning "npm not found and frontend\dist is missing; the site will return 503."
Write-Warning "Install Node.js and run: cd frontend; npm install; npm run build"
}
}
# MediaMTX, the transcoders and the HLS tree are managed by the backend itself:
# it starts the media origin at boot, supervises ffmpeg, and restarts either if it
# dies. This launcher only has to bring up the process that owns all of that.
Write-Host "[StreamCore] Starting backend on ${HostAddress}:${BackendPort} ..."
Write-Host "[StreamCore] MediaMTX, transcoders and HLS are supervised by the backend."
Write-Host "[StreamCore] Frontend, MediaMTX, transcoders and HLS are served by the backend."
$backendArgs = @("-m", "uvicorn", "main:app", "--host", $HostAddress, "--port", "$BackendPort")
& $Python @backendArgs
+18 -4
View File
@@ -1,9 +1,10 @@
#!/bin/bash
# StreamCore - Start the backend.
#
# MediaMTX, the transcoders and the HLS tree are managed by the backend itself:
# it starts the media origin at boot, supervises ffmpeg, and restarts either if it
# dies. This script only has to bring up the process that owns all of that.
# The backend owns everything: it serves the built frontend, starts the media
# origin at boot, supervises ffmpeg, and restarts either if it dies. This script
# only has to bring up the process that owns all of that. Node is a build-time
# dependency only (see the frontend build below), never a runtime one.
set -e
BASE="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
@@ -15,11 +16,24 @@ mkdir -p "$LS_HLS_RUNTIME_DIR" "$LS_REPLAY_MEDIA_DIR" "$BASE/runtime/logs"
HOST="${LS_HOST:-0.0.0.0}"
PORT="${LS_PORT:-8080}"
# Build the frontend once, so a fresh checkout does not come up as a 503.
if [ ! -f "$BASE/frontend/dist/index.html" ]; then
if command -v npm >/dev/null 2>&1; then
echo "[StreamCore] Building frontend..."
cd "$BASE/frontend"
[ -d node_modules ] || npm install
npm run build
else
echo "[StreamCore] WARNING: npm not found and frontend/dist is missing; the site will return 503." >&2
echo "[StreamCore] WARNING: install Node.js and run: cd frontend && npm install && npm run build" >&2
fi
fi
cd "$BASE/backend"
PY="$BASE/backend/venv/bin/python"
[ -x "$PY" ] || PY="$BASE/backend/.venv/bin/python"
[ -x "$PY" ] || PY="python"
echo "[StreamCore] Starting backend on ${HOST}:${PORT}..."
echo "[StreamCore] MediaMTX, transcoders and HLS are supervised by the backend."
echo "[StreamCore] Frontend, MediaMTX, transcoders and HLS are served by the backend."
exec "$PY" -m uvicorn main:app --host "$HOST" --port "$PORT"