Files
ALwrity/backend/services/podcast/broll_service.py
ajaysi 3d3bcceb45 Merge branch 'pr-483'
# Conflicts:
#	backend/services/podcast/broll_composer.py
#	backend/services/podcast/broll_service.py
2026-05-23 13:37:44 +05:30

259 lines
8.8 KiB
Python

"""
B-Roll Service - Orchestrator for programmatic B-roll video composition.
This service handles:
- Chart data extraction from research
- Individual scene B-roll video generation
- Final video composition from multiple B-roll scenes
Chart preview generation is delegated to the shared ChartService.
"""
import json
import uuid
import os
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional, List, TYPE_CHECKING
from loguru import logger
# Import video compositing from broll_composer
from services.podcast.broll_composer import (
Insight,
SceneAssets,
dispatch_scene,
compose_video,
make_insight_card,
)
# Import shared chart service for preview generation
from services.chart_service import ChartService, get_chart_service
class BrollService:
"""Orchestrates B-roll composition for podcast scenes."""
def __init__(self, output_dir: Optional[str] = None, user_id: Optional[str] = None):
"""
Initialize B-roll service.
Args:
output_dir: Base directory for B-roll output. Defaults to workspace chart directory.
user_id: User ID for multi-tenant workspace isolation.
"""
self._user_id = user_id
if output_dir:
self.output_dir = Path(output_dir)
else:
self.output_dir = self._get_chart_dir(user_id)
self.output_dir.mkdir(parents=True, exist_ok=True)
logger.info(f"[BrollService] Initialized with output directory: {self.output_dir}")
def _get_chart_dir(self, user_id: Optional[str] = None) -> Path:
"""Get chart directory from podcast constants (workspace-aware)."""
from api.podcast.constants import get_podcast_media_dir
return get_podcast_media_dir("chart", user_id, ensure_exists=True)
def get_output_path(self, filename: str) -> Path:
"""Get output path for a file."""
return self.output_dir / filename
def get_chart_preview_filename(self, chart_id: str) -> str:
"""Build deterministic chart preview filename from chart ID."""
return f"chart_preview_{chart_id}.png"
def get_chart_preview_path(self, chart_id: str) -> Path:
"""Get deterministic chart preview path from chart ID."""
return self.get_output_path(self.get_chart_preview_filename(chart_id))
def generate_chart_preview(
self,
chart_data: Dict[str, Any],
chart_type: str = "bar_comparison",
title: str = "",
subtitle: str = "",
chart_id: Optional[str] = None,
) -> str:
"""
Generate a chart PNG preview (static, for Write phase).
Delegates to ChartService for rendering, then returns the local file path.
"""
resolved_chart_id = chart_id or uuid.uuid4().hex[:8]
logger.info(f"[BrollService] Generating chart preview: type={chart_type}, id={resolved_chart_id}")
chart_svc = get_chart_service(user_id=self._user_id)
result = chart_svc.generate_chart(
chart_data=chart_data,
chart_type=chart_type,
title=title,
subtitle=subtitle or "",
chart_id=resolved_chart_id,
)
return result.get("path", "")
def generate_scene_broll(
self,
scene_id: str,
key_insight: str,
supporting_stat: str,
chart_data: Optional[Dict[str, Any]],
visual_cue: str, # bar_comparison, bar_horizontal, line_trend, pie, stacked_bar, bullet_points, full_avatar
duration: float,
background_img_path: str,
avatar_video_path: Optional[str] = None,
) -> str:
"""
Generate a B-roll video for a single scene.
Args:
scene_id: Scene identifier
key_insight: Main insight text for overlay
supporting_stat: Supporting statistic text
chart_data: Chart data dict (optional)
visual_cue: Type of scene to build
duration: Scene duration in seconds
background_img_path: Path to background image
avatar_video_path: Path to avatar video (optional)
Returns:
Path to generated video file
"""
scene_id_safe = scene_id.replace(" ", "_").replace("/", "_")
out_path = str(self.get_output_path(f"broll_{scene_id_safe}.mp4"))
try:
insight = Insight(
key_insight=key_insight,
supporting_stat=supporting_stat,
visual_cue=visual_cue,
audio_tone="neutral",
chart_data=chart_data or {},
duration=duration,
)
assets = SceneAssets(
background_img=background_img_path,
avatar_video=avatar_video_path,
)
scene_temp_dir = self.get_output_path(
f"scene_assets_{scene_id_safe}_{uuid.uuid4().hex[:8]}"
)
scene_temp_dir.mkdir(parents=True, exist_ok=True)
# Generate the scene
scene = dispatch_scene(insight, assets, temp_dir=scene_temp_dir)
# Write video
compose_video([scene], output_path=out_path)
logger.info(f"[BrollService] B-roll scene generated: {out_path}")
return out_path
except Exception as e:
logger.error(f"[BrollService] Failed to generate B-roll scene: {e}")
raise
def compose_final_video(
self,
video_paths: List[str],
output_filename: str,
fade_dur: float = 0.5,
fps: int = 24,
) -> str:
"""
Compose multiple B-roll scene videos into final video.
Args:
video_paths: List of video file paths to compose
output_filename: Output filename
fade_dur: Crossfade duration between scenes
fps: Output FPS
Returns:
Path to final composed video
"""
out_path = str(self.get_output_path(output_filename))
try:
scenes = []
for video_path in video_paths:
from moviepy import VideoFileClip
clip = VideoFileClip(video_path)
scenes.append(clip)
if not scenes:
raise ValueError("No video clips provided")
# Use crossfade_concat from broll_composer
from services.podcast.broll_composer import crossfade_concat
final = crossfade_concat(scenes, fade_dur=fade_dur)
final.write_videofile(
out_path,
fps=fps,
codec="libx264",
audio_codec="aac",
threads=4,
preset="fast",
logger=None,
)
# Close clips
for clip in scenes:
clip.close()
logger.info(f"[BrollService] Final video composed: {out_path}")
return out_path
except Exception as e:
logger.error(f"[BrollService] Failed to compose final video: {e}")
raise
def cleanup(self, file_paths: Optional[List[str]] = None):
"""
Clean up temporary B-roll files.
Args:
file_paths: Specific files to delete. If None, cleans output directory.
"""
if file_paths:
for path in file_paths:
try:
if os.path.exists(path):
os.remove(path)
logger.debug(f"[BrollService] Removed: {path}")
except Exception as e:
logger.warning(f"[BrollService] Failed to remove {path}: {e}")
else:
# Clean entire output directory
for file in self.output_dir.glob("*"):
try:
file.unlink()
except Exception as e:
logger.warning(f"[BrollService] Failed to remove {file}: {e}")
# Per-user service instances for multi-tenant isolation
_broll_service_instances: Dict[str, BrollService] = {}
def get_broll_service(output_dir: Optional[str] = None, user_id: Optional[str] = None) -> BrollService:
"""
Get or create B-roll service for the given user.
For multi-tenant isolation, pass user_id to get user-specific directory.
"""
if output_dir:
return BrollService(output_dir=output_dir)
# Create per-user instance based on user_id
cache_key = user_id or "default"
if cache_key not in _broll_service_instances:
_broll_service_instances[cache_key] = BrollService(user_id=user_id)
return _broll_service_instances[cache_key]