chore: Add GET/PUT shorts-template admin endpoints, collapsible templat…
- "backend/routers/creators.py" - "backend/schemas.py" - "frontend/src/api/templates.ts" - "frontend/src/pages/HighlightQueue.tsx" - "frontend/src/pages/HighlightQueue.module.css" - "backend/routers/shorts.py" - "backend/pipeline/stages.py" - "frontend/src/api/shorts.ts" GSD-Task: S04/T03
This commit is contained in:
parent
fa493e2640
commit
a60f4074dc
13 changed files with 726 additions and 63 deletions
|
|
@ -52,7 +52,7 @@ Steps:
|
||||||
- Estimate: 2h
|
- 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
|
- 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')"
|
- 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:
|
Steps:
|
||||||
1. Read T02 outputs to understand the shorts_template schema on the Creator model.
|
1. Read T02 outputs to understand the shorts_template schema on the Creator model.
|
||||||
|
|
|
||||||
22
.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json
Normal file
22
.gsd/milestones/M024/slices/S04/tasks/T02-VERIFY.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
93
.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md
Normal file
93
.gsd/milestones/M024/slices/S04/tasks/T03-SUMMARY.md
Normal file
|
|
@ -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.
|
||||||
|
|
@ -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_chapters.router, prefix="/api/v1")
|
||||||
app.include_router(creator_highlights.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.router, prefix="/api/v1")
|
||||||
|
app.include_router(creators.admin_router, prefix="/api/v1")
|
||||||
app.include_router(follows.router, prefix="/api/v1")
|
app.include_router(follows.router, prefix="/api/v1")
|
||||||
app.include_router(highlights.router, prefix="/api/v1")
|
app.include_router(highlights.router, prefix="/api/v1")
|
||||||
app.include_router(ingest.router, prefix="/api/v1")
|
app.include_router(ingest.router, prefix="/api/v1")
|
||||||
|
|
|
||||||
|
|
@ -2866,13 +2866,17 @@ def extract_personality_profile(self, creator_id: str) -> str:
|
||||||
# ── Stage: Shorts Generation ─────────────────────────────────────────────────
|
# ── Stage: Shorts Generation ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@celery_app.task(bind=True, max_retries=1, default_retry_delay=60)
|
@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.
|
"""Generate video shorts for an approved highlight candidate.
|
||||||
|
|
||||||
Creates one GeneratedShort row per FormatPreset, extracts the clip via
|
Creates one GeneratedShort row per FormatPreset, extracts the clip via
|
||||||
ffmpeg, uploads to MinIO, and updates status. Each preset is independent —
|
ffmpeg, uploads to MinIO, and updates status. Each preset is independent —
|
||||||
a failure on one does not block the others.
|
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.
|
Returns the highlight_candidate_id on completion.
|
||||||
"""
|
"""
|
||||||
from pipeline.shorts_generator import PRESETS, extract_clip_with_template, resolve_video_path
|
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,
|
clip_start, clip_end,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ── Generate captions from transcript (if available) ────────────
|
# ── Generate captions from transcript (if available and requested) ─
|
||||||
ass_path: Path | None = None
|
ass_path: Path | None = None
|
||||||
captions_ok = False
|
captions_ok = False
|
||||||
try:
|
if not captions:
|
||||||
transcript_data: list | None = None
|
logger.info(
|
||||||
if source_video.transcript_path:
|
"Captions disabled for highlight=%s — skipping caption generation",
|
||||||
try:
|
highlight_candidate_id,
|
||||||
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,
|
|
||||||
)
|
)
|
||||||
|
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) ─────────────────
|
# ── Load creator template config (if available) ─────────────────
|
||||||
intro_path: Path | None = None
|
intro_path: Path | None = None
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
"""Creator endpoints for Chrysopedia API.
|
"""Creator endpoints for Chrysopedia API.
|
||||||
|
|
||||||
Enhanced with sort (random default per R014), genre filter, and
|
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
|
import logging
|
||||||
|
|
@ -11,14 +12,24 @@ from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from auth import require_role
|
||||||
from database import get_session
|
from database import get_session
|
||||||
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage
|
from models import Creator, CreatorFollow, KeyMoment, SourceVideo, TechniquePage, User, UserRole
|
||||||
from schemas import CreatorBrowseItem, CreatorDetail, CreatorRead, CreatorTechniqueItem
|
from schemas import (
|
||||||
|
CreatorBrowseItem,
|
||||||
|
CreatorDetail,
|
||||||
|
CreatorRead,
|
||||||
|
CreatorTechniqueItem,
|
||||||
|
ShortsTemplateConfig,
|
||||||
|
ShortsTemplateUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
logger = logging.getLogger("chrysopedia.creators")
|
logger = logging.getLogger("chrysopedia.creators")
|
||||||
|
|
||||||
router = APIRouter(prefix="/creators", tags=["creators"])
|
router = APIRouter(prefix="/creators", tags=["creators"])
|
||||||
|
|
||||||
|
_require_admin = require_role(UserRole.admin)
|
||||||
|
|
||||||
|
|
||||||
@router.get("")
|
@router.get("")
|
||||||
async def list_creators(
|
async def list_creators(
|
||||||
|
|
@ -195,3 +206,93 @@ async def get_creator(
|
||||||
genre_breakdown=genre_breakdown,
|
genre_breakdown=genre_breakdown,
|
||||||
follower_count=follower_count,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -62,12 +62,17 @@ class GenerateResponse(BaseModel):
|
||||||
@router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202)
|
@router.post("/generate/{highlight_id}", response_model=GenerateResponse, status_code=202)
|
||||||
async def generate_shorts(
|
async def generate_shorts(
|
||||||
highlight_id: str,
|
highlight_id: str,
|
||||||
|
captions: bool = True,
|
||||||
db: AsyncSession = Depends(get_session),
|
db: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Dispatch shorts generation for an approved highlight candidate.
|
"""Dispatch shorts generation for an approved highlight candidate.
|
||||||
|
|
||||||
Creates pending GeneratedShort rows for each format preset and dispatches
|
Creates pending GeneratedShort rows for each format preset and dispatches
|
||||||
the Celery task. Returns 202 Accepted with status.
|
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
|
# Validate highlight exists and is approved
|
||||||
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
|
stmt = select(HighlightCandidate).where(HighlightCandidate.id == highlight_id)
|
||||||
|
|
@ -104,7 +109,7 @@ async def generate_shorts(
|
||||||
from pipeline.stages import stage_generate_shorts
|
from pipeline.stages import stage_generate_shorts
|
||||||
|
|
||||||
try:
|
try:
|
||||||
task = stage_generate_shorts.delay(highlight_id)
|
task = stage_generate_shorts.delay(highlight_id, captions=captions)
|
||||||
logger.info(
|
logger.info(
|
||||||
"Shorts generation dispatched highlight_id=%s task_id=%s",
|
"Shorts generation dispatched highlight_id=%s task_id=%s",
|
||||||
highlight_id,
|
highlight_id,
|
||||||
|
|
|
||||||
|
|
@ -817,3 +817,33 @@ class PostListResponse(BaseModel):
|
||||||
"""Paginated list of posts."""
|
"""Paginated list of posts."""
|
||||||
items: list[PostRead] = Field(default_factory=list)
|
items: list[PostRead] = Field(default_factory=list)
|
||||||
total: int = 0
|
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
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,13 @@ export interface DownloadResponse {
|
||||||
|
|
||||||
// ── API functions ────────────────────────────────────────────────────────────
|
// ── API functions ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function generateShorts(highlightId: string): Promise<GenerateResponse> {
|
export function generateShorts(
|
||||||
|
highlightId: string,
|
||||||
|
captions: boolean = true,
|
||||||
|
): Promise<GenerateResponse> {
|
||||||
|
const params = captions ? "" : "?captions=false";
|
||||||
return request<GenerateResponse>(
|
return request<GenerateResponse>(
|
||||||
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}`,
|
`${BASE}/admin/shorts/generate/${encodeURIComponent(highlightId)}${params}`,
|
||||||
{ method: "POST" },
|
{ method: "POST" },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
39
frontend/src/api/templates.ts
Normal file
39
frontend/src/api/templates.ts
Normal file
|
|
@ -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<ShortsTemplateConfig> {
|
||||||
|
return request<ShortsTemplateConfig>(
|
||||||
|
`${BASE}/admin/creators/${encodeURIComponent(creatorId)}/shorts-template`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function updateShortsTemplate(
|
||||||
|
creatorId: string,
|
||||||
|
config: ShortsTemplateUpdate,
|
||||||
|
): Promise<ShortsTemplateConfig> {
|
||||||
|
return request<ShortsTemplateConfig>(
|
||||||
|
`${BASE}/admin/creators/${encodeURIComponent(creatorId)}/shorts-template`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(config),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -489,4 +489,149 @@
|
||||||
.trimActions {
|
.trimActions {
|
||||||
margin-left: 0;
|
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { SidebarNav } from "./CreatorDashboard";
|
import { SidebarNav } from "./CreatorDashboard";
|
||||||
|
import { useAuth } from "../context/AuthContext";
|
||||||
import { ApiError } from "../api/client";
|
import { ApiError } from "../api/client";
|
||||||
import {
|
import {
|
||||||
fetchCreatorHighlights,
|
fetchCreatorHighlights,
|
||||||
|
|
@ -15,6 +16,11 @@ import {
|
||||||
getShortDownloadUrl,
|
getShortDownloadUrl,
|
||||||
type GeneratedShort,
|
type GeneratedShort,
|
||||||
} from "../api/shorts";
|
} from "../api/shorts";
|
||||||
|
import {
|
||||||
|
fetchShortsTemplate,
|
||||||
|
updateShortsTemplate,
|
||||||
|
type ShortsTemplateConfig,
|
||||||
|
} from "../api/templates";
|
||||||
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
import { useDocumentTitle } from "../hooks/useDocumentTitle";
|
||||||
import styles from "./HighlightQueue.module.css";
|
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 [copiedShort, setCopiedShort] = useState<{ id: string; kind: "share" | "embed" } | null>(null);
|
||||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
|
||||||
|
// Template config state
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [templateOpen, setTemplateOpen] = useState(false);
|
||||||
|
const [template, setTemplate] = useState<ShortsTemplateConfig>({
|
||||||
|
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<string | null>(null);
|
||||||
|
|
||||||
|
// Per-highlight captions toggle: default true
|
||||||
|
const [captionsMap, setCaptionsMap] = useState<Map<string, boolean>>(new Map());
|
||||||
|
|
||||||
const loadHighlights = useCallback(async (tab: FilterTab) => {
|
const loadHighlights = useCallback(async (tab: FilterTab) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
@ -224,6 +249,34 @@ export default function HighlightQueue() {
|
||||||
};
|
};
|
||||||
}, [shortsMap]);
|
}, [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) => {
|
const handleTabChange = (tab: FilterTab) => {
|
||||||
setActiveTab(tab);
|
setActiveTab(tab);
|
||||||
setExpandedId(null);
|
setExpandedId(null);
|
||||||
|
|
@ -306,8 +359,9 @@ export default function HighlightQueue() {
|
||||||
const handleGenerateShorts = async (highlightId: string) => {
|
const handleGenerateShorts = async (highlightId: string) => {
|
||||||
setGeneratingIds((prev) => new Set(prev).add(highlightId));
|
setGeneratingIds((prev) => new Set(prev).add(highlightId));
|
||||||
setError(null);
|
setError(null);
|
||||||
|
const captions = captionsMap.get(highlightId) ?? true;
|
||||||
try {
|
try {
|
||||||
await generateShorts(highlightId);
|
await generateShorts(highlightId, captions);
|
||||||
// Immediately fetch shorts to show pending status
|
// Immediately fetch shorts to show pending status
|
||||||
const res = await fetchShorts(highlightId);
|
const res = await fetchShorts(highlightId);
|
||||||
setShortsMap((prev) => {
|
setShortsMap((prev) => {
|
||||||
|
|
@ -385,6 +439,149 @@ export default function HighlightQueue() {
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Template config collapsible section */}
|
||||||
|
{user?.creator_id && (
|
||||||
|
<div className={styles.templateSection}>
|
||||||
|
<button
|
||||||
|
className={styles.templateToggle}
|
||||||
|
onClick={() => setTemplateOpen((v) => !v)}
|
||||||
|
>
|
||||||
|
<span>Shorts Template</span>
|
||||||
|
<span className={styles.templateChevron}>
|
||||||
|
{templateOpen ? "▾" : "▸"}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{templateOpen && (
|
||||||
|
<div className={styles.templateBody}>
|
||||||
|
<div className={styles.templateGrid}>
|
||||||
|
{/* Intro */}
|
||||||
|
<div className={styles.templateGroup}>
|
||||||
|
<label className={styles.templateLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={template.show_intro}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({ ...t, show_intro: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Show Intro
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.templateInput}
|
||||||
|
placeholder="Intro text"
|
||||||
|
maxLength={100}
|
||||||
|
value={template.intro_text}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({ ...t, intro_text: e.target.value }))
|
||||||
|
}
|
||||||
|
disabled={!template.show_intro}
|
||||||
|
/>
|
||||||
|
<label className={styles.templateRangeLabel}>
|
||||||
|
Duration: {template.intro_duration_secs.toFixed(1)}s
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={0.5}
|
||||||
|
value={template.intro_duration_secs}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({
|
||||||
|
...t,
|
||||||
|
intro_duration_secs: parseFloat(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!template.show_intro}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outro */}
|
||||||
|
<div className={styles.templateGroup}>
|
||||||
|
<label className={styles.templateLabel}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={template.show_outro}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({ ...t, show_outro: e.target.checked }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Show Outro
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.templateInput}
|
||||||
|
placeholder="Outro text"
|
||||||
|
maxLength={100}
|
||||||
|
value={template.outro_text}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({ ...t, outro_text: e.target.value }))
|
||||||
|
}
|
||||||
|
disabled={!template.show_outro}
|
||||||
|
/>
|
||||||
|
<label className={styles.templateRangeLabel}>
|
||||||
|
Duration: {template.outro_duration_secs.toFixed(1)}s
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
step={0.5}
|
||||||
|
value={template.outro_duration_secs}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({
|
||||||
|
...t,
|
||||||
|
outro_duration_secs: parseFloat(e.target.value),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={!template.show_outro}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Style */}
|
||||||
|
<div className={styles.templateGroup}>
|
||||||
|
<label className={styles.templateRangeLabel}>
|
||||||
|
Accent Color
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={template.accent_color}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({ ...t, accent_color: e.target.value }))
|
||||||
|
}
|
||||||
|
className={styles.colorInput}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className={styles.templateRangeLabel}>
|
||||||
|
Font
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className={styles.templateInput}
|
||||||
|
value={template.font_family}
|
||||||
|
onChange={(e) =>
|
||||||
|
setTemplate((t) => ({ ...t, font_family: e.target.value }))
|
||||||
|
}
|
||||||
|
maxLength={50}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={styles.templateActions}>
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.approveBtn}`}
|
||||||
|
onClick={handleSaveTemplate}
|
||||||
|
disabled={templateSaving}
|
||||||
|
>
|
||||||
|
{templateSaving ? "Saving…" : "Save Template"}
|
||||||
|
</button>
|
||||||
|
{templateMsg && (
|
||||||
|
<span className={styles.templateMsg}>{templateMsg}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && <div className={styles.errorState}>{error}</div>}
|
{error && <div className={styles.errorState}>{error}</div>}
|
||||||
|
|
||||||
|
|
@ -547,13 +744,29 @@ export default function HighlightQueue() {
|
||||||
{expandedId === h.id ? "Close" : "Trim"}
|
{expandedId === h.id ? "Close" : "Trim"}
|
||||||
</button>
|
</button>
|
||||||
{showGenerateBtn && (
|
{showGenerateBtn && (
|
||||||
<button
|
<>
|
||||||
className={`${styles.actionBtn} ${styles.generateBtn}`}
|
<label className={styles.captionToggle}>
|
||||||
disabled={isGenerating}
|
<input
|
||||||
onClick={() => handleGenerateShorts(h.id)}
|
type="checkbox"
|
||||||
>
|
checked={captionsMap.get(h.id) ?? true}
|
||||||
{isGenerating ? "Generating…" : "Generate Shorts"}
|
onChange={(e) =>
|
||||||
</button>
|
setCaptionsMap((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(h.id, e.target.checked);
|
||||||
|
return next;
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
Captions
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className={`${styles.actionBtn} ${styles.generateBtn}`}
|
||||||
|
disabled={isGenerating}
|
||||||
|
onClick={() => handleGenerateShorts(h.id)}
|
||||||
|
>
|
||||||
|
{isGenerating ? "Generating…" : "Generate Shorts"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</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/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"}
|
{"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"}
|
||||||
Loading…
Add table
Reference in a new issue