From 65cc7ba4d760b4420c073c19cf645fb2688b348a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 21 Dec 2025 01:10:37 +0000 Subject: [PATCH] feat: Add adaptive bitrate HLS and concurrency controls Co-authored-by: mathieumoran1 --- README.md | 24 ++++ __pycache__/video_server.cpython-312.pyc | Bin 21972 -> 27473 bytes video_server.py | 172 ++++++++++++++++++++--- 3 files changed, 175 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index c9328ab..266ab5e 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,30 @@ Example: - Source: `http://localhost:8001/media/MyMovie.mkv` - The player will request: `http://localhost:8001/api/hls?source=MyMovie.mkv` and then play the returned `.m3u8`. +### Multi-stream / adaptive bitrate +HLS is generated as **multi-rendition adaptive bitrate** by default (a master playlist `index.m3u8` pointing to multiple variant streams). + +You can configure variants with: + +```bash +export HLS_VARIANTS="1080:5500,720:3000,480:1500,360:900" +``` + +### Concurrency (many viewers at once) +- **Serving** multiple viewers is handled by Uvicorn/FastAPI; for heavy traffic run with more workers: + +```bash +export VIDEO_SERVER_RELOAD=0 +export VIDEO_SERVER_WORKERS=4 +python3 video_server.py +``` + +- **Transcoding** is CPU-heavy; it is throttled with: + +```bash +export HLS_MAX_JOBS=2 +``` + ### Install ```bash diff --git a/__pycache__/video_server.cpython-312.pyc b/__pycache__/video_server.cpython-312.pyc index bf420e154bcdb206ccb605195fb96c0c7f7f7ab2..5fddb13a1e258d67403f77cd0588b53131b27851 100644 GIT binary patch delta 10716 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 E0Y+=RcmMzZ delta 5405 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 t0QG-=w2`a0j+101O!>W@f@As0ole)WGUHuKA Path: """ @@ -170,6 +175,63 @@ def _hls_id_for_media(path: Path) -> str: return hashlib.sha1(key).hexdigest() +def _probe_streams(path: Path) -> Dict[str, Any]: + """ + Returns basic info about the input using ffprobe: {width,height,has_audio}. + """ + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=p=0:s=x", + str(path), + ] + try: + p = subprocess.run(cmd, check=True, capture_output=True, text=True) + wh = (p.stdout or "").strip() + except FileNotFoundError as exc: + raise HTTPException(status_code=500, detail="ffprobe not installed") from exc + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or "").strip() + raise HTTPException(status_code=500, detail=f"ffprobe failed: {stderr or 'ffprobe error'}") from exc + + width = height = None + if "x" in wh: + try: + w_s, h_s = wh.split("x", 1) + width, height = int(w_s), int(h_s) + except Exception: + width = height = None + + cmd_a = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "a:0", + "-show_entries", + "stream=index", + "-of", + "csv=p=0", + str(path), + ] + try: + pa = subprocess.run(cmd_a, check=False, capture_output=True, text=True) + has_audio = bool((pa.stdout or "").strip()) + except FileNotFoundError as exc: + raise HTTPException(status_code=500, detail="ffprobe not installed") from exc + + if not width or not height: + raise HTTPException(status_code=415, detail="Unsupported media: missing video stream") + + return {"width": width, "height": height, "has_audio": has_audio} + + def _acquire_lock(lock_path: Path, timeout_s: int = 600) -> None: """ Simple file lock to avoid concurrent transcodes for the same id. @@ -221,12 +283,45 @@ def _ensure_hls_for_media(media_path: Path) -> str: shutil.rmtree(tmp_dir, ignore_errors=True) tmp_dir.mkdir(parents=True, exist_ok=True) + # Multi-rendition adaptive HLS (master + variants). hls_time = os.getenv("HLS_TIME", "6").strip() crf = os.getenv("HLS_CRF", "23").strip() preset = os.getenv("HLS_PRESET", "veryfast").strip() - audio_bitrate = os.getenv("HLS_AUDIO_BITRATE", "160k").strip() - cmd = [ + info = _probe_streams(media_path) + src_h = int(info["height"]) + has_audio = bool(info["has_audio"]) + + # Variant syntax: "height:video_kbps,height:video_kbps,..." + default_variants = "1080:5500,720:3000,480:1500,360:900" + variants_env = os.getenv("HLS_VARIANTS", default_variants).strip() + variants: List[Tuple[int, int]] = [] + for part in [p.strip() for p in variants_env.split(",") if p.strip()]: + if ":" not in part: + continue + h_s, b_s = part.split(":", 1) + try: + h = int(h_s) + b = int(b_s) + except ValueError: + continue + if h <= 0 or b <= 0: + continue + if h <= src_h: + variants.append((h, b)) + + if not variants: + variants = [(min(720, src_h), 3000)] + + variants = sorted(list({v for v in variants}), key=lambda x: x[0], reverse=True) + n = len(variants) + + # Build filter_complex split+scale for N renditions + split_tags = "".join([f"[v{i}]" for i in range(n)]) + scale_filters = [f"[v{i}]scale=-2:{variants[i][0]}[v{i}o]" for i in range(n)] + filter_complex = f"[0:v]split={n}{split_tags};" + ";".join(scale_filters) + + cmd: List[str] = [ "ffmpeg", "-hide_banner", "-loglevel", @@ -234,20 +329,43 @@ def _ensure_hls_for_media(media_path: Path) -> str: "-y", "-i", str(media_path), - "-map", - "0:v:0", - "-map", - "0:a?", - "-c:v", - "libx264", - "-preset", - preset, - "-crf", - crf, - "-c:a", - "aac", - "-b:a", - audio_bitrate, + "-filter_complex", + filter_complex, + ] + + for i in range(n): + cmd += ["-map", f"[v{i}o]"] + if has_audio: + cmd += ["-map", "0:a?"] + + cmd += ["-pix_fmt", "yuv420p"] + + audio_bitrate = os.getenv("HLS_AUDIO_BITRATE", "160k").strip() + for i, (_h, b_kbps) in enumerate(variants): + maxrate = int(b_kbps * 1.07) + bufsize = int(b_kbps * 1.5) + cmd += [ + f"-c:v:{i}", + "libx264", + "-preset", + preset, + "-crf", + crf, + f"-b:v:{i}", + f"{b_kbps}k", + f"-maxrate:v:{i}", + f"{maxrate}k", + f"-bufsize:v:{i}", + f"{bufsize}k", + "-sc_threshold", + "0", + ] + if has_audio: + cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", audio_bitrate] + + cmd += ["-force_key_frames", f"expr:gte(t,n_forced*{hls_time})"] + + cmd += [ "-f", "hls", "-hls_time", @@ -257,12 +375,20 @@ def _ensure_hls_for_media(media_path: Path) -> str: "-hls_flags", "independent_segments", "-hls_segment_filename", - str(tmp_dir / "seg_%05d.ts"), - str(tmp_dir / "index.m3u8"), + str(tmp_dir / "seg_%v_%05d.ts"), + "-master_pl_name", + "index.m3u8", ] + if has_audio: + var_map = " ".join([f"v:{i},a:{i}" for i in range(n)]) + else: + var_map = " ".join([f"v:{i}" for i in range(n)]) + cmd += ["-var_stream_map", var_map, str(tmp_dir / "v%v.m3u8")] + try: - subprocess.run(cmd, check=True, capture_output=True) + with _HLS_JOB_SEM: + subprocess.run(cmd, check=True, capture_output=True) except FileNotFoundError as exc: raise HTTPException(status_code=500, detail="ffmpeg not installed") from exc except subprocess.CalledProcessError as exc: @@ -497,5 +623,9 @@ if __name__ == "__main__": host = os.getenv("VIDEO_SERVER_HOST", "0.0.0.0") port = int(os.getenv("VIDEO_SERVER_PORT", "8001")) reload = os.getenv("VIDEO_SERVER_RELOAD", "1").strip() not in {"0", "false", "False", "no", "NO"} - uvicorn.run("video_server:app", host=host, port=port, reload=reload) + workers = int(os.getenv("VIDEO_SERVER_WORKERS", "1").strip() or "1") + # Uvicorn ignores/limits workers when reload=True. + if reload: + workers = 1 + uvicorn.run("video_server:app", host=host, port=port, reload=reload, workers=workers)