feat: voice clone audio generation + podcast workspace architecture

- Voice clone integration: When user selects voice clone in Write phase,
  backend uses their uploaded voice sample + scene script text to generate
  audio via qwen3/minimax/cosyvoice voice clone APIs
- Multi-tenant workspace storage: All podcast assets (audio, video, images,
  charts) now use workspace-specific directories per user
- Chart preview improvements: Card-based B-Roll charts UI with thumbnails,
  takeaway text, and action buttons; public endpoint for image serving
- Voice clone caching: In-memory LRU cache for voice samples (avoids
  re-downloading per scene); frontend caches voice clone metadata
- Thread pool for voice clone: Audio generation uses ThreadPoolExecutor to
  avoid blocking the FastAPI event loop
- Auto-detect voice clone IDs (vc_*, MY_VOICE_CLONE) to route correctly
- DB fallback for voice sample URL: Fetches from ContentAsset if not passed
- Fixed API URL resolution for chart previews
- Fixed GlassyCard DOM warnings for motion props
- Fixed ScriptGenerationProgressView syntax error
- Fixed usePodcastWorkflow scriptData reference
This commit is contained in:
ajaysi
2026-04-21 19:38:50 +05:30
parent 7637babd7d
commit 91b2f996fd
33 changed files with 1642 additions and 457 deletions

View File

@@ -12,7 +12,7 @@ import uuid
import os
import tempfile
from pathlib import Path
from typing import Dict, Any, Optional, List
from typing import Dict, Any, Optional, List, TYPE_CHECKING
from loguru import logger
# Import chart generators directly
@@ -34,21 +34,27 @@ from services.podcast.broll_composer import (
class BrollService:
"""Orchestrates B-roll composition for podcast scenes."""
def __init__(self, output_dir: Optional[str] = None):
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 temp directory.
output_dir: Base directory for B-roll output. Defaults to workspace chart directory.
user_id: User ID for multi-tenant workspace isolation.
"""
if output_dir:
self.output_dir = Path(output_dir)
else:
self.output_dir = Path(tempfile.gettempdir()) / "broll_output"
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
@@ -84,29 +90,91 @@ class BrollService:
resolved_chart_id = chart_id or uuid.uuid4().hex[:8]
out_path = str(self.get_chart_preview_path(resolved_chart_id))
# Debug logging
logger.warning(f"[BrollService] Generating: type={chart_type}, data keys={list(chart_data.keys())}")
try:
if chart_type == "bar_comparison":
make_bar_chart(chart_data, out_path, title, subtitle=subtitle)
# Accept both formats: {labels, before, after} OR {labels, values}
labels = chart_data.get("labels", [])
before = chart_data.get("before", [])
after = chart_data.get("after", [])
# If using new format (labels, values), treat as single bar chart
if not before and not after:
values = chart_data.get("values", [])
if values:
# Use original labels, set before to zeros, values go to after
before = [0] * len(labels)
after = values[:len(labels)]
# Create modified data dict with proper format for make_bar_chart
chart_data_for_render = {
"labels": labels,
"before": before,
"after": after
}
else:
chart_data_for_render = chart_data
else:
chart_data_for_render = chart_data
if not labels or (not before and not after):
logger.warning(f"[BrollService] Missing required data for bar_comparison: labels={len(labels)}, before={len(before)}, after={len(after)}")
return ""
if len(labels) != len(before) or len(labels) != len(after):
logger.warning(f"[BrollService] Data shape mismatch: labels={len(labels)}, before={len(before)}, after={len(after)}")
return ""
make_bar_chart(chart_data_for_render, out_path, title, subtitle=subtitle)
elif chart_type == "bar_horizontal":
labels = chart_data.get("labels", [])
values = chart_data.get("values", [])
if not labels or not values:
logger.warning("[BrollService] Missing required data for bar_horizontal")
return ""
make_horizontal_bar(chart_data, out_path, title)
elif chart_type == "line_trend":
labels = chart_data.get("labels", [])
values = chart_data.get("values", [])
if not labels or not values:
logger.warning("[BrollService] Missing required data for line_trend")
return ""
make_line_trend(chart_data, out_path, title)
elif chart_type == "pie":
make_pie_chart(chart_data, out_path, title)
elif chart_type == "pie":
labels = chart_data.get("labels", [])
values = chart_data.get("values", [])
if not labels or not values:
logger.warning("[BrollService] Missing required data for pie")
return ""
make_pie_chart(chart_data, out_path, title)
elif chart_type == "stacked_bar":
labels = chart_data.get("labels", [])
segments = chart_data.get("segments", [])
if not labels or not segments:
logger.warning("[BrollService] Missing required data for stacked_bar")
return ""
make_stacked_bar(chart_data, out_path, title)
elif chart_type == "bullet":
elif chart_type == "bullet" or chart_type == "bullet_points":
# Accept both: bullet_points OR labels
bullet_points = chart_data.get("bullet_points", [])
# If using new format, use labels as bullet points
if not bullet_points:
bullet_points = chart_data.get("labels", [])
if not bullet_points:
labels_fallback = chart_data.get("labels", [])
if labels_fallback:
bullet_points = labels_fallback
if bullet_points:
make_bullet_overlay(bullet_points, out_path)
else:
logger.warning("[BrollService] No bullet points provided")
return ""
else:
logger.warning(f"[BrollService] Unknown chart type: {chart_type}")
return ""
logger.warning(f"[BrollService] Unknown chart type: {chart_type}, falling back to bar_comparison")
# Try bar_comparison as fallback
try:
make_bar_chart(chart_data, out_path, title, subtitle=subtitle)
return out_path
except Exception as fallback_err:
logger.warning(f"[BrollService] Fallback also failed: {fallback_err}")
return ""
logger.info(f"[BrollService] Chart preview generated: {out_path}")
return out_path
@@ -254,13 +322,21 @@ class BrollService:
logger.warning(f"[BrollService] Failed to remove {file}: {e}")
# Singleton instance for reuse
_broll_service_instance: Optional[BrollService] = None
# Per-user service instances for multi-tenant isolation
_broll_service_instances: Dict[str, BrollService] = {}
def get_broll_service(output_dir: Optional[str] = None) -> BrollService:
"""Get or create B-roll service singleton."""
global _broll_service_instance
if _broll_service_instance is None:
_broll_service_instance = BrollService(output_dir=output_dir)
return _broll_service_instance
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]

View File

@@ -86,8 +86,8 @@ class PodcastService:
) -> Optional[PodcastProject]:
"""Update project fields."""
from loguru import logger
logger.warning(f"[PodcastService] update_project: user_id={user_id}, project_id={project_id}")
logger.warning(f"[PodcastService] update_project: updates={updates}")
updated_fields = list(updates.keys()) if isinstance(updates, dict) else []
logger.warning(f"[PodcastService] update_project: user_id={user_id}, project_id={project_id}, fields={updated_fields}")
project = self.get_project(user_id, project_id)
if not project: