Files
AiVideo/engine/adapters/video/moviepy_adapter.py
2026-03-25 19:35:37 +08:00

82 lines
2.4 KiB
Python

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)