Files
live/backend/services/transcoder.py
T
adminandClaude Opus 4.8 996e3fea39 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]>
2026-09-22 18:07:56 +08:00

158 lines
6.9 KiB
Python

import subprocess
import os
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 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)
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
)
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:
try:
proc.terminate()
proc.wait(timeout=5)
except Exception:
try:
proc.kill()
except Exception:
pass
def stop_all(self):
for key in list(self._processes.keys()):
self.stop_transcode(key)
transcode_mgr = TranscodeManager()