forked from Rockachopa/Timmy-time-dashboard
This commit is contained in:
1
src/content/composition/__init__.py
Normal file
1
src/content/composition/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Episode composition from extracted clips."""
|
||||
274
src/content/composition/episode.py
Normal file
274
src/content/composition/episode.py
Normal file
@@ -0,0 +1,274 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user