- "backend/routers/creators.py" - "backend/schemas.py" - "frontend/src/api/templates.ts" - "frontend/src/pages/HighlightQueue.tsx" - "frontend/src/pages/HighlightQueue.module.css" - "backend/routers/shorts.py" - "backend/pipeline/stages.py" - "frontend/src/api/shorts.ts" GSD-Task: S04/T03
211 lines
6.6 KiB
Python
211 lines
6.6 KiB
Python
"""Shorts generation API endpoints.
|
|
|
|
Trigger short generation from approved highlights, list generated shorts,
|
|
and get presigned download URLs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
from pydantic import BaseModel
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from database import get_session
|
|
from models import (
|
|
FormatPreset,
|
|
GeneratedShort,
|
|
HighlightCandidate,
|
|
HighlightStatus,
|
|
ShortStatus,
|
|
)
|
|
|
|
logger = logging.getLogger("chrysopedia.shorts")
|
|
|
|
router = APIRouter(prefix="/admin/shorts", tags=["shorts"])
|
|
|
|
|
|
# ── Response schemas ─────────────────────────────────────────────────────────
|
|
|
|
|
|
class GeneratedShortResponse(BaseModel):
|
|
id: str
|
|
highlight_candidate_id: str
|
|
format_preset: str
|
|
status: str
|
|
error_message: str | None = None
|
|
file_size_bytes: int | None = None
|
|
duration_secs: float | None = None
|
|
width: int
|
|
height: int
|
|
share_token: str | None = None
|
|
created_at: datetime
|
|
|
|
model_config = {"from_attributes": True}
|
|
|
|
|
|
class ShortsListResponse(BaseModel):
|
|
shorts: list[GeneratedShortResponse]
|
|
|
|
|
|
class GenerateResponse(BaseModel):
|
|
status: str
|
|
message: str
|
|
|
|
|
|
# ── Endpoints ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
@router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202)
|
|
async def generate_shorts(
|
|
highlight_id: str,
|
|
captions: bool = True,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Dispatch shorts generation for an approved highlight candidate.
|
|
|
|
Creates pending GeneratedShort rows for each format preset and dispatches
|
|
the Celery task. Returns 202 Accepted with status.
|
|
|
|
Args:
|
|
highlight_id: UUID of the approved highlight candidate.
|
|
captions: Whether to burn in Whisper-generated subtitles (default True).
|
|
"""
|
|
# Validate highlight exists and is approved
|
|
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
|
|
result = await db.execute(stmt)
|
|
highlight = result.scalar_one_or_none()
|
|
|
|
if highlight is None:
|
|
raise HTTPException(status_code=404, detail=f"Highlight not found: {highlight_id}")
|
|
|
|
if highlight.status != HighlightStatus.approved:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Highlight must be approved before generating shorts (current: {highlight.status.value})",
|
|
)
|
|
|
|
# Check if shorts are already processing
|
|
existing_stmt = (
|
|
select(GeneratedShort)
|
|
.where(
|
|
GeneratedShort.highlight_candidate_id == highlight_id,
|
|
GeneratedShort.status.in_([ShortStatus.pending, ShortStatus.processing]),
|
|
)
|
|
)
|
|
existing_result = await db.execute(existing_stmt)
|
|
in_progress = existing_result.scalars().all()
|
|
|
|
if in_progress:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Shorts generation already in progress for this highlight",
|
|
)
|
|
|
|
# Dispatch Celery task
|
|
from pipeline.stages import stage_generate_shorts
|
|
|
|
try:
|
|
task = stage_generate_shorts.delay(highlight_id, captions=captions)
|
|
logger.info(
|
|
"Shorts generation dispatched highlight_id=%s task_id=%s",
|
|
highlight_id,
|
|
task.id,
|
|
)
|
|
except Exception as exc:
|
|
logger.warning(
|
|
"Failed to dispatch shorts generation for highlight_id=%s: %s",
|
|
highlight_id,
|
|
exc,
|
|
)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Shorts generation dispatch failed — Celery/Redis may be unavailable",
|
|
) from exc
|
|
|
|
return GenerateResponse(
|
|
status="dispatched",
|
|
message=f"Shorts generation dispatched for highlight {highlight_id}",
|
|
)
|
|
|
|
|
|
@router.get("/{highlight_id}", response_model=ShortsListResponse)
|
|
async def list_shorts(
|
|
highlight_id: str,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""List all generated shorts for a highlight candidate."""
|
|
# Verify highlight exists
|
|
hl_stmt = select(HighlightCandidate.id).where(HighlightCandidate.id == highlight_id)
|
|
hl_result = await db.execute(hl_stmt)
|
|
if hl_result.scalar_one_or_none() is None:
|
|
raise HTTPException(status_code=404, detail=f"Highlight not found: {highlight_id}")
|
|
|
|
stmt = (
|
|
select(GeneratedShort)
|
|
.where(GeneratedShort.highlight_candidate_id == highlight_id)
|
|
.order_by(GeneratedShort.format_preset)
|
|
)
|
|
result = await db.execute(stmt)
|
|
shorts = result.scalars().all()
|
|
|
|
return ShortsListResponse(
|
|
shorts=[
|
|
GeneratedShortResponse(
|
|
id=str(s.id),
|
|
highlight_candidate_id=str(s.highlight_candidate_id),
|
|
format_preset=s.format_preset.value,
|
|
status=s.status.value,
|
|
error_message=s.error_message,
|
|
file_size_bytes=s.file_size_bytes,
|
|
duration_secs=s.duration_secs,
|
|
width=s.width,
|
|
height=s.height,
|
|
share_token=s.share_token,
|
|
created_at=s.created_at,
|
|
)
|
|
for s in shorts
|
|
]
|
|
)
|
|
|
|
|
|
@router.get("/download/{short_id}")
|
|
async def download_short(
|
|
short_id: str,
|
|
db: AsyncSession = Depends(get_session),
|
|
):
|
|
"""Get a presigned download URL for a completed short."""
|
|
stmt = select(GeneratedShort).where(GeneratedShort.id == short_id)
|
|
result = await db.execute(stmt)
|
|
short = result.scalar_one_or_none()
|
|
|
|
if short is None:
|
|
raise HTTPException(status_code=404, detail=f"Short not found: {short_id}")
|
|
|
|
if short.status != ShortStatus.complete:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Short is not complete (current: {short.status.value})",
|
|
)
|
|
|
|
if not short.minio_object_key:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Short marked complete but has no storage key",
|
|
)
|
|
|
|
from minio_client import generate_download_url
|
|
|
|
try:
|
|
url = generate_download_url(short.minio_object_key)
|
|
except Exception as exc:
|
|
logger.error("Failed to generate download URL for short_id=%s: %s", short_id, exc)
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Failed to generate download URL — MinIO may be unavailable",
|
|
) from exc
|
|
|
|
return {"download_url": url, "format_preset": short.format_preset.value}
|