mirror of
https://github.com/myronblair/kino-app
synced 2026-07-27 21:18:43 -05:00
auto-commit for cf4f0369-14b7-4d06-b915-b8f7098fd48d
This commit is contained in:
+112
-26
@@ -1,44 +1,133 @@
|
||||
"""HLS transcoding via ffmpeg. Background-runs and updates DB."""
|
||||
"""HLS transcoding via ffmpeg.
|
||||
|
||||
Two modes:
|
||||
- quick (stream-copy): instant, single bitrate, no quality loss. Source must be H.264/AAC.
|
||||
- abr (adaptive): re-encode to multiple bitrates with master playlist for ABR streaming.
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Tuple, Callable, Awaitable
|
||||
|
||||
logger = logging.getLogger("kino.transcode")
|
||||
|
||||
|
||||
async def transcode_to_hls(
|
||||
source: Path,
|
||||
out_dir: Path,
|
||||
on_status,
|
||||
):
|
||||
"""
|
||||
Run ffmpeg to produce HLS playlist + segments.
|
||||
`on_status(status, error=None)` is awaited at start/end with status one of
|
||||
'running'|'done'|'failed'.
|
||||
Uses stream-copy where possible (fast, no re-encode).
|
||||
"""
|
||||
async def probe_video(source: Path) -> Tuple[int, int]:
|
||||
"""Return (width, height) using ffprobe; (0, 0) on failure."""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"ffprobe", "-v", "error", "-select_streams", "v:0",
|
||||
"-show_entries", "stream=width,height", "-of", "json", str(source),
|
||||
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, _ = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
return (0, 0)
|
||||
data = json.loads(stdout.decode("utf-8", errors="ignore") or "{}")
|
||||
s = (data.get("streams") or [{}])[0]
|
||||
return (int(s.get("width") or 0), int(s.get("height") or 0))
|
||||
except Exception:
|
||||
return (0, 0)
|
||||
|
||||
|
||||
# (height, video_bitrate, max_bitrate, buffer, audio_bitrate)
|
||||
ALL_VARIANTS = [
|
||||
(1080, "5000k", "5500k", "7500k", "192k"),
|
||||
(720, "2800k", "3000k", "4200k", "128k"),
|
||||
(480, "1400k", "1500k", "2100k", "96k"),
|
||||
(360, "800k", "856k", "1200k", "64k"),
|
||||
]
|
||||
|
||||
|
||||
def variants_for_source(src_height: int) -> List[Tuple[int, str, str, str, str]]:
|
||||
"""Pick variants ≤ source height. Always include at least one (smallest)."""
|
||||
if src_height <= 0:
|
||||
return [ALL_VARIANTS[2]] # safe default 480p
|
||||
chosen = [v for v in ALL_VARIANTS if v[0] <= src_height]
|
||||
if not chosen:
|
||||
chosen = [ALL_VARIANTS[-1]]
|
||||
return chosen
|
||||
|
||||
|
||||
async def transcode_quick(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
"""Stream-copy to single-rate HLS. Output filename: playlist.m3u8."""
|
||||
if not source.is_file():
|
||||
await on_status("failed", error=f"Source missing: {source}")
|
||||
return
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
playlist = out_dir / "playlist.m3u8"
|
||||
|
||||
cmd = [
|
||||
"ffmpeg", "-y",
|
||||
"-i", str(source),
|
||||
"-c:v", "copy",
|
||||
"-c:a", "copy",
|
||||
"ffmpeg", "-y", "-i", str(source),
|
||||
"-c:v", "copy", "-c:a", "copy",
|
||||
"-bsf:v", "h264_mp4toannexb",
|
||||
"-f", "hls", "-hls_time", "6",
|
||||
"-hls_list_size", "0", "-hls_playlist_type", "vod",
|
||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||
str(out_dir / "playlist.m3u8"),
|
||||
]
|
||||
await _run(cmd, on_status, out_dir, "playlist.m3u8")
|
||||
|
||||
|
||||
async def transcode_abr(source: Path, out_dir: Path, on_status: Callable[..., Awaitable]) -> None:
|
||||
"""Multi-bitrate ABR HLS. Output entry filename: master.m3u8."""
|
||||
if not source.is_file():
|
||||
await on_status("failed", error=f"Source missing: {source}")
|
||||
return
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
_, height = await probe_video(source)
|
||||
variants = variants_for_source(height)
|
||||
n = len(variants)
|
||||
|
||||
# Build filter graph
|
||||
splits = "".join(f"[v{i}]" for i in range(n))
|
||||
fc_parts = [f"[0:v]split={n}{splits}"]
|
||||
for i, (h, *_rest) in enumerate(variants):
|
||||
fc_parts.append(f"[v{i}]scale=w=-2:h={h}[v{i}out]")
|
||||
filter_complex = ";".join(fc_parts)
|
||||
|
||||
cmd: List[str] = ["ffmpeg", "-y", "-i", str(source), "-filter_complex", filter_complex]
|
||||
|
||||
for i, (_h, vb, maxr, buf, _ab) in enumerate(variants):
|
||||
cmd += [
|
||||
"-map", f"[v{i}out]",
|
||||
f"-c:v:{i}", "libx264",
|
||||
f"-preset:v:{i}", "veryfast",
|
||||
f"-profile:v:{i}", "main",
|
||||
f"-pix_fmt:v:{i}", "yuv420p",
|
||||
f"-b:v:{i}", vb,
|
||||
f"-maxrate:v:{i}", maxr,
|
||||
f"-bufsize:v:{i}", buf,
|
||||
f"-g", "48", f"-keyint_min", "48", f"-sc_threshold", "0",
|
||||
]
|
||||
|
||||
# Audio: same source mapped N times, one per variant
|
||||
for i in range(n):
|
||||
cmd += ["-map", "a:0?"]
|
||||
for i, (*_v, ab) in enumerate(variants):
|
||||
cmd += [f"-c:a:{i}", "aac", f"-b:a:{i}", ab, f"-ac:a:{i}", "2"]
|
||||
|
||||
var_stream_map = " ".join(f"v:{i},a:{i}" for i in range(n))
|
||||
cmd += [
|
||||
"-f", "hls",
|
||||
"-hls_time", "6",
|
||||
"-hls_list_size", "0",
|
||||
"-hls_playlist_type", "vod",
|
||||
"-hls_segment_filename", str(out_dir / "seg_%04d.ts"),
|
||||
str(playlist),
|
||||
"-hls_flags", "independent_segments",
|
||||
"-hls_segment_filename", str(out_dir / "v%v" / "seg_%04d.ts"),
|
||||
"-master_pl_name", "master.m3u8",
|
||||
"-var_stream_map", var_stream_map,
|
||||
str(out_dir / "v%v" / "playlist.m3u8"),
|
||||
]
|
||||
|
||||
# Pre-create variant subdirs (some ffmpeg builds need them)
|
||||
for i in range(n):
|
||||
(out_dir / f"v{i}").mkdir(exist_ok=True)
|
||||
|
||||
await _run(cmd, on_status, out_dir, "master.m3u8")
|
||||
|
||||
|
||||
async def _run(cmd: List[str], on_status, out_dir: Path, entry_filename: str) -> None:
|
||||
await on_status("running")
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
@@ -49,10 +138,9 @@ async def transcode_to_hls(
|
||||
err = stderr.decode("utf-8", errors="ignore")[-500:]
|
||||
logger.error(f"ffmpeg failed: {err}")
|
||||
await on_status("failed", error=err[:200])
|
||||
# cleanup partial files
|
||||
shutil.rmtree(out_dir, ignore_errors=True)
|
||||
return
|
||||
await on_status("done")
|
||||
await on_status("done", entry=entry_filename)
|
||||
except FileNotFoundError:
|
||||
await on_status("failed", error="ffmpeg not installed")
|
||||
except Exception as e:
|
||||
@@ -62,11 +150,9 @@ async def transcode_to_hls(
|
||||
|
||||
|
||||
def srt_to_vtt(srt_text: str) -> str:
|
||||
"""Convert SRT subtitles to WebVTT format (simple, no styling)."""
|
||||
lines = srt_text.replace("\r\n", "\n").split("\n")
|
||||
out = ["WEBVTT", ""]
|
||||
for line in lines:
|
||||
# Replace SRT timestamp commas with VTT dots
|
||||
if "-->" in line:
|
||||
out.append(line.replace(",", "."))
|
||||
else:
|
||||
|
||||
Reference in New Issue
Block a user