""" VideoProcessorService ===================== Extracts 24 predefined 15-second clips using MoviePy. No system FFmpeg installation required — MoviePy bundles its own via imageio-ffmpeg. """ import asyncio import logging import os from pathlib import Path from typing import List from moviepy.editor import VideoFileClip from app.models.schemas import ClipResult, ProcessingResponse from app.utils.helpers import get_file_size, seconds_to_hhmmss from app.utils.segments import MINIMUM_VIDEO_DURATION, SEGMENTS logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────────── # Exceptions # ───────────────────────────────────────────────────────────────────────────── class VideoProcessingError(Exception): """Raised when video processing fails for an expected reason.""" class VideoTooShortError(VideoProcessingError): """Raised when the source video is shorter than the last required segment.""" class InvalidVideoError(VideoProcessingError): """Raised when MoviePy cannot read the video file.""" # ───────────────────────────────────────────────────────────────────────────── # Service # ───────────────────────────────────────────────────────────────────────────── class VideoProcessorService: """ Handles end-to-end clip extraction using MoviePy. """ def __init__(self, output_dir: str = "outputs") -> None: self.output_dir = Path(output_dir) self.output_dir.mkdir(parents=True, exist_ok=True) async def process( self, video_path: str, upload_id: str, original_filename: str, ) -> ProcessingResponse: duration = self._validate_video(video_path) session_output_dir = self.output_dir / upload_id session_output_dir.mkdir(parents=True, exist_ok=True) semaphore = asyncio.Semaphore(3) tasks = [ self._extract_clip_async( video_path=video_path, segment=seg, output_dir=session_output_dir, upload_id=upload_id, semaphore=semaphore, ) for seg in SEGMENTS ] clip_results: List[ClipResult] = await asyncio.gather(*tasks) clip_results.sort(key=lambda c: c.clip_index) return ProcessingResponse( upload_id=upload_id, total_clips=len(clip_results), clips=clip_results, output_directory=str(session_output_dir), message=( f"Successfully extracted {len(clip_results)} clips " f"from '{original_filename}' " f"(duration: {seconds_to_hhmmss(duration)})." ), ) def _validate_video(self, video_path: str) -> float: if not os.path.isfile(video_path): raise InvalidVideoError(f"File not found: {video_path}") try: with VideoFileClip(video_path) as clip: duration = clip.duration except Exception as exc: raise InvalidVideoError( f"Cannot read video file. Make sure it is a valid MP4. ({exc})" ) from exc if duration is None: raise InvalidVideoError("Could not determine video duration.") if duration < MINIMUM_VIDEO_DURATION: raise VideoTooShortError( f"Video is too short. " f"Duration: {seconds_to_hhmmss(duration)} — " f"minimum required: {seconds_to_hhmmss(MINIMUM_VIDEO_DURATION)} (25:00)." ) return duration async def _extract_clip_async(self, video_path, segment, output_dir, upload_id, semaphore): async with semaphore: return await asyncio.get_event_loop().run_in_executor( None, self._extract_clip_sync, video_path, segment, output_dir, upload_id, ) def _extract_clip_sync(self, video_path, segment, output_dir, upload_id): filename = self._build_filename(upload_id, segment) output_path = str(output_dir / filename) logger.info("Extracting clip %d (%s)", segment.index, segment.label) try: with VideoFileClip(video_path) as video: clip = video.subclip(segment.start_seconds, segment.end_seconds) clip.write_videofile( output_path, codec="libx264", audio_codec="aac", temp_audiofile=f"{output_path}.temp_audio.m4a", remove_temp=True, logger=None, ffmpeg_params=["-crf", "23", "-preset", "fast"], ) except Exception as exc: raise VideoProcessingError( f"Failed to extract clip {segment.index} ({segment.label}): {exc}" ) from exc return ClipResult( clip_index=segment.index, filename=filename, start_time=seconds_to_hhmmss(segment.start_seconds), end_time=seconds_to_hhmmss(segment.end_seconds), duration_seconds=segment.duration, size_bytes=get_file_size(output_path), ) @staticmethod def _build_filename(upload_id: str, segment) -> str: label_safe = segment.label.replace(":", "").replace("-", "_") short_id = upload_id[:8] return f"clip_{segment.index:02d}_{label_safe}_{short_id}.mp4"