From 4ee945ca0d3f49d0d679bb8b3ec0c0e6219dc44b Mon Sep 17 00:00:00 2001 From: admin Date: Sun, 27 Sep 2026 18:56:21 +0800 Subject: [PATCH] feat: encode on the GPU and restart individual renditions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- DEPLOYMENT.md | 49 +++- backend/main.py | 39 ++- backend/services/hardware.py | 117 ++++++++ backend/services/transcoder.py | 249 +++++++++++------- .../src/viewer/components/VideoPlayer.tsx | 8 - start.ps1 | 21 +- start.sh | 22 +- 7 files changed, 394 insertions(+), 111 deletions(-) create mode 100644 backend/services/hardware.py diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md index 3c07efe..0a1bbb1 100644 --- a/DEPLOYMENT.md +++ b/DEPLOYMENT.md @@ -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/. 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/. -### 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 diff --git a/backend/main.py b/backend/main.py index 2254719..2ba112c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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"), - ) \ No newline at end of file + ) + +# 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) diff --git a/backend/services/hardware.py b/backend/services/hardware.py new file mode 100644 index 0000000..565867b --- /dev/null +++ b/backend/services/hardware.py @@ -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) diff --git a/backend/services/transcoder.py b/backend/services/transcoder.py index 629cdf9..ec615f9 100644 --- a/backend/services/transcoder.py +++ b/backend/services/transcoder.py @@ -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) diff --git a/frontend/src/viewer/components/VideoPlayer.tsx b/frontend/src/viewer/components/VideoPlayer.tsx index 7a49f8e..8a54aff 100644 --- a/frontend/src/viewer/components/VideoPlayer.tsx +++ b/frontend/src/viewer/components/VideoPlayer.tsx @@ -529,14 +529,6 @@ const VideoPlayer = forwardRef(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 && ( -
- - - LIVE - -
- )} {loading && (
diff --git a/start.ps1 b/start.ps1 index 8345f41..de89868 100644 --- a/start.ps1 +++ b/start.ps1 @@ -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 diff --git a/start.sh b/start.sh index a588b63..dc13795 100644 --- a/start.sh +++ b/start.sh @@ -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"