feat: Added shorts router with generate/list/download endpoints, fronte…
- "backend/routers/shorts.py" - "backend/main.py" - "frontend/src/api/shorts.ts" - "frontend/src/pages/HighlightQueue.tsx" - "frontend/src/pages/HighlightQueue.module.css" GSD-Task: S03/T03
This commit is contained in:
parent
0007528e77
commit
84d8dc4455
9 changed files with 800 additions and 121 deletions
|
|
@ -132,7 +132,7 @@ Create the pure ffmpeg wrapper module with 3 format presets, then wire a Celery
|
|||
- Estimate: 45m
|
||||
- Files: backend/pipeline/shorts_generator.py, backend/pipeline/stages.py, backend/minio_client.py
|
||||
- Verify: cd backend && python -c "from pipeline.shorts_generator import extract_clip, PRESETS, resolve_video_path; print('OK')" && python -c "from pipeline.stages import stage_generate_shorts; print('OK')"
|
||||
- [ ] **T03: Add shorts API endpoints and frontend generate button with status display** — ## Description
|
||||
- [x] **T03: Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling** — ## Description
|
||||
|
||||
Expose the shorts pipeline through API endpoints (trigger generation, list shorts, download link) and add a "Generate Shorts" button to the HighlightQueue UI for approved highlights with status badges and download links.
|
||||
|
||||
|
|
|
|||
16
.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json
Normal file
16
.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"schemaVersion": 1,
|
||||
"taskId": "T02",
|
||||
"unitId": "M023/S03/T02",
|
||||
"timestamp": 1775296060809,
|
||||
"passed": true,
|
||||
"discoverySource": "task-plan",
|
||||
"checks": [
|
||||
{
|
||||
"command": "cd backend",
|
||||
"exitCode": 0,
|
||||
"durationMs": 9,
|
||||
"verdict": "pass"
|
||||
}
|
||||
]
|
||||
}
|
||||
91
.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md
Normal file
91
.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
---
|
||||
id: T03
|
||||
parent: S03
|
||||
milestone: M023
|
||||
provides: []
|
||||
requires: []
|
||||
affects: []
|
||||
key_files: ["backend/routers/shorts.py", "backend/main.py", "frontend/src/api/shorts.ts", "frontend/src/pages/HighlightQueue.tsx", "frontend/src/pages/HighlightQueue.module.css"]
|
||||
key_decisions: ["Show generate button only on approved highlights with no in-progress shorts (or all-failed)", "Poll every 5s only for highlights with pending/processing shorts, stop when all settle", "Download opens presigned URL in new tab"]
|
||||
patterns_established: []
|
||||
drill_down_paths: []
|
||||
observability_surfaces: []
|
||||
duration: ""
|
||||
verification_result: "All 8 verification checks passed: router registered in main.py, router imports clean, TypeScript compiles with zero errors, frontend production build succeeds, model imports OK, ffmpeg in Dockerfile, video_source_path in config, chrysopedia_videos volume mount present."
|
||||
completed_at: 2026-04-04T09:51:41.379Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling
|
||||
|
||||
> Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling
|
||||
|
||||
## What Happened
|
||||
---
|
||||
id: T03
|
||||
parent: S03
|
||||
milestone: M023
|
||||
key_files:
|
||||
- backend/routers/shorts.py
|
||||
- backend/main.py
|
||||
- frontend/src/api/shorts.ts
|
||||
- frontend/src/pages/HighlightQueue.tsx
|
||||
- frontend/src/pages/HighlightQueue.module.css
|
||||
key_decisions:
|
||||
- Show generate button only on approved highlights with no in-progress shorts (or all-failed)
|
||||
- Poll every 5s only for highlights with pending/processing shorts, stop when all settle
|
||||
- Download opens presigned URL in new tab
|
||||
duration: ""
|
||||
verification_result: passed
|
||||
completed_at: 2026-04-04T09:51:41.381Z
|
||||
blocker_discovered: false
|
||||
---
|
||||
|
||||
# T03: Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling
|
||||
|
||||
**Added shorts router with generate/list/download endpoints, frontend API client, and HighlightQueue UI with generate button, per-preset status badges, download links, and 5s polling**
|
||||
|
||||
## What Happened
|
||||
|
||||
Created backend/routers/shorts.py with three endpoints: POST generate (validates approved status, checks no in-progress shorts, dispatches Celery task, returns 202), GET list (returns all shorts for a highlight), GET download (returns presigned MinIO URL for completed shorts). Registered the router in main.py. Created frontend/src/api/shorts.ts with typed API client. Updated HighlightQueue.tsx with shorts state management, generate button on eligible highlights, per-preset status badges with color-coded states, download buttons, and 5s polling while shorts are processing. Added CSS styles including a pulsing animation for processing state.
|
||||
|
||||
## Verification
|
||||
|
||||
All 8 verification checks passed: router registered in main.py, router imports clean, TypeScript compiles with zero errors, frontend production build succeeds, model imports OK, ffmpeg in Dockerfile, video_source_path in config, chrysopedia_videos volume mount present.
|
||||
|
||||
## Verification Evidence
|
||||
|
||||
| # | Command | Exit Code | Verdict | Duration |
|
||||
|---|---------|-----------|---------|----------|
|
||||
| 1 | `grep -q 'shorts' backend/main.py` | 0 | ✅ pass | 100ms |
|
||||
| 2 | `cd backend && python -c "from routers.shorts import router; print('OK')"` | 0 | ✅ pass | 5600ms |
|
||||
| 3 | `cd frontend && npx tsc --noEmit` | 0 | ✅ pass | 5600ms |
|
||||
| 4 | `cd frontend && npm run build` | 0 | ✅ pass | 6300ms |
|
||||
| 5 | `cd backend && python -c "from models import GeneratedShort, FormatPreset, ShortStatus; print('OK')"` | 0 | ✅ pass | 6300ms |
|
||||
| 6 | `grep ffmpeg docker/Dockerfile.api` | 0 | ✅ pass | 100ms |
|
||||
| 7 | `grep video_source_path backend/config.py` | 0 | ✅ pass | 100ms |
|
||||
| 8 | `grep chrysopedia_videos docker-compose.yml` | 0 | ✅ pass | 100ms |
|
||||
|
||||
|
||||
## Deviations
|
||||
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
|
||||
None.
|
||||
|
||||
## Files Created/Modified
|
||||
|
||||
- `backend/routers/shorts.py`
|
||||
- `backend/main.py`
|
||||
- `frontend/src/api/shorts.ts`
|
||||
- `frontend/src/pages/HighlightQueue.tsx`
|
||||
- `frontend/src/pages/HighlightQueue.module.css`
|
||||
|
||||
|
||||
## Deviations
|
||||
None.
|
||||
|
||||
## Known Issues
|
||||
None.
|
||||
|
|
@ -12,7 +12,7 @@ from fastapi import FastAPI
|
|||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from config import get_settings
|
||||
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, stats, techniques, topics, videos
|
||||
from routers import admin, auth, chat, consent, creator_chapters, creator_dashboard, creator_highlights, creators, files, follows, health, highlights, ingest, pipeline, posts, reports, search, shorts, stats, techniques, topics, videos
|
||||
|
||||
|
||||
def _setup_logging() -> None:
|
||||
|
|
@ -101,6 +101,7 @@ app.include_router(posts.router, prefix="/api/v1")
|
|||
app.include_router(files.router, prefix="/api/v1")
|
||||
app.include_router(reports.router, prefix="/api/v1")
|
||||
app.include_router(search.router, prefix="/api/v1")
|
||||
app.include_router(shorts.router, prefix="/api/v1")
|
||||
app.include_router(stats.router, prefix="/api/v1")
|
||||
app.include_router(techniques.router, prefix="/api/v1")
|
||||
app.include_router(topics.router, prefix="/api/v1")
|
||||
|
|
|
|||
204
backend/routers/shorts.py
Normal file
204
backend/routers/shorts.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
"""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
|
||||
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,
|
||||
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.
|
||||
"""
|
||||
# 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)
|
||||
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,
|
||||
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}
|
||||
53
frontend/src/api/shorts.ts
Normal file
53
frontend/src/api/shorts.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { request, BASE } from "./client";
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface GeneratedShort {
|
||||
id: string;
|
||||
highlight_candidate_id: string;
|
||||
format_preset: "vertical" | "square" | "horizontal";
|
||||
status: "pending" | "processing" | "complete" | "failed";
|
||||
error_message: string | null;
|
||||
file_size_bytes: number | null;
|
||||
duration_secs: number | null;
|
||||
width: number;
|
||||
height: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ShortsListResponse {
|
||||
shorts: GeneratedShort[];
|
||||
}
|
||||
|
||||
export interface GenerateResponse {
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface DownloadResponse {
|
||||
download_url: string;
|
||||
format_preset: string;
|
||||
}
|
||||
|
||||
// ── API functions ────────────────────────────────────────────────────────────
|
||||
|
||||
export function generateShorts(highlightId: string): Promise<GenerateResponse> {
|
||||
return request<GenerateResponse>(
|
||||
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchShorts(highlightId: string): Promise<ShortsListResponse> {
|
||||
return request<ShortsListResponse>(
|
||||
`${BASE}/admin/shorts/${encodeURIComponent(highlightId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getShortDownloadUrl(
|
||||
shortId: string,
|
||||
): Promise<DownloadResponse> {
|
||||
return request<DownloadResponse>(
|
||||
`${BASE}/admin/shorts/download/${encodeURIComponent(shortId)}`,
|
||||
);
|
||||
}
|
||||
|
|
@ -357,6 +357,106 @@
|
|||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* ── Shorts section ────────────────────────────────────────────────────────── */
|
||||
|
||||
.shortsSection {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.shortsLabel {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortsBadges {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shortItem {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.shortBadge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.shortStatusPending {
|
||||
background: var(--color-badge-pending-bg);
|
||||
color: var(--color-badge-pending-text);
|
||||
}
|
||||
|
||||
.shortStatusProcessing {
|
||||
background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12));
|
||||
color: var(--color-accent, #00ffd1);
|
||||
animation: pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.shortStatusComplete {
|
||||
background: var(--color-badge-approved-bg, rgba(34, 197, 94, 0.12));
|
||||
color: var(--color-badge-approved-text, #22c55e);
|
||||
}
|
||||
|
||||
.shortStatusFailed {
|
||||
background: var(--color-badge-rejected-bg, rgba(239, 68, 68, 0.12));
|
||||
color: var(--color-badge-rejected-text, #ef4444);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.downloadLink {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--color-accent, #00ffd1);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.downloadLink:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.shortError {
|
||||
color: var(--color-badge-rejected-text, #ef4444);
|
||||
font-size: 0.75rem;
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.generateBtn {
|
||||
background: var(--color-accent-subtle, rgba(0, 255, 209, 0.12));
|
||||
color: var(--color-accent, #00ffd1);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.generateBtn:hover {
|
||||
background: rgba(0, 255, 209, 0.22);
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { SidebarNav } from "./CreatorDashboard";
|
||||
import { ApiError } from "../api/client";
|
||||
import {
|
||||
|
|
@ -9,6 +9,12 @@ import {
|
|||
type HighlightCandidate,
|
||||
type ScoreBreakdown,
|
||||
} from "../api/highlights";
|
||||
import {
|
||||
generateShorts,
|
||||
fetchShorts,
|
||||
getShortDownloadUrl,
|
||||
type GeneratedShort,
|
||||
} from "../api/shorts";
|
||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||
import styles from "./HighlightQueue.module.css";
|
||||
|
||||
|
|
@ -31,6 +37,44 @@ function statusBadgeClass(status: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
function shortStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case "complete":
|
||||
return styles.shortStatusComplete ?? "";
|
||||
case "failed":
|
||||
return styles.shortStatusFailed ?? "";
|
||||
case "processing":
|
||||
return styles.shortStatusProcessing ?? "";
|
||||
default:
|
||||
return styles.shortStatusPending ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
function formatPresetLabel(preset: string): string {
|
||||
switch (preset) {
|
||||
case "vertical":
|
||||
return "9:16";
|
||||
case "square":
|
||||
return "1:1";
|
||||
case "horizontal":
|
||||
return "16:9";
|
||||
default:
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
|
||||
function canGenerateShorts(shorts: GeneratedShort[]): boolean {
|
||||
if (shorts.length === 0) return true;
|
||||
// Can re-generate if all existing shorts failed
|
||||
return shorts.every((s) => s.status === "failed");
|
||||
}
|
||||
|
||||
function hasProcessingShorts(shorts: GeneratedShort[]): boolean {
|
||||
return shorts.some(
|
||||
(s) => s.status === "pending" || s.status === "processing",
|
||||
);
|
||||
}
|
||||
|
||||
const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [
|
||||
{ key: "duration_score", label: "Duration" },
|
||||
{ key: "content_density_score", label: "Content Density" },
|
||||
|
|
@ -43,6 +87,8 @@ const BREAKDOWN_LABELS: { key: keyof ScoreBreakdown; label: string }[] = [
|
|||
|
||||
type FilterTab = "all" | "shorts" | "approved" | "rejected";
|
||||
|
||||
const POLL_INTERVAL_MS = 5000;
|
||||
|
||||
/* ── Component ──────────────────────────────────────────────────────────────── */
|
||||
|
||||
export default function HighlightQueue() {
|
||||
|
|
@ -57,6 +103,13 @@ export default function HighlightQueue() {
|
|||
const [trimEnd, setTrimEnd] = useState<string>("");
|
||||
const [actionLoading, setActionLoading] = useState<string | null>(null);
|
||||
|
||||
// Shorts state: highlight_id → GeneratedShort[]
|
||||
const [shortsMap, setShortsMap] = useState<Map<string, GeneratedShort[]>>(
|
||||
new Map(),
|
||||
);
|
||||
const [generatingIds, setGeneratingIds] = useState<Set<string>>(new Set());
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const loadHighlights = useCallback(async (tab: FilterTab) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
|
@ -75,10 +128,83 @@ export default function HighlightQueue() {
|
|||
}
|
||||
}, []);
|
||||
|
||||
// Load shorts for all approved highlights
|
||||
const loadShortsForHighlights = useCallback(
|
||||
async (hls: HighlightCandidate[]) => {
|
||||
const approved = hls.filter((h) => h.status === "approved");
|
||||
const newMap = new Map<string, GeneratedShort[]>();
|
||||
|
||||
await Promise.all(
|
||||
approved.map(async (h) => {
|
||||
try {
|
||||
const res = await fetchShorts(h.id);
|
||||
newMap.set(h.id, res.shorts);
|
||||
} catch {
|
||||
// Non-critical — leave empty
|
||||
newMap.set(h.id, []);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setShortsMap(newMap);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadHighlights(activeTab);
|
||||
}, [activeTab, loadHighlights]);
|
||||
|
||||
// After highlights load, fetch shorts for approved ones
|
||||
useEffect(() => {
|
||||
if (highlights.length > 0) {
|
||||
loadShortsForHighlights(highlights);
|
||||
}
|
||||
}, [highlights, loadShortsForHighlights]);
|
||||
|
||||
// Polling: when any highlight has processing shorts, poll every 5s
|
||||
useEffect(() => {
|
||||
const anyProcessing = Array.from(shortsMap.values()).some(hasProcessingShorts);
|
||||
|
||||
if (anyProcessing) {
|
||||
if (!pollRef.current) {
|
||||
pollRef.current = setInterval(() => {
|
||||
// Refresh shorts for highlights that have processing items
|
||||
const processingIds = Array.from(shortsMap.entries())
|
||||
.filter(([, shorts]) => hasProcessingShorts(shorts))
|
||||
.map(([id]) => id);
|
||||
|
||||
Promise.all(
|
||||
processingIds.map(async (id) => {
|
||||
try {
|
||||
const res = await fetchShorts(id);
|
||||
setShortsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(id, res.shorts);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
}
|
||||
}),
|
||||
);
|
||||
}, POLL_INTERVAL_MS);
|
||||
}
|
||||
} else {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (pollRef.current) {
|
||||
clearInterval(pollRef.current);
|
||||
pollRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [shortsMap]);
|
||||
|
||||
const handleTabChange = (tab: FilterTab) => {
|
||||
setActiveTab(tab);
|
||||
setExpandedId(null);
|
||||
|
|
@ -158,6 +284,42 @@ export default function HighlightQueue() {
|
|||
}
|
||||
};
|
||||
|
||||
const handleGenerateShorts = async (highlightId: string) => {
|
||||
setGeneratingIds((prev) => new Set(prev).add(highlightId));
|
||||
setError(null);
|
||||
try {
|
||||
await generateShorts(highlightId);
|
||||
// Immediately fetch shorts to show pending status
|
||||
const res = await fetchShorts(highlightId);
|
||||
setShortsMap((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(highlightId, res.shorts);
|
||||
return next;
|
||||
});
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.detail : "Failed to dispatch shorts generation",
|
||||
);
|
||||
} finally {
|
||||
setGeneratingIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(highlightId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async (shortId: string) => {
|
||||
try {
|
||||
const res = await getShortDownloadUrl(shortId);
|
||||
window.open(res.download_url, "_blank");
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.detail : "Failed to get download URL",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { key: FilterTab; label: string }[] = [
|
||||
{ key: "all", label: "All" },
|
||||
{ key: "shorts", label: "Shorts" },
|
||||
|
|
@ -206,7 +368,14 @@ export default function HighlightQueue() {
|
|||
{/* Candidate list */}
|
||||
{!loading && highlights.length > 0 && (
|
||||
<div className={styles.candidateList}>
|
||||
{highlights.map((h) => (
|
||||
{highlights.map((h) => {
|
||||
const shorts = shortsMap.get(h.id) ?? [];
|
||||
const isGenerating = generatingIds.has(h.id);
|
||||
const showGenerateBtn =
|
||||
h.status === "approved" && canGenerateShorts(shorts);
|
||||
const showShortsStatus = shorts.length > 0;
|
||||
|
||||
return (
|
||||
<div key={h.id} className={styles.candidateCard}>
|
||||
{/* Header */}
|
||||
<div className={styles.candidateHeader}>
|
||||
|
|
@ -260,6 +429,41 @@ export default function HighlightQueue() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Shorts status badges */}
|
||||
{showShortsStatus && (
|
||||
<div className={styles.shortsSection}>
|
||||
<span className={styles.shortsLabel}>Shorts</span>
|
||||
<div className={styles.shortsBadges}>
|
||||
{shorts.map((s) => (
|
||||
<div key={s.id} className={styles.shortItem}>
|
||||
<span
|
||||
className={`${styles.shortBadge} ${shortStatusClass(s.status)}`}
|
||||
>
|
||||
{formatPresetLabel(s.format_preset)}{" "}
|
||||
{s.status}
|
||||
</span>
|
||||
{s.status === "complete" && (
|
||||
<button
|
||||
className={styles.downloadLink}
|
||||
onClick={() => handleDownload(s.id)}
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
)}
|
||||
{s.status === "failed" && s.error_message && (
|
||||
<span
|
||||
className={styles.shortError}
|
||||
title={s.error_message}
|
||||
>
|
||||
⚠
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
|
|
@ -283,6 +487,15 @@ export default function HighlightQueue() {
|
|||
>
|
||||
{expandedId === h.id ? "Close" : "Trim"}
|
||||
</button>
|
||||
{showGenerateBtn && (
|
||||
<button
|
||||
className={`${styles.actionBtn} ${styles.generateBtn}`}
|
||||
disabled={isGenerating}
|
||||
onClick={() => handleGenerateShorts(h.id)}
|
||||
>
|
||||
{isGenerating ? "Generating…" : "Generate Shorts"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Trim panel */}
|
||||
|
|
@ -328,7 +541,8 @@ export default function HighlightQueue() {
|
|||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/admin-pipeline.ts","./src/api/admin-techniques.ts","./src/api/auth.ts","./src/api/chat.ts","./src/api/client.ts","./src/api/consent.ts","./src/api/creator-dashboard.ts","./src/api/creators.ts","./src/api/follows.ts","./src/api/highlights.ts","./src/api/index.ts","./src/api/posts.ts","./src/api/reports.ts","./src/api/search.ts","./src/api/shorts.ts","./src/api/stats.ts","./src/api/techniques.ts","./src/api/topics.ts","./src/api/videos.ts","./src/components/AdminDropdown.tsx","./src/components/AppFooter.tsx","./src/components/AudioWaveform.tsx","./src/components/CategoryIcons.tsx","./src/components/ChapterMarkers.tsx","./src/components/ChatWidget.tsx","./src/components/ConfirmModal.tsx","./src/components/CopyLinkButton.tsx","./src/components/CreatorAvatar.tsx","./src/components/ImpersonationBanner.tsx","./src/components/PersonalityProfile.tsx","./src/components/PlayerControls.tsx","./src/components/PostsFeed.tsx","./src/components/ProtectedRoute.tsx","./src/components/ReportIssueModal.tsx","./src/components/SearchAutocomplete.tsx","./src/components/SocialIcons.tsx","./src/components/SortDropdown.tsx","./src/components/TableOfContents.tsx","./src/components/TagList.tsx","./src/components/ToggleSwitch.tsx","./src/components/TranscriptSidebar.tsx","./src/components/VideoPlayer.tsx","./src/context/AuthContext.tsx","./src/hooks/useCountUp.ts","./src/hooks/useDocumentTitle.ts","./src/hooks/useMediaSync.ts","./src/hooks/useSortPreference.ts","./src/pages/About.tsx","./src/pages/AdminAuditLog.tsx","./src/pages/AdminPipeline.tsx","./src/pages/AdminReports.tsx","./src/pages/AdminTechniquePages.tsx","./src/pages/AdminUsers.tsx","./src/pages/ChapterReview.tsx","./src/pages/ChatPage.tsx","./src/pages/ConsentDashboard.tsx","./src/pages/CreatorDashboard.tsx","./src/pages/CreatorDetail.tsx","./src/pages/CreatorSettings.tsx","./src/pages/CreatorTiers.tsx","./src/pages/CreatorsBrowse.tsx","./src/pages/HighlightQueue.tsx","./src/pages/Home.tsx","./src/pages/Login.tsx","./src/pages/PostEditor.tsx","./src/pages/PostsList.tsx","./src/pages/Register.tsx","./src/pages/SearchResults.tsx","./src/pages/SubTopicPage.tsx","./src/pages/TechniquePage.tsx","./src/pages/TopicsBrowse.tsx","./src/pages/WatchPage.tsx","./src/utils/catSlug.ts","./src/utils/citations.tsx"],"version":"5.6.3"}
|
||||
Loading…
Add table
Reference in a new issue