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:
1 parent
996e3fea39
commit
4ee945ca0d
7 files changed
+394
-111
No files matched your search
+36
-3
@@ -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)
|
||||
Reference in new issue
Block a user