feat(transcode): optimize + on-the-fly quality + HLS streaming (§6.6)
POST /tracks/{id}/optimize enqueues a transcode; GET /stream/{id}?quality=
serves a cached Opus rendition (miss → master + background warm); GET
/stream/{id}/hls/{playlist.m3u8,segment} serves the worker-generated HLS
rendition (AAC-in-TS), with ?token= propagated onto segment URLs for players
that can't set headers. Hexagonal: Transcoder port, FfmpegTranscoder adapter,
TranscodeService + cache-path helpers, transcode_track worker (idempotent),
schemas + deps wiring. ffmpeg already in the image.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""ffmpeg-based transcoding adapters (Group B / plan §6.6)."""
|
||||
@@ -0,0 +1,67 @@
|
||||
"""FfmpegTranscoder — Opus + HLS renditions via the ffmpeg CLI.
|
||||
|
||||
Implements :class:`app.domain.ports.Transcoder`. Runs ffmpeg as a subprocess
|
||||
(only ever from a worker — CLAUDE.md: no heavy work in the request cycle) and
|
||||
raises :class:`TranscodeError` on a non-zero exit. Opus is used for the direct
|
||||
quality renditions; HLS segments are AAC-in-MPEG-TS (broad player support).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
|
||||
import anyio
|
||||
|
||||
from app.core.logging import get_logger
|
||||
from app.domain.errors import TranscodeError
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# HLS segment length. 10s is a common VOD default — few requests, quick seeks.
|
||||
_HLS_SEGMENT_SECONDS = "10"
|
||||
|
||||
|
||||
def _mkdir(path: Path) -> None:
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
class FfmpegTranscoder:
|
||||
def __init__(self, ffmpeg_path: str = "ffmpeg") -> None:
|
||||
self._ffmpeg = ffmpeg_path
|
||||
|
||||
async def to_opus(self, src: Path, dest: Path, *, bitrate_kbps: int) -> None:
|
||||
await anyio.to_thread.run_sync(_mkdir, dest.parent)
|
||||
tmp = dest.with_suffix(dest.suffix + ".part")
|
||||
await self._run(
|
||||
"-i", str(src),
|
||||
"-vn", "-c:a", "libopus", "-b:a", f"{bitrate_kbps}k",
|
||||
"-f", "opus", str(tmp),
|
||||
)
|
||||
# Publish atomically so a concurrent reader never sees a half-written file.
|
||||
await anyio.to_thread.run_sync(tmp.replace, dest)
|
||||
|
||||
async def to_hls(self, src: Path, out_dir: Path, *, bitrate_kbps: int) -> None:
|
||||
await anyio.to_thread.run_sync(_mkdir, out_dir)
|
||||
playlist = out_dir / "playlist.m3u8"
|
||||
segments = out_dir / "seg_%03d.ts"
|
||||
await self._run(
|
||||
"-i", str(src),
|
||||
"-vn", "-c:a", "aac", "-b:a", f"{bitrate_kbps}k",
|
||||
"-f", "hls",
|
||||
"-hls_time", _HLS_SEGMENT_SECONDS,
|
||||
"-hls_playlist_type", "vod",
|
||||
"-hls_segment_filename", str(segments),
|
||||
str(playlist),
|
||||
)
|
||||
|
||||
async def _run(self, *args: str) -> None:
|
||||
cmd = [self._ffmpeg, "-y", "-nostdin", "-loglevel", "error", *args]
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate()
|
||||
if proc.returncode != 0:
|
||||
detail = stderr.decode(errors="replace").strip()[-500:]
|
||||
log.error("ffmpeg_failed", returncode=proc.returncode, error=detail)
|
||||
raise TranscodeError(f"ffmpeg exited {proc.returncode}: {detail}")
|
||||
Reference in New Issue
Block a user