diff --git a/.gsd/milestones/M023/slices/S03/S03-PLAN.md b/.gsd/milestones/M023/slices/S03/S03-PLAN.md index fb5d5f3..9118f85 100644 --- a/.gsd/milestones/M023/slices/S03/S03-PLAN.md +++ b/.gsd/milestones/M023/slices/S03/S03-PLAN.md @@ -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. diff --git a/.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json b/.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json new file mode 100644 index 0000000..d7fd17c --- /dev/null +++ b/.gsd/milestones/M023/slices/S03/tasks/T02-VERIFY.json @@ -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" + } + ] +} diff --git a/.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md b/.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..4e37606 --- /dev/null +++ b/.gsd/milestones/M023/slices/S03/tasks/T03-SUMMARY.md @@ -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. diff --git a/backend/main.py b/backend/main.py index d533432..7e43e3e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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") diff --git a/backend/routers/shorts.py b/backend/routers/shorts.py new file mode 100644 index 0000000..b56851c --- /dev/null +++ b/backend/routers/shorts.py @@ -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} diff --git a/frontend/src/api/shorts.ts b/frontend/src/api/shorts.ts new file mode 100644 index 0000000..2ea87e6 --- /dev/null +++ b/frontend/src/api/shorts.ts @@ -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 { + return request( + `${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`, + { method: "POST" }, + ); +} + +export function fetchShorts(highlightId: string): Promise { + return request( + `${BASE}/admin/shorts/${encodeURIComponent(highlightId)}`, + ); +} + +export function getShortDownloadUrl( + shortId: string, +): Promise { + return request( + `${BASE}/admin/shorts/download/${encodeURIComponent(shortId)}`, + ); +} diff --git a/frontend/src/pages/HighlightQueue.module.css b/frontend/src/pages/HighlightQueue.module.css index 5c44c7e..d6b38dd 100644 --- a/frontend/src/pages/HighlightQueue.module.css +++ b/frontend/src/pages/HighlightQueue.module.css @@ -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) { diff --git a/frontend/src/pages/HighlightQueue.tsx b/frontend/src/pages/HighlightQueue.tsx index ed76689..8ba7c7b 100644 --- a/frontend/src/pages/HighlightQueue.tsx +++ b/frontend/src/pages/HighlightQueue.tsx @@ -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(""); const [actionLoading, setActionLoading] = useState(null); + // Shorts state: highlight_id → GeneratedShort[] + const [shortsMap, setShortsMap] = useState>( + new Map(), + ); + const [generatingIds, setGeneratingIds] = useState>(new Set()); + const pollRef = useRef | 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(); + + 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,129 +368,181 @@ export default function HighlightQueue() { {/* Candidate list */} {!loading && highlights.length > 0 && (
- {highlights.map((h) => ( -
- {/* Header */} -
- - {h.key_moment?.title ?? "Untitled highlight"} - - - {formatDuration(h.duration_secs)} - - {h.duration_secs <= 60 && ( - Short - )} - - {h.status} - -
+ {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; - {/* Composite score */} -
- - {Math.round(h.score * 100)}% - -
-
+ return ( +
+ {/* Header */} +
+ + {h.key_moment?.title ?? "Untitled highlight"} + + + {formatDuration(h.duration_secs)} + + {h.duration_secs <= 60 && ( + Short + )} + + {h.status} +
-
- {/* Score breakdown (shown when expanded) */} - {expandedId === h.id && h.score_breakdown && ( -
- {BREAKDOWN_LABELS.map(({ key, label }) => { - const val = h.score_breakdown![key]; - return ( -
- {label} -
-
+ {/* Composite score */} +
+ + {Math.round(h.score * 100)}% + +
+
+
+
+ + {/* Score breakdown (shown when expanded) */} + {expandedId === h.id && h.score_breakdown && ( +
+ {BREAKDOWN_LABELS.map(({ key, label }) => { + const val = h.score_breakdown![key]; + return ( +
+ {label} +
+
+
+ + {Math.round(val * 100)}% +
- - {Math.round(val * 100)}% - -
- ); - })} -
- )} + ); + })} +
+ )} - {/* Action buttons */} -
- - - + {/* Shorts status badges */} + {showShortsStatus && ( +
+ Shorts +
+ {shorts.map((s) => ( +
+ + {formatPresetLabel(s.format_preset)}{" "} + {s.status} + + {s.status === "complete" && ( + + )} + {s.status === "failed" && s.error_message && ( + + ⚠ + + )} +
+ ))} +
+
+ )} + + {/* Action buttons */} +
+ + + + {showGenerateBtn && ( + + )} +
+ + {/* Trim panel */} + {expandedId === h.id && ( +
+
+ Start (s) + setTrimStart(e.target.value)} + min={0} + step={0.1} + /> +
+
+ End (s) + setTrimEnd(e.target.value)} + min={0} + step={0.1} + /> +
+
+ + +
+
+ )}
- - {/* Trim panel */} - {expandedId === h.id && ( -
-
- Start (s) - setTrimStart(e.target.value)} - min={0} - step={0.1} - /> -
-
- End (s) - setTrimEnd(e.target.value)} - min={0} - step={0.1} - /> -
-
- - -
-
- )} -
- ))} + ); + })}
)}
diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index d8bbd74..ca94c00 100644 --- a/frontend/tsconfig.app.tsbuildinfo +++ b/frontend/tsconfig.app.tsbuildinfo @@ -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"} \ No newline at end of file +{"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"} \ No newline at end of file