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
+393
-110
No files matched your search
+43
-6
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
+160
-89
@@ -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,95 +83,134 @@ 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:
|
||||
label = quality["label"]
|
||||
# The stream-copy ("原画") rendition carries the top rendition's numbers
|
||||
# only so the signature stays complete; ffmpeg copies the input rather
|
||||
# than encoding to them. See StreamService.transcode_renditions.
|
||||
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
|
||||
width = quality["width"]
|
||||
height = quality["height"]
|
||||
fps = quality.get("fps", 30)
|
||||
codec = quality.get("codec", "libx264")
|
||||
bitrate = quality["bitrate"]
|
||||
crf = quality.get("crf", 23)
|
||||
preset = quality.get("preset", "ultrafast")
|
||||
audio_bitrate = quality.get("audio_bitrate", "128k")
|
||||
keyframe_seconds = quality.get("keyframe_seconds", segment_seconds)
|
||||
keyframe_frames = keyframe_seconds * fps
|
||||
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug)
|
||||
os.makedirs(hls_dir, exist_ok=True)
|
||||
self._clear_stale_output(hls_dir)
|
||||
hls_output = [
|
||||
"-f", "hls",
|
||||
"-hls_time", str(segment_seconds),
|
||||
"-hls_list_size", "6",
|
||||
"-hls_flags", "delete_segments+independent_segments+program_date_time",
|
||||
"-hls_segment_filename", os.path.join(hls_dir, "segment_%03d.ts"),
|
||||
os.path.join(hls_dir, "index.m3u8"),
|
||||
]
|
||||
# NOTE: ffmpeg's -reconnect* flags are HTTP/HTTPS-only protocol
|
||||
# options and make RTMP inputs fail with "Option not found".
|
||||
# RTMP drop recovery is handled by the supervisor in main.py,
|
||||
# which restarts exited encoders on its next tick.
|
||||
hls_input = ["-rw_timeout", "10000000", "-i", input_url]
|
||||
if is_copy:
|
||||
# Source ("原画") rendition: keep the publisher's video as-is and only
|
||||
# re-wrap it, so the output keeps the exact incoming resolution,
|
||||
# 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 = (
|
||||
["ffmpeg", "-y"] + hls_input
|
||||
+ [
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-copyts", "-start_at_zero",
|
||||
"-map", "0:v:0", "-map", "0:a:0?",
|
||||
]
|
||||
+ hls_output
|
||||
)
|
||||
else:
|
||||
cmd = (
|
||||
["ffmpeg", "-y"] + hls_input
|
||||
+ [
|
||||
"-c:v", codec, "-preset", preset,
|
||||
"-r", str(fps), "-b:v", bitrate, "-crf", str(crf),
|
||||
"-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})",
|
||||
"-copyts", "-start_at_zero",
|
||||
"-c:a", "aac", "-b:a", audio_bitrate,
|
||||
]
|
||||
+ hls_output
|
||||
)
|
||||
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
|
||||
)
|
||||
procs.append(proc)
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
continue
|
||||
live[slug] = _Rendition(slug=slug, process=proc)
|
||||
started += 1
|
||||
|
||||
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
|
||||
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
|
||||
# than encoding to them. See StreamService.transcode_renditions.
|
||||
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 = _rendition_slug(quality)
|
||||
width = quality["width"]
|
||||
height = quality["height"]
|
||||
fps = quality.get("fps", 30)
|
||||
codec = quality.get("codec", "libx264")
|
||||
bitrate = quality["bitrate"]
|
||||
crf = quality.get("crf", 23)
|
||||
preset = quality.get("preset", "ultrafast")
|
||||
audio_bitrate = quality.get("audio_bitrate", "128k")
|
||||
keyframe_seconds = quality.get("keyframe_seconds", segment_seconds)
|
||||
keyframe_frames = keyframe_seconds * fps
|
||||
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug)
|
||||
os.makedirs(hls_dir, exist_ok=True)
|
||||
self._clear_stale_output(hls_dir)
|
||||
hls_output = [
|
||||
"-f", "hls",
|
||||
"-hls_time", str(segment_seconds),
|
||||
"-hls_list_size", "6",
|
||||
"-hls_flags", "delete_segments+independent_segments+program_date_time",
|
||||
"-hls_segment_filename", os.path.join(hls_dir, "segment_%03d.ts"),
|
||||
os.path.join(hls_dir, "index.m3u8"),
|
||||
]
|
||||
# NOTE: ffmpeg's -reconnect* flags are HTTP/HTTPS-only protocol
|
||||
# options and make RTMP inputs fail with "Option not found".
|
||||
# RTMP drop recovery is handled by the supervisor in main.py,
|
||||
# which restarts exited encoders on its next tick.
|
||||
hls_input = ["-rw_timeout", "10000000", "-i", input_url]
|
||||
if is_copy:
|
||||
# Source ("原画") rendition: keep the publisher's video as-is and only
|
||||
# re-wrap it, so the output keeps the exact incoming resolution,
|
||||
# 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.
|
||||
return (
|
||||
["ffmpeg", "-y"] + hls_input
|
||||
+ [
|
||||
"-c:v", "copy",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-copyts", "-start_at_zero",
|
||||
"-map", "0:v:0", "-map", "0:a:0?",
|
||||
]
|
||||
+ 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:
|
||||
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
|
||||
+ [
|
||||
"-r", str(fps), "-b:v", bitrate,
|
||||
"-s", f"{width}x{height}",
|
||||
"-g", str(keyframe_frames),
|
||||
"-keyint_min", str(keyframe_frames),
|
||||
"-force_key_frames", f"expr:gte(t,n_forced*{keyframe_seconds})",
|
||||
"-copyts", "-start_at_zero",
|
||||
"-c:a", "aac", "-b:a", audio_bitrate,
|
||||
]
|
||||
+ hls_output
|
||||
)
|
||||
|
||||
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">
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user