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:
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user