Files
ALwrity/backend/services/story_writer/video_generation_service.py

427 lines
19 KiB
Python

"""
Video Generation Service for Story Writer
Combines images and audio into animated video clips using MoviePy.
"""
import os
import uuid
from typing import List, Dict, Any, Optional
from pathlib import Path
from loguru import logger
from fastapi import HTTPException
class StoryVideoGenerationService:
"""Service for generating videos from story scenes, images, and audio."""
def __init__(self, output_dir: Optional[str] = None):
"""
Initialize the video generation service.
Parameters:
output_dir (str, optional): Directory to save generated videos.
Defaults to 'backend/story_videos' if not provided.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
# Default to backend/story_videos directory
base_dir = Path(__file__).parent.parent.parent
self.output_dir = base_dir / "story_videos"
# Create output directory if it doesn't exist
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[StoryVideoGeneration] Initialized with output directory: {self.output_dir}")
def _generate_video_filename(self, story_title: str = "story") -> str:
"""Generate a unique filename for a story video."""
# Clean story title for filename
clean_title = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in story_title[:30])
unique_id = str(uuid.uuid4())[:8]
return f"story_{clean_title}_{unique_id}.mp4"
def save_scene_video(self, video_bytes: bytes, scene_number: int, user_id: str) -> Dict[str, str]:
"""
Save individual scene video bytes to file.
Parameters:
video_bytes: Raw video file bytes (mp4/webm format)
scene_number: Scene number for naming
user_id: Clerk user ID for naming
Returns:
Dict[str, str]: Video metadata with video_url and video_filename
"""
try:
# Generate filename with scene number and user ID
clean_user_id = "".join(c if c.isalnum() or c in ('-', '_') else '_' for c in user_id[:16])
timestamp = str(uuid.uuid4())[:8]
filename = f"scene_{scene_number}_{clean_user_id}_{timestamp}.mp4"
video_path = self.output_dir / filename
# Write video bytes to file
with open(video_path, 'wb') as f:
f.write(video_bytes)
file_size = video_path.stat().st_size
logger.info(f"[StoryVideoGeneration] Saved scene {scene_number} video: {filename} ({file_size} bytes)")
# Generate URL path (relative to /api/story/videos/)
video_url = f"/api/story/videos/{filename}"
return {
"video_filename": filename,
"video_url": video_url,
"video_path": str(video_path),
"file_size": file_size
}
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error saving scene video: {e}", exc_info=True)
raise RuntimeError(f"Failed to save scene video: {str(e)}") from e
def generate_scene_video(
self,
scene: Dict[str, Any],
image_path: str,
audio_path: str,
user_id: str,
duration: Optional[float] = None,
fps: int = 24
) -> Dict[str, Any]:
"""
Generate a video clip for a single story scene.
Parameters:
scene (Dict[str, Any]): Scene data.
image_path (str): Path to the scene image file.
audio_path (str): Path to the scene audio file.
user_id (str): Clerk user ID for subscription checking (for future usage tracking).
duration (float, optional): Video duration in seconds. If None, uses audio duration.
fps (int): Frames per second for video (default: 24).
Returns:
Dict[str, Any]: Video metadata including file path, URL, and scene info.
"""
scene_number = scene.get("scene_number", 0)
scene_title = scene.get("title", "Untitled")
try:
logger.info(f"[StoryVideoGeneration] Generating video for scene {scene_number}: {scene_title}")
# Import MoviePy
try:
# MoviePy v2.x exposes classes at top-level (moviepy.ImageClip, etc)
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips
except Exception as _imp_err:
# Detailed diagnostics to help users fix environment issues
try:
import sys as _sys
import platform as _platform
import importlib
mv = None
imv = None
ff_path = "unresolved"
try:
mv = importlib.import_module("moviepy")
except Exception:
pass
try:
imv = importlib.import_module("imageio")
except Exception:
pass
try:
import imageio_ffmpeg as _iff
ff_path = _iff.get_ffmpeg_exe()
except Exception:
pass
logger.error(
"[StoryVideoGeneration] MoviePy import failed. "
f"py={_sys.executable} plat={_platform.platform()} "
f"moviepy_ver={getattr(mv,'__version__', 'NA')} "
f"imageio_ver={getattr(imv,'__version__', 'NA')} "
f"ffmpeg_path={ff_path} err={_imp_err}"
)
except Exception:
# best-effort diagnostics
pass
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
# Load image and audio
image_file = Path(image_path)
audio_file = Path(audio_path)
if not image_file.exists():
raise FileNotFoundError(f"Image not found: {image_path}")
if not audio_file.exists():
raise FileNotFoundError(f"Audio not found: {audio_path}")
# Load audio to get duration
audio_clip = AudioFileClip(str(audio_file))
audio_duration = audio_clip.duration
# Use provided duration or audio duration
video_duration = duration if duration is not None else audio_duration
# Create image clip (MoviePy v2: use with_* API)
image_clip = ImageClip(str(image_file)).with_duration(video_duration)
image_clip = image_clip.with_fps(fps)
# Set audio to image clip
video_clip = image_clip.with_audio(audio_clip)
# Generate video filename
video_filename = f"scene_{scene_number}_{scene_title.replace(' ', '_').replace('/', '_')[:50]}_{uuid.uuid4().hex[:8]}.mp4"
video_path = self.output_dir / video_filename
# Write video file
video_clip.write_videofile(
str(video_path),
fps=fps,
codec='libx264',
audio_codec='aac',
preset='medium',
threads=4,
logger=None # Disable MoviePy's default logger
)
# Clean up clips
video_clip.close()
audio_clip.close()
image_clip.close()
# Get file size
file_size = video_path.stat().st_size
logger.info(f"[StoryVideoGeneration] Saved video to: {video_path} ({file_size} bytes)")
# Return video metadata
return {
"scene_number": scene_number,
"scene_title": scene_title,
"video_path": str(video_path),
"video_filename": video_filename,
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
"duration": video_duration,
"fps": fps,
"file_size": file_size,
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error generating video for scene {scene_number}: {e}")
raise RuntimeError(f"Failed to generate video for scene {scene_number}: {str(e)}") from e
def generate_story_video(
self,
scenes: List[Dict[str, Any]],
image_paths: List[Optional[str]],
audio_paths: List[str],
user_id: str,
story_title: str = "Story",
fps: int = 24,
transition_duration: float = 0.5,
progress_callback: Optional[callable] = None,
video_paths: Optional[List[Optional[str]]] = None
) -> Dict[str, Any]:
"""
Generate a complete story video from multiple scenes.
Parameters:
scenes (List[Dict[str, Any]]): List of scene data.
image_paths (List[Optional[str]]): List of image file paths (None if scene has animated video).
audio_paths (List[str]): List of audio file paths for each scene.
user_id (str): Clerk user ID for subscription checking.
story_title (str): Title of the story (default: "Story").
fps (int): Frames per second for video (default: 24).
transition_duration (float): Duration of transitions between scenes in seconds (default: 0.5).
progress_callback (callable, optional): Callback function for progress updates.
video_paths (Optional[List[Optional[str]]]): List of animated video file paths (None if scene has static image).
Returns:
Dict[str, Any]: Video metadata including file path, URL, and story info.
"""
if not scenes or not audio_paths:
raise ValueError("Scenes and audio paths are required")
if len(scenes) != len(audio_paths):
raise ValueError("Number of scenes and audio paths must match")
video_paths = video_paths or [None] * len(scenes)
if len(video_paths) != len(scenes):
video_paths = video_paths + [None] * (len(scenes) - len(video_paths))
try:
logger.info(f"[StoryVideoGeneration] Generating story video for {len(scenes)} scenes")
# Import MoviePy
try:
from moviepy import ImageClip, AudioFileClip, concatenate_videoclips
except Exception as _imp_err:
# Detailed diagnostics to help users fix environment issues
try:
import sys as _sys
import platform as _platform
import importlib
mv = None
imv = None
ff_path = "unresolved"
try:
mv = importlib.import_module("moviepy")
except Exception:
pass
try:
imv = importlib.import_module("imageio")
except Exception:
pass
try:
import imageio_ffmpeg as _iff
ff_path = _iff.get_ffmpeg_exe()
except Exception:
pass
logger.error(
"[StoryVideoGeneration] MoviePy import failed. "
f"py={_sys.executable} plat={_platform.platform()} "
f"moviepy_ver={getattr(mv,'__version__', 'NA')} "
f"imageio_ver={getattr(imv,'__version__', 'NA')} "
f"ffmpeg_path={ff_path} err={_imp_err}"
)
except Exception:
pass
logger.error("[StoryVideoGeneration] MoviePy not installed. Install with: pip install moviepy imageio imageio-ffmpeg")
raise RuntimeError("MoviePy is not installed. Please install it to generate videos.")
scene_clips = []
total_duration = 0.0
# Import VideoFileClip for animated videos
try:
from moviepy import VideoFileClip
except ImportError:
VideoFileClip = None
for idx, (scene, image_path, audio_path, video_path) in enumerate(zip(scenes, image_paths, audio_paths, video_paths)):
try:
scene_number = scene.get("scene_number", idx + 1)
scene_title = scene.get("title", "Untitled")
logger.info(f"[StoryVideoGeneration] Processing scene {scene_number}/{len(scenes)}: {scene_title}")
audio_file = Path(audio_path)
if not audio_file.exists():
logger.warning(f"[StoryVideoGeneration] Audio not found: {audio_path}, skipping scene {scene_number}")
continue
# Load audio
audio_clip = AudioFileClip(str(audio_file))
audio_duration = audio_clip.duration
# Prefer animated video if available
if video_path and Path(video_path).exists():
logger.info(f"[StoryVideoGeneration] Using animated video for scene {scene_number}: {video_path}")
# Load animated video
if VideoFileClip is None:
raise RuntimeError("VideoFileClip not available - MoviePy may not be fully installed")
video_clip = VideoFileClip(str(video_path))
# Replace audio with the preferred audio (AI or free)
video_clip = video_clip.with_audio(audio_clip)
# Match duration to audio if needed
if video_clip.duration > audio_duration:
video_clip = video_clip.subclip(0, audio_duration)
elif video_clip.duration < audio_duration:
# Loop the video if it's shorter than audio
loops_needed = int(audio_duration / video_clip.duration) + 1
video_clip = concatenate_videoclips([video_clip] * loops_needed).subclip(0, audio_duration)
video_clip = video_clip.with_audio(audio_clip)
elif image_path and Path(image_path).exists():
# Fall back to static image
logger.info(f"[StoryVideoGeneration] Using static image for scene {scene_number}: {image_path}")
image_file = Path(image_path)
# Create image clip (MoviePy v2: use with_* API)
image_clip = ImageClip(str(image_file)).with_duration(audio_duration)
image_clip = image_clip.with_fps(fps)
# Set audio to image clip
video_clip = image_clip.with_audio(audio_clip)
else:
logger.warning(f"[StoryVideoGeneration] No video or image found for scene {scene_number}, skipping")
continue
scene_clips.append(video_clip)
total_duration += audio_duration
# Call progress callback if provided
if progress_callback:
progress = ((idx + 1) / len(scenes)) * 90 # Reserve 10% for final composition
progress_callback(progress, f"Processed scene {scene_number}/{len(scenes)}")
logger.info(f"[StoryVideoGeneration] Processed scene {idx + 1}/{len(scenes)}")
except Exception as e:
logger.error(f"[StoryVideoGeneration] Failed to process scene {idx + 1}: {e}")
# Continue with next scene instead of failing completely
continue
if not scene_clips:
raise RuntimeError("No valid scene clips were created")
# Concatenate all scene clips
logger.info(f"[StoryVideoGeneration] Concatenating {len(scene_clips)} scene clips")
final_video = concatenate_videoclips(scene_clips, method="compose")
# Generate video filename
video_filename = self._generate_video_filename(story_title)
video_path = self.output_dir / video_filename
# Call progress callback
if progress_callback:
progress_callback(95, "Rendering final video...")
# Write video file
final_video.write_videofile(
str(video_path),
fps=fps,
codec='libx264',
audio_codec='aac',
preset='medium',
threads=4,
logger=None # Disable MoviePy's default logger
)
# Get file size
file_size = video_path.stat().st_size
# Clean up clips
final_video.close()
for clip in scene_clips:
clip.close()
# Call progress callback
if progress_callback:
progress_callback(100, "Video generation complete!")
logger.info(f"[StoryVideoGeneration] Saved story video to: {video_path} ({file_size} bytes)")
# Return video metadata
return {
"video_path": str(video_path),
"video_filename": video_filename,
"video_url": f"/api/story/videos/{video_filename}", # API endpoint to serve videos
"duration": total_duration,
"fps": fps,
"file_size": file_size,
"num_scenes": len(scene_clips),
}
except HTTPException:
# Re-raise HTTPExceptions (e.g., 429 subscription limit)
raise
except Exception as e:
logger.error(f"[StoryVideoGeneration] Error generating story video: {e}")
raise RuntimeError(f"Failed to generate story video: {str(e)}") from e