Files
mitus/cht/scrub/proxy.py
2026-04-03 10:21:51 -03:00

84 lines
2.8 KiB
Python

"""Proxy generation — low-res MJPEG for frame-accurate scrubbing.
Each completed recording segment gets a lightweight proxy video where every
frame is a keyframe (MJPEG). mpv can seek frame-accurately in these files
with hr-seek=yes, giving DaVinci Resolve-style scrubbing speed.
Proxies are ephemeral — stored in /tmp, regenerated on demand.
"""
import logging
import shutil
from pathlib import Path
import ffmpeg as ffmpeg_lib
log = logging.getLogger(__name__)
from cht.config import DATA_DIR
PROXY_DIR = DATA_DIR / "proxies"
PROXY_HEIGHT = 360 # pixels — low enough for speed, high enough to see content
def proxy_path_for(segment_path: Path, session_id: str | None = None) -> Path:
"""Return the proxy path for a given segment."""
subdir = session_id or "default"
return PROXY_DIR / subdir / f"{segment_path.stem}_proxy.avi"
def generate_proxy(segment_path: Path, output_path: Path,
height: int = PROXY_HEIGHT) -> Path:
"""Transcode a segment to MJPEG proxy at reduced resolution.
Every frame is a keyframe — enables O(1) seeking.
Returns output_path on success.
"""
output_path.parent.mkdir(parents=True, exist_ok=True)
stream = ffmpeg_lib.input(str(segment_path), hwaccel="cuda")
output = (
ffmpeg_lib.output(
stream, str(output_path),
vcodec="mjpeg",
vf=f"scale=-2:{height}",
# MJPEG: every frame is a keyframe by nature
**{"q:v": "5"}, # quality 2-31, lower = better
an=None, # strip audio
)
.overwrite_output()
.global_args("-hide_banner", "-loglevel", "warning")
)
log.info("Generating proxy: %s%s", segment_path.name, output_path)
try:
output.run(capture_stdout=True, capture_stderr=True)
except ffmpeg_lib.Error as e:
stderr = (e.stderr or b"").decode("utf-8", errors="replace")
log.error("Proxy generation failed for %s: %s", segment_path.name, stderr.strip())
raise
log.info("Proxy ready: %s (%.1f MB)",
output_path.name, output_path.stat().st_size / 1_000_000)
return output_path
def ensure_proxy(segment_path: Path, session_id: str | None = None,
height: int = PROXY_HEIGHT) -> Path:
"""Return proxy path, generating it if missing."""
out = proxy_path_for(segment_path, session_id)
if out.exists():
return out
return generate_proxy(segment_path, out, height)
def cleanup_proxies(session_id: str | None = None) -> None:
"""Delete proxy files for a session, or all proxies if session_id is None."""
if session_id:
target = PROXY_DIR / session_id
else:
target = PROXY_DIR
if target.exists():
shutil.rmtree(target)
log.info("Cleaned up proxies: %s", target)