feat: consolidate on the shadcn frontend and harden playback
Frontend
- Replace the Ant Design app with the shadcn/ui app as the single frontend,
keeping the portfolio side-by-side revertible in this commit.
- Drop the abandoned mdui experiment and the AntD tree so there is one app,
one router and one design system to maintain.
Live playback
- Stop racing the encoder. lowLatencyMode + liveSyncDurationCount:1 targeted a
single 4s segment, so playback ran at up to 1.10x and the forward buffer
bottomed out at 0.1s, re-buffering every segment. Measured over 40s:
buffer min 0.1s -> 8.9s, rate 1.00 constant, 0 stalls.
- Remove the 落后 labels and the page-level edge-snapping timer that fought
hls.js over two different definitions of "latency".
- Live can no longer be paused: the control is not rendered while it plays, so
the icon can never misreport state.
- Fix autoplay: muted autoplay started silently and never unmuted, and the
gesture that restored sound also toggled playback off. One owner now.
Source ("原画") rendition
- Publish the publisher's stream remuxed losslessly via -c:v copy alongside the
transcoded ones, so a source option exists even when every ladder entry
downsizes.
- Give renditions an ASCII path slug: the HLS path is part of the URL and
MediaMTX rejects non-alphanumeric paths, so 原画 could never be served.
Backend
- Supervise MediaMTX from the backend: start it at boot and restart it on exit,
never killing an origin it did not start.
- Clear stale HLS output before restarting an encoder; an ENDLIST-less playlist
left by a dead encoder hangs hls.js forever.
- Mark JSON timestamps as UTC. utcnow().isoformat() emitted a naive string that
JS read as local time, so every viewer heartbeat looked 8 hours old in UTC+8
and the live board's active-viewer count was permanently zero.
- Rebind a viewer session when it arrives on another stream instead of failing,
and survive the StrictMode double-heartbeat race on first insert.
- Replace the Linux-only /proc and os.getloadavg metrics with psutil.
- Complete MediaMTX's cookieCheck redirect inside /hls proxying, and hold the
httpx client open for the streamed body.
Housekeeping
- Ignore RainOSS (it stores accessKeySecret), runtime output, TLS keys and
.venv; remove generated screenshots, load test scripts and stray files.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
1 parent
186e32caf1
commit
996e3fea39
70 files changed
+4844
-3070
No files matched your search
@@ -5,15 +5,67 @@ from config import settings
|
||||
|
||||
class TranscodeManager:
|
||||
_processes: dict[str, list[subprocess.Popen]] = {}
|
||||
# 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] = {}
|
||||
_failed_starts: dict[str, int] = {}
|
||||
|
||||
@staticmethod
|
||||
def _signature(qualities: list[dict]) -> str:
|
||||
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')}"
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
def _clear_stale_output(self, hls_dir: str) -> None:
|
||||
"""Drop the previous run's playlist and segments.
|
||||
|
||||
ffmpeg writes a live playlist with no #EXT-X-ENDLIST, so a playlist left
|
||||
behind by an encoder that died looks to hls.js exactly like a stream that
|
||||
simply stopped: it keeps reloading a file that will never change and
|
||||
buffers forever. Removing it makes the player see a missing manifest
|
||||
instead, which it retries until the supervisor has the encoder back up.
|
||||
"""
|
||||
if not os.path.isdir(hls_dir):
|
||||
return
|
||||
for name in os.listdir(hls_dir):
|
||||
if name.endswith(".ts") or name.endswith(".m3u8") or name.endswith(".m3u8.tmp"):
|
||||
try:
|
||||
os.remove(os.path.join(hls_dir, name))
|
||||
except OSError:
|
||||
# A player holding the file open (Windows) is fine: it will be
|
||||
# overwritten by the restarting encoder.
|
||||
pass
|
||||
|
||||
def start_transcode(self, stream_key: str, input_url: str, qualities: list[dict]):
|
||||
if stream_key in self._processes:
|
||||
if stream_key in self._processes and self.is_running(stream_key):
|
||||
return
|
||||
self.stop_transcode(stream_key)
|
||||
|
||||
procs = []
|
||||
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)
|
||||
@@ -24,20 +76,10 @@ class TranscodeManager:
|
||||
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, label)
|
||||
hls_dir = os.path.join(settings.hls_runtime_dir, stream_key, path_slug)
|
||||
os.makedirs(hls_dir, exist_ok=True)
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", input_url,
|
||||
"-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,
|
||||
self._clear_stale_output(hls_dir)
|
||||
hls_output = [
|
||||
"-f", "hls",
|
||||
"-hls_time", str(segment_seconds),
|
||||
"-hls_list_size", "6",
|
||||
@@ -45,6 +87,44 @@ class TranscodeManager:
|
||||
"-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
|
||||
)
|
||||
|
||||
try:
|
||||
proc = subprocess.Popen(
|
||||
cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL
|
||||
@@ -54,6 +134,8 @@ class TranscodeManager:
|
||||
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, [])
|
||||
@@ -72,4 +154,4 @@ class TranscodeManager:
|
||||
self.stop_transcode(key)
|
||||
|
||||
|
||||
transcode_mgr = TranscodeManager()
|
||||
transcode_mgr = TranscodeManager()
|
||||
Reference in new issue
Block a user