forked from Rockachopa/Timmy-time-dashboard
275 lines
9.3 KiB
Python
275 lines
9.3 KiB
Python
"""MoviePy v2.2.1 episode builder.
|
|
|
|
Composes a full episode video from:
|
|
- Intro card (Timmy branding still image + title text)
|
|
- Highlight clips with crossfade transitions
|
|
- TTS narration audio mixed over video
|
|
- Background music from pre-generated library
|
|
- Outro card with links / subscribe prompt
|
|
|
|
MoviePy is an optional dependency. If it is not installed, all functions
|
|
return failure results instead of crashing.
|
|
|
|
Usage
|
|
-----
|
|
from content.composition.episode import build_episode
|
|
|
|
result = await build_episode(
|
|
clip_paths=["/tmp/clips/h1.mp4", "/tmp/clips/h2.mp4"],
|
|
narration_path="/tmp/narration.wav",
|
|
output_path="/tmp/episodes/ep001.mp4",
|
|
title="Top Highlights — March 2026",
|
|
)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
from config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class EpisodeResult:
|
|
"""Result of an episode composition attempt."""
|
|
|
|
success: bool
|
|
output_path: str | None = None
|
|
duration: float = 0.0
|
|
error: str | None = None
|
|
clip_count: int = 0
|
|
|
|
|
|
@dataclass
|
|
class EpisodeSpec:
|
|
"""Full specification for a composed episode."""
|
|
|
|
title: str
|
|
clip_paths: list[str] = field(default_factory=list)
|
|
narration_path: str | None = None
|
|
music_path: str | None = None
|
|
intro_image: str | None = None
|
|
outro_image: str | None = None
|
|
output_path: str | None = None
|
|
transition_duration: float | None = None
|
|
|
|
@property
|
|
def resolved_transition(self) -> float:
|
|
return (
|
|
self.transition_duration
|
|
if self.transition_duration is not None
|
|
else settings.video_transition_duration
|
|
)
|
|
|
|
@property
|
|
def resolved_output(self) -> str:
|
|
return self.output_path or str(
|
|
Path(settings.content_episodes_dir) / f"{_slugify(self.title)}.mp4"
|
|
)
|
|
|
|
|
|
def _slugify(text: str) -> str:
|
|
"""Convert title to a filesystem-safe slug."""
|
|
import re
|
|
|
|
slug = text.lower()
|
|
slug = re.sub(r"[^\w\s-]", "", slug)
|
|
slug = re.sub(r"[\s_]+", "-", slug)
|
|
slug = slug.strip("-")
|
|
return slug[:80] or "episode"
|
|
|
|
|
|
def _moviepy_available() -> bool:
|
|
"""Return True if moviepy is importable."""
|
|
try:
|
|
import importlib.util
|
|
|
|
return importlib.util.find_spec("moviepy") is not None
|
|
except Exception:
|
|
return False
|
|
|
|
|
|
def _compose_sync(spec: EpisodeSpec) -> EpisodeResult:
|
|
"""Synchronous MoviePy composition — run in a thread via asyncio.to_thread."""
|
|
try:
|
|
from moviepy import ( # type: ignore[import]
|
|
AudioFileClip,
|
|
ColorClip,
|
|
CompositeAudioClip,
|
|
ImageClip,
|
|
TextClip,
|
|
VideoFileClip,
|
|
concatenate_videoclips,
|
|
)
|
|
except ImportError as exc:
|
|
return EpisodeResult(success=False, error=f"moviepy not available: {exc}")
|
|
|
|
clips = []
|
|
|
|
# ── Intro card ────────────────────────────────────────────────────────────
|
|
intro_duration = 3.0
|
|
if spec.intro_image and Path(spec.intro_image).exists():
|
|
intro = ImageClip(spec.intro_image).with_duration(intro_duration)
|
|
else:
|
|
intro = ColorClip(size=(1280, 720), color=(10, 10, 30), duration=intro_duration)
|
|
try:
|
|
title_txt = TextClip(
|
|
text=spec.title,
|
|
font_size=48,
|
|
color="white",
|
|
size=(1200, None),
|
|
method="caption",
|
|
).with_duration(intro_duration)
|
|
title_txt = title_txt.with_position("center")
|
|
from moviepy import CompositeVideoClip # type: ignore[import]
|
|
|
|
intro = CompositeVideoClip([intro, title_txt])
|
|
except Exception as exc:
|
|
logger.warning("Could not add title text to intro: %s", exc)
|
|
|
|
clips.append(intro)
|
|
|
|
# ── Highlight clips with crossfade ────────────────────────────────────────
|
|
valid_clips: list = []
|
|
for path in spec.clip_paths:
|
|
if not Path(path).exists():
|
|
logger.warning("Clip not found, skipping: %s", path)
|
|
continue
|
|
try:
|
|
vc = VideoFileClip(path)
|
|
valid_clips.append(vc)
|
|
except Exception as exc:
|
|
logger.warning("Could not load clip %s: %s", path, exc)
|
|
|
|
if valid_clips:
|
|
transition = spec.resolved_transition
|
|
for vc in valid_clips:
|
|
try:
|
|
vc = vc.with_effects([]) # ensure no stale effects
|
|
clips.append(vc.crossfadein(transition))
|
|
except Exception:
|
|
clips.append(vc)
|
|
|
|
# ── Outro card ────────────────────────────────────────────────────────────
|
|
outro_duration = 5.0
|
|
if spec.outro_image and Path(spec.outro_image).exists():
|
|
outro = ImageClip(spec.outro_image).with_duration(outro_duration)
|
|
else:
|
|
outro = ColorClip(size=(1280, 720), color=(10, 10, 30), duration=outro_duration)
|
|
clips.append(outro)
|
|
|
|
if not clips:
|
|
return EpisodeResult(success=False, error="no clips to compose")
|
|
|
|
# ── Concatenate ───────────────────────────────────────────────────────────
|
|
try:
|
|
final = concatenate_videoclips(clips, method="compose")
|
|
except Exception as exc:
|
|
return EpisodeResult(success=False, error=f"concatenation failed: {exc}")
|
|
|
|
# ── Narration audio ───────────────────────────────────────────────────────
|
|
audio_tracks = []
|
|
if spec.narration_path and Path(spec.narration_path).exists():
|
|
try:
|
|
narr = AudioFileClip(spec.narration_path)
|
|
if narr.duration > final.duration:
|
|
narr = narr.subclipped(0, final.duration)
|
|
audio_tracks.append(narr)
|
|
except Exception as exc:
|
|
logger.warning("Could not load narration audio: %s", exc)
|
|
|
|
if spec.music_path and Path(spec.music_path).exists():
|
|
try:
|
|
music = AudioFileClip(spec.music_path).with_volume_scaled(0.15)
|
|
if music.duration < final.duration:
|
|
# Loop music to fill episode duration
|
|
loops = int(final.duration / music.duration) + 1
|
|
from moviepy import concatenate_audioclips # type: ignore[import]
|
|
|
|
music = concatenate_audioclips([music] * loops).subclipped(
|
|
0, final.duration
|
|
)
|
|
else:
|
|
music = music.subclipped(0, final.duration)
|
|
audio_tracks.append(music)
|
|
except Exception as exc:
|
|
logger.warning("Could not load background music: %s", exc)
|
|
|
|
if audio_tracks:
|
|
try:
|
|
mixed = CompositeAudioClip(audio_tracks)
|
|
final = final.with_audio(mixed)
|
|
except Exception as exc:
|
|
logger.warning("Audio mixing failed, continuing without audio: %s", exc)
|
|
|
|
# ── Write output ──────────────────────────────────────────────────────────
|
|
output_path = spec.resolved_output
|
|
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
try:
|
|
final.write_videofile(
|
|
output_path,
|
|
codec=settings.default_video_codec,
|
|
audio_codec="aac",
|
|
logger=None,
|
|
)
|
|
except Exception as exc:
|
|
return EpisodeResult(success=False, error=f"write_videofile failed: {exc}")
|
|
|
|
return EpisodeResult(
|
|
success=True,
|
|
output_path=output_path,
|
|
duration=final.duration,
|
|
clip_count=len(valid_clips),
|
|
)
|
|
|
|
|
|
async def build_episode(
|
|
clip_paths: list[str],
|
|
title: str,
|
|
narration_path: str | None = None,
|
|
music_path: str | None = None,
|
|
intro_image: str | None = None,
|
|
outro_image: str | None = None,
|
|
output_path: str | None = None,
|
|
transition_duration: float | None = None,
|
|
) -> EpisodeResult:
|
|
"""Compose a full episode video asynchronously.
|
|
|
|
Wraps the synchronous MoviePy work in ``asyncio.to_thread`` so the
|
|
FastAPI event loop is never blocked.
|
|
|
|
Returns
|
|
-------
|
|
EpisodeResult
|
|
Always returns a result; never raises.
|
|
"""
|
|
if not _moviepy_available():
|
|
logger.warning("moviepy not installed — episode composition disabled")
|
|
return EpisodeResult(
|
|
success=False,
|
|
error="moviepy not available — install moviepy>=2.0",
|
|
)
|
|
|
|
spec = EpisodeSpec(
|
|
title=title,
|
|
clip_paths=clip_paths,
|
|
narration_path=narration_path,
|
|
music_path=music_path,
|
|
intro_image=intro_image,
|
|
outro_image=outro_image,
|
|
output_path=output_path,
|
|
transition_duration=transition_duration,
|
|
)
|
|
|
|
try:
|
|
return await asyncio.to_thread(_compose_sync, spec)
|
|
except Exception as exc:
|
|
logger.warning("Episode composition error: %s", exc)
|
|
return EpisodeResult(success=False, error=str(exc))
|