from __future__ import annotations import os from pathlib import Path from typing import Any import numpy as np from moviepy import AudioFileClip, VideoClip from PIL import Image from engine.config import AppConfig from .base import BaseVideoGen class MoviePyVideoGen(BaseVideoGen): def __init__(self, cfg: AppConfig): self.cfg = cfg def generate(self, image_path: str, prompt: dict, output_path: str | Path) -> str: output_path = Path(output_path) output_path.parent.mkdir(parents=True, exist_ok=True) # Required prompt fields for shot rendering. duration_s = float(prompt.get("duration_s", 3)) fps = int(prompt.get("fps", self.cfg.get("video.mock_fps", 24))) audio_path = prompt.get("audio_path") # Clip resolution. size = prompt.get("size") if isinstance(size, (list, tuple)) and len(size) == 2: w, h = int(size[0]), int(size[1]) else: mock_size = self.cfg.get("video.mock_size", [1024, 576]) w, h = int(mock_size[0]), int(mock_size[1]) base_img = Image.open(image_path).convert("RGB") def make_frame(t: float): progress = float(t) / max(duration_s, 1e-6) progress = max(0.0, min(1.0, progress)) scale = 1.0 + 0.03 * progress new_w = max(w, int(w * scale)) new_h = max(h, int(h * scale)) frame = base_img.resize((new_w, new_h), Image.LANCZOS) left = (new_w - w) // 2 top = (new_h - h) // 2 frame = frame.crop((left, top, left + w, top + h)) return np.array(frame) video = VideoClip(make_frame, duration=duration_s, has_constant_size=True) # Optional audio. if audio_path and os.path.exists(str(audio_path)): a = AudioFileClip(str(audio_path)) video = video.with_audio(a) else: a = None try: video.write_videofile( str(output_path), fps=fps, codec="libx264", audio_codec="aac", preset="veryfast", threads=2, ) finally: try: video.close() except Exception: pass if a is not None: try: a.close() except Exception: pass return str(output_path)