Frontend Changes: - Add scene numbering badge (1/N) next to scene titles - Add inline status chips (Complete, Audio, Image, Voice, Why Script) - Professional AI-like gradient styling for all chips with shadows - Remove Script Editor header and 'Why This Script Format?' collapsible - Move Voice and Why Script info to per-scene chips - Make scene section mobile-responsive (responsive layout, button sizing) - Rename 'B-Roll Charts' to 'Podcast Charts' with accordion (collapsed by default) - Add sceneIndex prop to SceneEditor for scene numbering - Enhanced accessibility with keyboard navigation and focus states Backend Changes: - Audio handler improvements - B-roll handler enhancements - Script handler updates - B-roll composer and service improvements - Removed temporary broll_temp files Technical: - Full mobile responsiveness for scene cards - Gradient chip styling: vibrant colors with white text and shadows - Non-breaking approval/generation flow preserved - TypeScript compatibility maintained
399 lines
14 KiB
Python
399 lines
14 KiB
Python
"""
|
|
B-Roll Handlers
|
|
|
|
API endpoints for B-roll chart preview and video generation.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
|
|
from fastapi.responses import FileResponse
|
|
from typing import Dict, Any, Optional, List
|
|
from pydantic import BaseModel, Field
|
|
from pathlib import Path
|
|
import uuid
|
|
|
|
from middleware.auth_middleware import get_current_user, get_current_user_with_query_token
|
|
from api.story_writer.utils.auth import require_authenticated_user
|
|
from api.story_writer.task_manager import task_manager
|
|
from api.podcast.utils import _resolve_podcast_media_file
|
|
from services.podcast.broll_service import get_broll_service
|
|
from utils.media_utils import resolve_media_path
|
|
from loguru import logger
|
|
|
|
|
|
router = APIRouter(prefix="/broll", tags=["B-Roll"])
|
|
|
|
|
|
def _resolve_broll_background_image_path(background_image_url: str) -> str:
|
|
"""Resolve background image URL/path to a local file path."""
|
|
resolved = resolve_media_path(background_image_url)
|
|
if not resolved:
|
|
raise HTTPException(status_code=404, detail=f"Background image not found: {background_image_url}")
|
|
return str(resolved)
|
|
|
|
|
|
def _resolve_broll_avatar_video_path(avatar_video_url: Optional[str], user_id: str) -> Optional[str]:
|
|
"""Resolve optional avatar video URL/path to a local file path."""
|
|
if not avatar_video_url:
|
|
return None
|
|
|
|
parsed = urlparse(avatar_video_url)
|
|
path = parsed.path if parsed.scheme else avatar_video_url
|
|
|
|
if "/api/podcast/videos/" in path:
|
|
filename = path.split("/api/podcast/videos/", 1)[1].split("?", 1)[0].strip()
|
|
if not filename:
|
|
raise HTTPException(status_code=400, detail="Invalid avatar video URL")
|
|
return str(_resolve_podcast_media_file(filename, "video", user_id))
|
|
|
|
local_path = Path(path).expanduser().resolve()
|
|
if local_path.exists() and local_path.is_file():
|
|
return str(local_path)
|
|
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(
|
|
"Unsupported avatar video URL format. "
|
|
"Use /api/podcast/videos/{filename} or a valid local file path."
|
|
),
|
|
)
|
|
|
|
|
|
def _execute_broll_scene_task(
|
|
task_id: str,
|
|
*,
|
|
scene_id: str,
|
|
key_insight: str,
|
|
supporting_stat: str,
|
|
chart_data: Optional[Dict[str, Any]],
|
|
visual_cue: str,
|
|
duration: float,
|
|
background_img_path: str,
|
|
avatar_video_path: Optional[str],
|
|
):
|
|
"""Background task for rendering a B-roll scene."""
|
|
try:
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"processing",
|
|
progress=10.0,
|
|
message="Starting B-roll scene render...",
|
|
)
|
|
|
|
broll_service = get_broll_service()
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"processing",
|
|
progress=35.0,
|
|
message="Composing scene layers and overlays...",
|
|
)
|
|
|
|
video_path = broll_service.generate_scene_broll(
|
|
scene_id=scene_id,
|
|
key_insight=key_insight,
|
|
supporting_stat=supporting_stat,
|
|
chart_data=chart_data,
|
|
visual_cue=visual_cue,
|
|
duration=duration,
|
|
background_img_path=background_img_path,
|
|
avatar_video_path=avatar_video_path,
|
|
)
|
|
|
|
filename = Path(video_path).name
|
|
video_url = f"/api/podcast/broll/final/{filename}"
|
|
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"completed",
|
|
progress=100.0,
|
|
message="B-roll scene render completed.",
|
|
result={
|
|
"scene_id": scene_id,
|
|
"broll_video_path": video_path,
|
|
"broll_video_url": video_url,
|
|
},
|
|
)
|
|
except Exception as exc:
|
|
logger.error(f"[Broll] Task {task_id} failed: {exc}")
|
|
task_manager.update_task_status(
|
|
task_id,
|
|
"failed",
|
|
error=f"B-roll scene render failed: {str(exc)}",
|
|
error_status=500,
|
|
)
|
|
|
|
|
|
class ChartPreviewRequest(BaseModel):
|
|
"""Request model for chart preview generation."""
|
|
chart_data: Dict[str, Any] = Field(..., description="Chart data (labels, before/after, etc.)")
|
|
chart_type: str = Field(
|
|
default="bar_comparison",
|
|
description="bar_comparison | bar_horizontal | line_trend | pie | stacked_bar | bullet"
|
|
)
|
|
title: str = Field(default="", description="Chart title")
|
|
subtitle: Optional[str] = Field(default="", description="Optional subtitle at bottom")
|
|
|
|
|
|
class ChartPreviewResponse(BaseModel):
|
|
"""Response for chart preview."""
|
|
preview_url: str
|
|
chart_id: str
|
|
|
|
|
|
class BrollSceneRequest(BaseModel):
|
|
"""Request for generating B-roll video for a scene."""
|
|
scene_id: str
|
|
key_insight: str
|
|
supporting_stat: str
|
|
chart_data: Optional[Dict[str, Any]] = None
|
|
visual_cue: str = Field(default="bar_comparison", description="bar_comparison | bar_horizontal | line_trend | pie | stacked_bar | bullet_points | full_avatar")
|
|
duration: float = Field(default=10.0, ge=3.0, le=60.0)
|
|
background_image_url: str
|
|
avatar_video_url: Optional[str] = None
|
|
|
|
|
|
class BrollSceneResponse(BaseModel):
|
|
"""Response for B-roll scene generation."""
|
|
scene_id: str
|
|
broll_video_url: str = ""
|
|
broll_video_path: str = ""
|
|
task_id: Optional[str] = None
|
|
status: str = "completed"
|
|
message: Optional[str] = None
|
|
|
|
|
|
class BrollComposeRequest(BaseModel):
|
|
"""Request for composing multiple B-roll videos."""
|
|
scene_video_paths: List[str]
|
|
output_filename: str = "final_broll.mp4"
|
|
fade_dur: float = Field(default=0.5, ge=0.0, le=2.0)
|
|
fps: int = Field(default=24, ge=12, le=60)
|
|
|
|
|
|
class BrollComposeResponse(BaseModel):
|
|
"""Response for B-roll composition."""
|
|
final_video_url: str
|
|
final_video_path: str
|
|
|
|
|
|
@router.post("/preview/chart", response_model=ChartPreviewResponse)
|
|
async def generate_chart_preview(
|
|
request: ChartPreviewRequest,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Generate a chart PNG preview (static image for Write phase).
|
|
|
|
This endpoint is called from the Write phase to show users chart previews
|
|
before they commit to B-roll video generation.
|
|
"""
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
# Debug logging
|
|
logger.warning(f"[Broll] Chart preview request: type={request.chart_type}, title={request.title}, chart_data keys={list(request.chart_data.keys())}, user_id={user_id}")
|
|
|
|
try:
|
|
broll_service = get_broll_service(user_id=user_id)
|
|
chart_id = uuid.uuid4().hex[:8]
|
|
|
|
preview_path = broll_service.generate_chart_preview(
|
|
chart_data=request.chart_data,
|
|
chart_type=request.chart_type,
|
|
title=request.title,
|
|
subtitle=request.subtitle or "",
|
|
chart_id=chart_id,
|
|
)
|
|
|
|
# If chart generation failed (empty path), return a placeholder instead of 500
|
|
if not preview_path:
|
|
# Return a fallback response so frontend doesn't crash
|
|
logger.warning(f"[Broll] Chart preview skipped - invalid data for type: {request.chart_type}")
|
|
return ChartPreviewResponse(
|
|
preview_url="",
|
|
chart_id=chart_id,
|
|
)
|
|
|
|
preview_filename = Path(preview_path).name
|
|
preview_url = f"/api/podcast/broll/preview/{chart_id}/{preview_filename}"
|
|
|
|
logger.warning(f"[Broll] Chart preview generated: chart_id={chart_id}, path={preview_path}, url={preview_url}")
|
|
|
|
return ChartPreviewResponse(
|
|
preview_url=preview_url,
|
|
chart_id=chart_id,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[Broll] Chart preview generation failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Chart preview failed: {str(e)}")
|
|
|
|
|
|
@router.post("/render/broll-scene", response_model=BrollSceneResponse)
|
|
async def generate_broll_scene(
|
|
request: BrollSceneRequest,
|
|
background_tasks: BackgroundTasks,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Generate a B-roll video for a single scene.
|
|
|
|
This creates a programmatic video with:
|
|
- Background image with Ken Burns effect
|
|
- Chart overlay (if chart_data provided)
|
|
- Avatar circle in corner (if avatar_video_url provided)
|
|
- Insight card at bottom
|
|
|
|
Returns a task_id for polling since video generation can take time.
|
|
"""
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
try:
|
|
# Validate visual_cue
|
|
valid_cues = ["bar_comparison", "bar_chart_comparison", "bar_horizontal", "line_trend", "pie", "stacked_bar", "bullet_points", "full_avatar"]
|
|
if request.visual_cue not in valid_cues:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Invalid visual_cue. Must be one of: {valid_cues}"
|
|
)
|
|
|
|
background_img_path = _resolve_broll_background_image_path(request.background_image_url)
|
|
avatar_video_path = _resolve_broll_avatar_video_path(request.avatar_video_url, user_id)
|
|
|
|
logger.info(f"[Broll] B-roll scene request for scene: {request.scene_id}")
|
|
|
|
# Scene rendering can be expensive, so use task manager/background execution.
|
|
task_id = task_manager.create_task(
|
|
"podcast_broll_scene_generation",
|
|
metadata={"owner_user_id": user_id, "scene_id": request.scene_id},
|
|
)
|
|
|
|
background_tasks.add_task(
|
|
_execute_broll_scene_task,
|
|
task_id=task_id,
|
|
scene_id=request.scene_id,
|
|
key_insight=request.key_insight,
|
|
supporting_stat=request.supporting_stat,
|
|
chart_data=request.chart_data,
|
|
visual_cue=request.visual_cue,
|
|
duration=request.duration,
|
|
background_img_path=background_img_path,
|
|
avatar_video_path=avatar_video_path,
|
|
)
|
|
|
|
return BrollSceneResponse(
|
|
scene_id=request.scene_id,
|
|
task_id=task_id,
|
|
status="pending",
|
|
message="B-roll scene render started. Poll /api/podcast/task/{task_id}/status for progress.",
|
|
)
|
|
|
|
except HTTPException:
|
|
raise
|
|
except Exception as e:
|
|
logger.error(f"[Broll] B-roll scene generation failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"B-roll generation failed: {str(e)}")
|
|
|
|
|
|
@router.post("/render/broll-compose", response_model=BrollComposeResponse)
|
|
async def compose_broll_videos(
|
|
request: BrollComposeRequest,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""
|
|
Compose multiple B-roll scene videos into a final video.
|
|
|
|
Applies crossfade transitions between scenes.
|
|
"""
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
try:
|
|
broll_service = get_broll_service()
|
|
|
|
final_path = broll_service.compose_final_video(
|
|
video_paths=request.scene_video_paths,
|
|
output_filename=request.output_filename,
|
|
fade_dur=request.fade_dur,
|
|
fps=request.fps,
|
|
)
|
|
|
|
final_filename = final_path.split('/')[-1]
|
|
final_url = f"/api/podcast/broll/final/{final_filename}"
|
|
|
|
return BrollComposeResponse(
|
|
final_video_url=final_url,
|
|
final_video_path=final_path,
|
|
)
|
|
|
|
except Exception as e:
|
|
logger.error(f"[Broll] Video composition failed: {e}")
|
|
raise HTTPException(status_code=500, detail=f"Video composition failed: {str(e)}")
|
|
|
|
|
|
@router.get("/preview/{chart_id}/{filename}")
|
|
async def serve_chart_preview(
|
|
chart_id: str,
|
|
filename: str,
|
|
current_user: Dict[str, Any] = Depends(get_current_user_with_query_token),
|
|
):
|
|
"""
|
|
Serve chart preview PNG files.
|
|
|
|
Uses authentication via Authorization header or token query parameter,
|
|
matching the pattern used by /api/podcast/images/ for browser <img> tags.
|
|
"""
|
|
from api.podcast.constants import get_podcast_media_dir
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
# Validate filename to prevent directory traversal
|
|
if ".." in filename or "/" in filename or "\\" in filename:
|
|
raise HTTPException(status_code=400, detail="Invalid filename")
|
|
|
|
logger.warning(f"[Broll] serve_chart_preview: chart_id={chart_id}, filename={filename}, user_id={user_id}")
|
|
|
|
charts_dir = get_podcast_media_dir("chart", user_id)
|
|
file_path = charts_dir / filename
|
|
|
|
logger.warning(f"[Broll] serve_chart_preview: resolved path={file_path}, exists={file_path.exists()}")
|
|
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Chart preview not found")
|
|
|
|
# Security: ensure resolved path is within charts_dir
|
|
if not str(file_path.resolve()).startswith(str(charts_dir.resolve())):
|
|
raise HTTPException(status_code=403, detail="Access denied")
|
|
|
|
return FileResponse(
|
|
path=str(file_path),
|
|
media_type="image/png",
|
|
filename=filename,
|
|
)
|
|
|
|
|
|
@router.get("/final/{filename}")
|
|
async def serve_final_broll(
|
|
filename: str,
|
|
current_user: Dict[str, Any] = Depends(get_current_user),
|
|
):
|
|
"""Serve final composed B-roll video files."""
|
|
user_id = require_authenticated_user(current_user)
|
|
|
|
broll_service = get_broll_service()
|
|
file_path = broll_service.output_dir / filename
|
|
|
|
if not file_path.exists():
|
|
raise HTTPException(status_code=404, detail="Video not found")
|
|
|
|
return FileResponse(
|
|
path=str(file_path),
|
|
media_type="video/mp4",
|
|
filename=filename,
|
|
)
|
|
|
|
|
|
@router.get("/health")
|
|
async def broll_health():
|
|
"""Health check for B-roll service."""
|
|
return {"status": "ok", "service": "broll"}
|