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
+394 -111

No files matched your search

+36 -3
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
@@ -277,4 +281,33 @@ async def serve_hls(path: str):
status_code=response.status_code,
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)