"""FFmpeg clip extraction with format presets for shorts generation. Pure functions — no DB access, no Celery dependency. Tested independently. """ from __future__ import annotations import logging import subprocess from dataclasses import dataclass from pathlib import Path from models import FormatPreset logger = logging.getLogger(__name__) FFMPEG_TIMEOUT_SECS = 300 @dataclass(frozen=True) class PresetSpec: """Resolution and ffmpeg video filter for a format preset.""" width: int height: int vf_filter: str PRESETS: dict[FormatPreset, PresetSpec] = { FormatPreset.vertical: PresetSpec( width=1080, height=1920, vf_filter="scale=1080:-2,pad=1080:1920:(ow-iw)/2:(oh-ih)/2:black", ), FormatPreset.square: PresetSpec( width=1080, height=1080, vf_filter="crop=min(iw\\,ih):min(iw\\,ih),scale=1080:1080", ), FormatPreset.horizontal: PresetSpec( width=1920, height=1080, vf_filter="scale=1920:1080:force_original_aspect_ratio=decrease,pad=1920:1080:(ow-iw)/2:(oh-ih)/2:black", ), } def resolve_video_path(video_source_root: str, file_path: str) -> Path: """Join root + relative path and validate the file exists. Args: video_source_root: Base directory for video files (e.g. /videos). file_path: Relative path stored in SourceVideo.file_path. Returns: Resolved absolute Path. Raises: FileNotFoundError: If the resolved path doesn't exist or isn't a file. """ resolved = Path(video_source_root) / file_path if not resolved.is_file(): raise FileNotFoundError( f"Video file not found: {resolved} " f"(root={video_source_root!r}, relative={file_path!r})" ) return resolved def extract_clip( input_path: Path | str, output_path: Path | str, start_secs: float, end_secs: float, vf_filter: str, ass_path: Path | str | None = None, ) -> None: """Extract a clip from a video file using ffmpeg. Seeks to *start_secs*, encodes until *end_secs*, and applies *vf_filter*. Uses ``-c:v libx264 -preset fast -crf 23`` for reasonable quality/speed. When *ass_path* is provided, the ASS subtitle filter is appended to the video filter chain so that captions are burned into the output video. Args: input_path: Source video file. output_path: Destination mp4 file (parent dir must exist). start_secs: Start time in seconds. end_secs: End time in seconds. vf_filter: ffmpeg ``-vf`` filter string. ass_path: Optional path to an ASS subtitle file. When provided, ``ass=`` is appended to the filter chain. Raises: subprocess.CalledProcessError: If ffmpeg exits non-zero. subprocess.TimeoutExpired: If ffmpeg exceeds the timeout. ValueError: If start >= end. """ duration = end_secs - start_secs if duration <= 0: raise ValueError( f"Invalid clip range: start={start_secs}s end={end_secs}s " f"(duration={duration}s)" ) # Build the video filter chain — ASS burn-in comes after scale/pad effective_vf = vf_filter if ass_path is not None: # Escape colons and backslashes in the path for ffmpeg filter syntax escaped = str(ass_path).replace("\\", "\\\\").replace(":", "\\:") effective_vf = f"{vf_filter},ass={escaped}" cmd = [ "ffmpeg", "-y", # overwrite output "-ss", str(start_secs), # seek before input (fast) "-i", str(input_path), "-t", str(duration), "-vf", effective_vf, "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", # web-friendly mp4 str(output_path), ] logger.info( "ffmpeg: extracting %.1fs clip from %s → %s", duration, input_path, output_path, ) result = subprocess.run( cmd, capture_output=True, timeout=FFMPEG_TIMEOUT_SECS, ) if result.returncode != 0: stderr_text = result.stderr.decode("utf-8", errors="replace")[-2000:] logger.error("ffmpeg failed (rc=%d): %s", result.returncode, stderr_text) raise subprocess.CalledProcessError( result.returncode, cmd, output=result.stdout, stderr=result.stderr, )