diff --git a/.gsd/milestones/M024/slices/S04/S04-PLAN.md b/.gsd/milestones/M024/slices/S04/S04-PLAN.md index 8fc392c..0e26794 100644 --- a/.gsd/milestones/M024/slices/S04/S04-PLAN.md +++ b/.gsd/milestones/M024/slices/S04/S04-PLAN.md @@ -52,7 +52,7 @@ Steps: - Estimate: 2h - Files: backend/pipeline/card_renderer.py, backend/pipeline/shorts_generator.py, backend/pipeline/stages.py, backend/models.py, alembic/versions/028_add_shorts_template.py, backend/pipeline/test_card_renderer.py - Verify: cd backend && python -m pytest pipeline/test_card_renderer.py -v && python -c "from pipeline.card_renderer import render_card, concat_segments; print('import ok')" -- [ ] **T03: Template API endpoints and frontend template config UI** — Add REST endpoints for reading and updating creator shorts template config. Add template configuration UI to the HighlightQueue page — color picker, text inputs, duration controls, and intro/outro toggles. Add a caption toggle to the short generation flow. +- [x] **T03: Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task** — Add REST endpoints for reading and updating creator shorts template config. Add template configuration UI to the HighlightQueue page — color picker, text inputs, duration controls, and intro/outro toggles. Add a caption toggle to the short generation flow. Steps: 1. Read T02 outputs to understand the shorts_template schema on the Creator model. diff --git a/.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json b/.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json new file mode 100644 index 0000000..feaf118 --- /dev/null +++ b/.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "taskId": "T02", + "unitId": "M024/S04/T02", + "timestamp": 1775301458773, + "passed": true, + "discoverySource": "task-plan", + "checks": [ + { + "command": "cd backend", + "exitCode": 0, + "durationMs": 7, + "verdict": "pass" + }, + { + "command": "python -m pytest pipeline/test_card_renderer.py -v", + "exitCode": 0, + "durationMs": 581, + "verdict": "pass" + } + ] +} diff --git a/.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md b/.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md new file mode 100644 index 0000000..d86282d --- /dev/null +++ b/.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md @@ -0,0 +1,93 @@ +--- +id: T03 +parent: S04 +milestone: M024 +provides: [] +requires: [] +affects: [] +key_files: ["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", "backend/main.py"] +key_decisions: ["Admin template endpoints on a separate admin_router in creators.py to keep public /creators endpoints untouched", "Template JSONB keys match card_renderer.parse_template_config expectations for zero-translation at pipeline read time"] +patterns_established: [] +drill_down_paths: [] +observability_surfaces: [] +duration: "" +verification_result: "Frontend builds cleanly (npm run build exit 0). Backend router imports succeed. All 45 prior tests (17 caption + 28 card renderer) still pass." +completed_at: 2026-04-04T11:25:15.695Z +blocker_discovered: false +--- + +# T03: Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task + +> Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task + +## What Happened +--- +id: T03 +parent: S04 +milestone: M024 +key_files: + - 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 + - backend/main.py +key_decisions: + - Admin template endpoints on a separate admin_router in creators.py to keep public /creators endpoints untouched + - Template JSONB keys match card_renderer.parse_template_config expectations for zero-translation at pipeline read time +duration: "" +verification_result: passed +completed_at: 2026-04-04T11:25:15.695Z +blocker_discovered: false +--- + +# T03: Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task + +**Add GET/PUT shorts-template admin endpoints, collapsible template config UI on HighlightQueue, and per-highlight captions toggle wired through to Celery task** + +## What Happened + +Added ShortsTemplateConfig and ShortsTemplateUpdate Pydantic schemas with validation. Created admin_router in creators.py with GET/PUT endpoints under /admin/creators/{creator_id}/shorts-template requiring admin auth. Created frontend API client (templates.ts) and added collapsible template config panel to HighlightQueue with intro/outro text inputs, duration sliders, show/hide toggles, color picker, and font input. Added per-highlight captions toggle checkbox next to Generate Shorts button. Updated generate-shorts endpoint and Celery task to accept and respect the captions parameter. + +## Verification + +Frontend builds cleanly (npm run build exit 0). Backend router imports succeed. All 45 prior tests (17 caption + 28 card renderer) still pass. + +## Verification Evidence + +| # | Command | Exit Code | Verdict | Duration | +|---|---------|-----------|---------|----------| +| 1 | `cd frontend && npm run build` | 0 | ✅ pass | 6500ms | +| 2 | `cd backend && python -c "from routers.creators import router, admin_router; print('ok')"` | 0 | ✅ pass | 6500ms | +| 3 | `cd backend && python -m pytest pipeline/test_caption_generator.py pipeline/test_card_renderer.py -v` | 0 | ✅ pass | 3700ms | + + +## Deviations + +None. + +## Known Issues + +None. + +## Files Created/Modified + +- `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` +- `backend/main.py` + + +## Deviations +None. + +## Known Issues +None. diff --git a/backend/main.py b/backend/main.py index 248e1fa..4971f06 100644 --- a/backend/main.py +++ b/backend/main.py @@ -93,6 +93,7 @@ app.include_router(creator_dashboard.router, prefix="/api/v1") app.include_router(creator_chapters.router, prefix="/api/v1") app.include_router(creator_highlights.router, prefix="/api/v1") app.include_router(creators.router, prefix="/api/v1") +app.include_router(creators.admin_router, prefix="/api/v1") app.include_router(follows.router, prefix="/api/v1") app.include_router(highlights.router, prefix="/api/v1") app.include_router(ingest.router, prefix="/api/v1") diff --git a/backend/pipeline/stages.py b/backend/pipeline/stages.py index ff40603..a0d2269 100644 --- a/backend/pipeline/stages.py +++ b/backend/pipeline/stages.py @@ -2866,13 +2866,17 @@ def extract_personality_profile(self, creator_id: str) -> str: # ── Stage: Shorts Generation ───────────────────────────────────────────────── @celery_app.task(bind=True, max_retries=1, default_retry_delay=60) -def stage_generate_shorts(self, highlight_candidate_id: str) -> str: +def stage_generate_shorts(self, highlight_candidate_id: str, captions: bool = True) -> str: """Generate video shorts for an approved highlight candidate. Creates one GeneratedShort row per FormatPreset, extracts the clip via ffmpeg, uploads to MinIO, and updates status. Each preset is independent — a failure on one does not block the others. + Args: + highlight_candidate_id: UUID string of the approved highlight. + captions: Whether to generate and burn in ASS subtitles (default True). + Returns the highlight_candidate_id on completion. """ from pipeline.shorts_generator import PRESETS, extract_clip_with_template, resolve_video_path @@ -2956,55 +2960,61 @@ def stage_generate_shorts(self, highlight_candidate_id: str) -> str: clip_start, clip_end, ) - # ── Generate captions from transcript (if available) ──────────── + # ── Generate captions from transcript (if available and requested) ─ ass_path: Path | None = None captions_ok = False - try: - transcript_data: list | None = None - if source_video.transcript_path: - try: - with open(source_video.transcript_path, "r") as fh: - raw = json.load(fh) - if isinstance(raw, dict): - transcript_data = raw.get("segments", raw.get("results", [])) - elif isinstance(raw, list): - transcript_data = raw - except (FileNotFoundError, json.JSONDecodeError, OSError) as io_exc: - logger.warning( - "Failed to load transcript for captions highlight=%s: %s", - highlight_candidate_id, io_exc, - ) - - if transcript_data: - from pipeline.highlight_scorer import extract_word_timings - - word_timings = extract_word_timings(transcript_data, clip_start, clip_end) - if word_timings: - ass_content = generate_ass_captions(word_timings, clip_start) - ass_path = write_ass_file( - ass_content, - Path(f"/tmp/captions_{highlight_candidate_id}.ass"), - ) - captions_ok = True - logger.info( - "Generated captions for highlight=%s (%d words)", - highlight_candidate_id, len(word_timings), - ) - else: - logger.warning( - "No word timings in transcript window [%.1f–%.1f]s for highlight=%s — proceeding without captions", - clip_start, clip_end, highlight_candidate_id, - ) - else: - logger.info( - "No transcript available for highlight=%s — proceeding without captions", - highlight_candidate_id, - ) - except Exception as cap_exc: - logger.warning( - "Caption generation failed for highlight=%s: %s — proceeding without captions", - highlight_candidate_id, cap_exc, + if not captions: + logger.info( + "Captions disabled for highlight=%s — skipping caption generation", + highlight_candidate_id, ) + else: + try: + transcript_data: list | None = None + if source_video.transcript_path: + try: + with open(source_video.transcript_path, "r") as fh: + raw = json.load(fh) + if isinstance(raw, dict): + transcript_data = raw.get("segments", raw.get("results", [])) + elif isinstance(raw, list): + transcript_data = raw + except (FileNotFoundError, json.JSONDecodeError, OSError) as io_exc: + logger.warning( + "Failed to load transcript for captions highlight=%s: %s", + highlight_candidate_id, io_exc, + ) + + if transcript_data: + from pipeline.highlight_scorer import extract_word_timings + + word_timings = extract_word_timings(transcript_data, clip_start, clip_end) + if word_timings: + ass_content = generate_ass_captions(word_timings, clip_start) + ass_path = write_ass_file( + ass_content, + Path(f"/tmp/captions_{highlight_candidate_id}.ass"), + ) + captions_ok = True + logger.info( + "Generated captions for highlight=%s (%d words)", + highlight_candidate_id, len(word_timings), + ) + else: + logger.warning( + "No word timings in transcript window [%.1f–%.1f]s for highlight=%s — proceeding without captions", + clip_start, clip_end, highlight_candidate_id, + ) + else: + logger.info( + "No transcript available for highlight=%s — proceeding without captions", + highlight_candidate_id, + ) + except Exception as cap_exc: + logger.warning( + "Caption generation failed for highlight=%s: %s — proceeding without captions", + highlight_candidate_id, cap_exc, + ) # ── Load creator template config (if available) ───────────────── intro_path: Path | None = None diff --git a/backend/routers/creators.py b/backend/routers/creators.py index c18b2cb..f89f759 100644 --- a/backend/routers/creators.py +++ b/backend/routers/creators.py @@ -1,7 +1,8 @@ """Creator endpoints for Chrysopedia API. Enhanced with sort (random default per R014), genre filter, and -technique/video counts for browse pages. +technique/video counts for browse pages. Includes admin endpoints +for shorts template configuration. """ import logging @@ -11,14 +12,24 @@ from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from auth import require_role from database import get_session -from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage -from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem +from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage, User, UserRole +from schemas import ( + CreatorBrowseItem, + CreatorDetail, + CreatorRead, + CreatorTechniqueItem, + ShortsTemplateConfig, + ShortsTemplateUpdate, +) logger = logging.getLogger("chrysopedia.creators") router = APIRouter(prefix="/creators", tags=["creators"]) +_require_admin = require_role(UserRole.admin) + @router.get("") async def list_creators( @@ -195,3 +206,93 @@ async def get_creator( genre_breakdown=genre_breakdown, follower_count=follower_count, ) + + +# ── Admin: Shorts Template Config ──────────────────────────────────────────── + +admin_router = APIRouter(prefix="/admin/creators", tags=["admin-creators"]) + + +@admin_router.get( + "/{creator_id}/shorts-template", + response_model=ShortsTemplateConfig, +) +async def get_shorts_template( + creator_id: str, + _admin: Annotated[User, Depends(_require_admin)], + db: AsyncSession = Depends(get_session), +) -> ShortsTemplateConfig: + """Return the current shorts template config for a creator. + + Returns default values when the creator has no template set. + """ + stmt = select(Creator).where(Creator.id == creator_id) + result = await db.execute(stmt) + creator = result.scalar_one_or_none() + + if creator is None: + raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}") + + raw = creator.shorts_template or {} + return ShortsTemplateConfig( + show_intro=bool(raw.get("show_intro", False)), + intro_text=str(raw.get("intro_text", "")), + intro_duration_secs=float(raw.get("intro_duration", 2.0)), + show_outro=bool(raw.get("show_outro", False)), + outro_text=str(raw.get("outro_text", "")), + outro_duration_secs=float(raw.get("outro_duration", 2.0)), + accent_color=str(raw.get("accent_color", "#22d3ee")), + font_family=str(raw.get("font_family", "Inter")), + ) + + +@admin_router.put( + "/{creator_id}/shorts-template", + response_model=ShortsTemplateConfig, +) +async def update_shorts_template( + creator_id: str, + body: ShortsTemplateUpdate, + _admin: Annotated[User, Depends(_require_admin)], + db: AsyncSession = Depends(get_session), +) -> ShortsTemplateConfig: + """Save shorts template config for a creator. + + Validates all fields and stores as JSONB on the Creator row. + """ + stmt = select(Creator).where(Creator.id == creator_id) + result = await db.execute(stmt) + creator = result.scalar_one_or_none() + + if creator is None: + raise HTTPException(status_code=404, detail=f"Creator not found: {creator_id}") + + # Store using the keys that card_renderer.parse_template_config expects + creator.shorts_template = { + "show_intro": body.show_intro, + "intro_text": body.intro_text, + "intro_duration": body.intro_duration_secs, + "show_outro": body.show_outro, + "outro_text": body.outro_text, + "outro_duration": body.outro_duration_secs, + "accent_color": body.accent_color, + "font_family": body.font_family, + } + await db.commit() + await db.refresh(creator) + + logger.info( + "Updated shorts template for creator_id=%s (intro=%s, outro=%s)", + creator_id, body.show_intro, body.show_outro, + ) + + return ShortsTemplateConfig( + show_intro=body.show_intro, + intro_text=body.intro_text, + intro_duration_secs=body.intro_duration_secs, + show_outro=body.show_outro, + outro_text=body.outro_text, + outro_duration_secs=body.outro_duration_secs, + accent_color=body.accent_color, + font_family=body.font_family, + ) diff --git a/backend/routers/shorts.py b/backend/routers/shorts.py index 0d798c7..8f6645b 100644 --- a/backend/routers/shorts.py +++ b/backend/routers/shorts.py @@ -62,12 +62,17 @@ class GenerateResponse(BaseModel): @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) @@ -104,7 +109,7 @@ async def generate_shorts( from pipeline.stages import stage_generate_shorts try: - task = stage_generate_shorts.delay(highlight_id) + task = stage_generate_shorts.delay(highlight_id, captions=captions) logger.info( "Shorts generation dispatched highlight_id=%s task_id=%s", highlight_id, diff --git a/backend/schemas.py b/backend/schemas.py index 38dae84..84b1282 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -817,3 +817,33 @@ class PostListResponse(BaseModel): """Paginated list of posts.""" items: list[PostRead] = Field(default_factory=list) total: int = 0 + + +# ── Shorts Template ────────────────────────────────────────────────────────── + +class ShortsTemplateConfig(BaseModel): + """Read schema for creator shorts template configuration.""" + show_intro: bool = False + intro_text: str = "" + intro_duration_secs: float = 2.0 + show_outro: bool = False + outro_text: str = "" + outro_duration_secs: float = 2.0 + accent_color: str = "#22d3ee" + font_family: str = "Inter" + + +class ShortsTemplateUpdate(BaseModel): + """Update payload for creator shorts template configuration.""" + intro_text: str = Field(default="", max_length=100) + outro_text: str = Field(default="", max_length=100) + accent_color: str = Field( + default="#22d3ee", + pattern=r"^#[0-9a-fA-F]{6}$", + description="Hex color code, e.g. #22d3ee", + ) + font_family: str = Field(default="Inter", max_length=50) + intro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0) + outro_duration_secs: float = Field(default=2.0, ge=1.0, le=5.0) + show_intro: bool = False + show_outro: bool = False diff --git a/frontend/src/api/shorts.ts b/frontend/src/api/shorts.ts index a15f454..f91060f 100644 --- a/frontend/src/api/shorts.ts +++ b/frontend/src/api/shorts.ts @@ -42,9 +42,13 @@ export interface DownloadResponse { // ── API functions ──────────────────────────────────────────────────────────── -export function generateShorts(highlightId: string): Promise { +export function generateShorts( + highlightId: string, + captions: boolean = true, +): Promise { + const params = captions ? "" : "?captions=false"; return request( - `${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`, + `${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}${params}`, { method: "POST" }, ); } diff --git a/frontend/src/api/templates.ts b/frontend/src/api/templates.ts new file mode 100644 index 0000000..0d8bf41 --- /dev/null +++ b/frontend/src/api/templates.ts @@ -0,0 +1,39 @@ +import { request, BASE } from "./client"; + +// ── Types ──────────────────────────────────────────────────────────────────── + +export interface ShortsTemplateConfig { + show_intro: boolean; + intro_text: string; + intro_duration_secs: number; + show_outro: boolean; + outro_text: string; + outro_duration_secs: number; + accent_color: string; + font_family: string; +} + +export type ShortsTemplateUpdate = ShortsTemplateConfig; + +// ── API functions ──────────────────────────────────────────────────────────── + +export function fetchShortsTemplate( + creatorId: string, +): Promise { + return request( + `${BASE}/admin/creators/${encodeURIComponent(creatorId)}/shorts-template`, + ); +} + +export function updateShortsTemplate( + creatorId: string, + config: ShortsTemplateUpdate, +): Promise { + return request( + `${BASE}/admin/creators/${encodeURIComponent(creatorId)}/shorts-template`, + { + method: "PUT", + body: JSON.stringify(config), + }, + ); +} diff --git a/frontend/src/pages/HighlightQueue.module.css b/frontend/src/pages/HighlightQueue.module.css index fec04a1..f8618d0 100644 --- a/frontend/src/pages/HighlightQueue.module.css +++ b/frontend/src/pages/HighlightQueue.module.css @@ -489,4 +489,149 @@ .trimActions { margin-left: 0; } + + .templateGrid { + grid-template-columns: 1fr; + } +} + +/* ── Template config section ───────────────────────────────────────────────── */ + +.templateSection { + margin-bottom: 1.25rem; + border: 1px solid var(--color-border); + border-radius: 8px; + background: var(--color-bg-surface); + overflow: hidden; +} + +.templateToggle { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-secondary); + background: none; + border: none; + cursor: pointer; + transition: background 0.15s; +} + +.templateToggle:hover { + background: var(--color-bg-surface-hover, rgba(255, 255, 255, 0.03)); +} + +.templateChevron { + font-size: 0.75rem; + color: var(--color-text-muted); +} + +.templateBody { + padding: 0 1rem 1rem; +} + +.templateGrid { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 1rem; + margin-bottom: 0.75rem; +} + +.templateGroup { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.templateLabel { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + font-weight: 600; + color: var(--color-text-secondary); + cursor: pointer; +} + +.templateLabel input[type="checkbox"] { + accent-color: var(--color-accent, #00ffd1); +} + +.templateInput { + width: 100%; + padding: 0.375rem 0.5rem; + font-size: 0.8125rem; + border: 1px solid var(--color-border); + border-radius: 4px; + background: var(--color-bg-surface); + color: var(--color-text-primary); + box-sizing: border-box; +} + +.templateInput:focus { + outline: none; + border-color: var(--color-accent); +} + +.templateInput:disabled { + opacity: 0.4; +} + +.templateRangeLabel { + display: flex; + flex-direction: column; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 500; + color: var(--color-text-muted); +} + +.templateRangeLabel input[type="range"] { + width: 100%; + accent-color: var(--color-accent, #00ffd1); +} + +.templateRangeLabel input[type="range"]:disabled { + opacity: 0.4; +} + +.colorInput { + width: 2rem; + height: 1.5rem; + padding: 0; + border: 1px solid var(--color-border); + border-radius: 4px; + cursor: pointer; + background: none; +} + +.templateActions { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.templateMsg { + font-size: 0.75rem; + color: var(--color-badge-approved-text, #22c55e); +} + +/* ── Captions toggle ──────────────────────────────────────────────────────── */ + +.captionToggle { + display: inline-flex; + align-items: center; + gap: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; + white-space: nowrap; +} + +.captionToggle input[type="checkbox"] { + accent-color: var(--color-accent, #00ffd1); } diff --git a/frontend/src/pages/HighlightQueue.tsx b/frontend/src/pages/HighlightQueue.tsx index 54b1daa..5dc7d65 100644 --- a/frontend/src/pages/HighlightQueue.tsx +++ b/frontend/src/pages/HighlightQueue.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { SidebarNav } from "./CreatorDashboard"; +import { useAuth } from "../context/AuthContext"; import { ApiError } from "../api/client"; import { fetchCreatorHighlights, @@ -15,6 +16,11 @@ import { getShortDownloadUrl, type GeneratedShort, } from "../api/shorts"; +import { + fetchShortsTemplate, + updateShortsTemplate, + type ShortsTemplateConfig, +} from "../api/templates"; import { useDocumentTitle } from "../hooks/useDocumentTitle"; import styles from "./HighlightQueue.module.css"; @@ -129,6 +135,25 @@ export default function HighlightQueue() { const [copiedShort, setCopiedShort] = useState<{ id: string; kind: "share" | "embed" } | null>(null); const pollRef = useRef | null>(null); + // Template config state + const { user } = useAuth(); + const [templateOpen, setTemplateOpen] = useState(false); + const [template, setTemplate] = useState({ + show_intro: false, + intro_text: "", + intro_duration_secs: 2.0, + show_outro: false, + outro_text: "", + outro_duration_secs: 2.0, + accent_color: "#22d3ee", + font_family: "Inter", + }); + const [templateSaving, setTemplateSaving] = useState(false); + const [templateMsg, setTemplateMsg] = useState(null); + + // Per-highlight captions toggle: default true + const [captionsMap, setCaptionsMap] = useState>(new Map()); + const loadHighlights = useCallback(async (tab: FilterTab) => { setLoading(true); setError(null); @@ -224,6 +249,34 @@ export default function HighlightQueue() { }; }, [shortsMap]); + // Load template config on mount if the user has a creator_id + useEffect(() => { + if (!user?.creator_id) return; + fetchShortsTemplate(user.creator_id) + .then(setTemplate) + .catch(() => { + // Non-critical — keep defaults + }); + }, [user?.creator_id]); + + const handleSaveTemplate = async () => { + if (!user?.creator_id) return; + setTemplateSaving(true); + setTemplateMsg(null); + try { + const saved = await updateShortsTemplate(user.creator_id, template); + setTemplate(saved); + setTemplateMsg("Template saved"); + setTimeout(() => setTemplateMsg(null), 2000); + } catch (err) { + setTemplateMsg( + err instanceof ApiError ? err.detail : "Failed to save template", + ); + } finally { + setTemplateSaving(false); + } + }; + const handleTabChange = (tab: FilterTab) => { setActiveTab(tab); setExpandedId(null); @@ -306,8 +359,9 @@ export default function HighlightQueue() { const handleGenerateShorts = async (highlightId: string) => { setGeneratingIds((prev) => new Set(prev).add(highlightId)); setError(null); + const captions = captionsMap.get(highlightId) ?? true; try { - await generateShorts(highlightId); + await generateShorts(highlightId, captions); // Immediately fetch shorts to show pending status const res = await fetchShorts(highlightId); setShortsMap((prev) => { @@ -385,6 +439,149 @@ export default function HighlightQueue() { ))} + {/* Template config collapsible section */} + {user?.creator_id && ( +
+ + {templateOpen && ( +
+
+ {/* Intro */} +
+ + + setTemplate((t) => ({ ...t, intro_text: e.target.value })) + } + disabled={!template.show_intro} + /> + +
+ + {/* Outro */} +
+ + + setTemplate((t) => ({ ...t, outro_text: e.target.value })) + } + disabled={!template.show_outro} + /> + +
+ + {/* Style */} +
+ + +
+
+
+ + {templateMsg && ( + {templateMsg} + )} +
+
+ )} +
+ )} + {/* Error */} {error &&
{error}
} @@ -547,13 +744,29 @@ export default function HighlightQueue() { {expandedId === h.id ? "Close" : "Trim"} {showGenerateBtn && ( - + <> + + + )} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo index f3515d9..2238751 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/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/EmbedPlayer.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/ShortPlayer.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","./src/utils/clipboard.ts"],"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/templates.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/EmbedPlayer.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/ShortPlayer.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","./src/utils/clipboard.ts"],"version":"5.6.3"} \ No newline at end of file