This repository has been archived on 2026-03-24. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Timmy-time-dashboard/src/content/composition/episode.py

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))