video-segmentation / app /services /video_processor.py
rasha102004's picture
Upload 28 files
e229c9e verified
Raw
History Blame Contribute Delete
6.14 kB
"""
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"