Spaces:
Sleeping
Sleeping
| """ | |
| 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), | |
| ) | |
| 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" | |