"""FastAPI server for Reel Studio v2. Thin HTTP layer over core.pipeline. Long operations run as background jobs: the POST returns {job_id} and the frontend polls GET /api/jobs/{id}. Structure mirrors the data model: - Project-level (shared): references, style profile, topic pool. - Reel-level (per video): script, voice, media, renders, clone. Every generative step has a `/prompt` endpoint returning the exact assembled prompt (for QC/editing) and accepts an optional ``prompt`` override on run. Run: .venv/bin/uvicorn server.app:app --port 8000 """ from __future__ import annotations import base64 import os import secrets from pathlib import Path from typing import Optional from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel from core.contracts import ( CAPTION_PRESETS, DEFAULT_VARIANTS, NicheProfile, OverlaySet, ReferenceEntry, Script, SfxSet, StyleProfile, Topic, TopicBatch, VariantSpec, from_dict, to_dict, ) from core.pipeline import Pipeline from core.stages.clone import CloneError from server.jobs import JobManager app = FastAPI(title="Reel Studio v2") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173", "http://localhost:5174", "http://127.0.0.1:5174"], allow_methods=["*"], allow_headers=["*"], ) # Optional login gate (HTTP Basic). Two ways to configure it, via Space secrets: # APP_USERS="alice:pw1,bob:pw2" — named accounts you control; add/remove # individuals, each logs in with their own # username + password. # APP_PASSWORD="shared-secret" — a single shared password (any username). # You can set either or both. If neither is set, the app is open to anyone. def _parse_users(raw: str) -> dict[str, str]: users: dict[str, str] = {} for pair in raw.split(","): name, sep, pw = pair.strip().partition(":") if sep and name.strip() and pw.strip(): users[name.strip()] = pw.strip() return users _APP_PASSWORD = os.environ.get("APP_PASSWORD", "").strip() _APP_USERS = _parse_users(os.environ.get("APP_USERS", "")) if _APP_PASSWORD or _APP_USERS: @app.middleware("http") async def _require_auth(request: Request, call_next): # Health check is unauthenticated so platform probes / uptime pings / # keep-alives can confirm the app is actually up (not gated behind 401). if request.url.path == "/api/health": return await call_next(request) header = request.headers.get("authorization", "") ok = False if header.startswith("Basic "): try: decoded = base64.b64decode(header[6:]).decode("utf-8", "ignore") username, _, supplied = decoded.partition(":") except Exception: username, supplied = "", "" expected = _APP_USERS.get(username) if expected is not None and secrets.compare_digest(supplied, expected): ok = True # named account elif _APP_PASSWORD and secrets.compare_digest(supplied, _APP_PASSWORD): ok = True # shared-password fallback if not ok: return Response( "Authentication required", status_code=401, headers={"WWW-Authenticate": 'Basic realm="Reel Studio"'}, ) return await call_next(request) pipeline = Pipeline() jobs = JobManager() app.mount("/files", StaticFiles(directory=str(pipeline.store.root)), name="files") # Music lives outside projects/ (assets/music) — serve it so it's previewable. pipeline.config.music_dir.mkdir(parents=True, exist_ok=True) app.mount("/music-files", StaticFiles(directory=str(pipeline.config.music_dir)), name="music") def _url(path: str) -> str: if not path: return "" try: rel = Path(path).resolve().relative_to(pipeline.store.root.resolve()) except ValueError: return "" return f"/files/{rel.as_posix()}" def _music_url(path: str) -> str: """URL for a track in assets/music (served at /music-files).""" if not path: return "" p = Path(path) try: if p.resolve().parent == pipeline.config.music_dir.resolve(): return f"/music-files/{p.name}" except OSError: pass return "" def _project_or_404(project_id: str): project = pipeline.store.load_project(project_id) if not project: raise HTTPException(404, f"No project {project_id}") return project def _reel_or_404(project_id: str, reel_id: str): reel = pipeline.store.load_reel(project_id, reel_id) if not reel: raise HTTPException(404, f"No reel {reel_id}") return reel # --------------------------------------------------------------------------- # Request models # --------------------------------------------------------------------------- class NicheIn(BaseModel): topic: str keywords: list[str] = [] subreddits: list[str] = [] audience: str = "" tone: str = "" class ReferenceIn(BaseModel): kind: str name: str url: str = "" channel_id: str = "" notes: str = "" class ProjectIn(BaseModel): name: str niche: NicheIn format: str = "youtube_short" references: list[ReferenceIn] = [] class NicheSuggestIn(BaseModel): topic: str class SuggestIn(BaseModel): n: int = 5 prompt: Optional[str] = None class StyleIn(BaseModel): prompt: Optional[str] = None class TopicsIn(BaseModel): n: int = 8 prompt: Optional[str] = None class TopicEditIn(BaseModel): topics: list[dict] class ReelIn(BaseModel): name: str = "" topic: str = "" class ScriptGenIn(BaseModel): topic: str prompt: Optional[str] = None target_sec: Optional[float] = None # aim total spoken length at ~this many seconds class ScriptPromptIn(BaseModel): topic: str class SceneRegenIn(BaseModel): scene: int prompt: Optional[str] = None class VoiceIn(BaseModel): voice: str = "" provider: Optional[str] = None class SwapIn(BaseModel): line_index: int class SelectClipIn(BaseModel): line_index: int index: int class ClipStartIn(BaseModel): line_index: int start: float # seconds to seek into the source clip before trimming class ReframeIn(BaseModel): line_index: int reframe: list # [{t, x, y, zoom}] pan/zoom keyframes (t = seconds into the clip) class ExtendIn(BaseModel): line_index: int extend: float # extra seconds to hold the clip after its narration class TrackIn(BaseModel): line_index: int box: dict # {x, y, w, h} as 9:16 output fractions — the player's start region start_offset: float = 0.0 # seconds into the scene where tracking begins anchors: list = [] # optional [{t,x,y}] correction points → anchored tracking class SpeedIn(BaseModel): line_index: int speed: float = 1.0 freeze: float = 0.0 class MusicIn(BaseModel): filename: Optional[str] = None # '' clears, None leaves unchanged volume: Optional[float] = None start: Optional[float] = None # offset into the track (beat-drop sync) begin: Optional[float] = None # reel time the music starts beat_sync: Optional[bool] = None # pulse zoom on the music beats beat_sync_intensity: Optional[float] = None # 0..1 punch amplitude class RenderIn(BaseModel): variants: Optional[list[dict]] = None class CloneIn(BaseModel): url: str # --------------------------------------------------------------------------- # Meta # --------------------------------------------------------------------------- @app.get("/api/health") def health(): cfg = pipeline.config return { "ok": True, "keys": { "gemini": bool(cfg.gemini_api_key), "gemini_key_count": len(cfg.gemini_api_keys), "pexels": bool(cfg.pexels_api_key), "youtube": bool(cfg.youtube_api_key), "freesound": bool(cfg.freesound_api_key), "elevenlabs": bool(cfg.elevenlabs_api_key), }, "adapters": {"llm": cfg.llm_adapter, "tts": cfg.tts_adapter, "stock": cfg.stock_adapter}, "tts_providers": pipeline.available_tts_providers(), "caption_presets": list(CAPTION_PRESETS), "videogen": pipeline.videogen_available(), } @app.get("/api/jobs/{job_id}") def get_job(job_id: str): job = jobs.get(job_id) if not job: raise HTTPException(404, "No such job") return job.to_dict() @app.get("/api/voices") def voices(provider: Optional[str] = None): try: used, names = pipeline.voices(provider) return {"provider": used, "voices": names, "providers": pipeline.available_tts_providers()} except Exception as e: raise HTTPException(503, f"TTS adapter unavailable: {e}") @app.get("/api/elevenlabs/usage") def elevenlabs_usage(): """ElevenLabs credit usage (characters used / limit) for the UI.""" return pipeline.elevenlabs_usage() or {"available": False} @app.post("/api/voices/clone") async def clone_voice(name: str = Form(...), description: str = Form(""), file: UploadFile = File(...)): """Instant Voice Cloning from an uploaded sample → a new narration voice.""" try: res = pipeline.clone_voice_eleven(name, await file.read(), file.filename or "sample.mp3", description) return {**res, "voices": pipeline.voices("elevenlabs")[1]} except Exception as e: raise HTTPException(400, str(e)) class DesignVoiceIn(BaseModel): description: str sample_text: str = "Welcome back to the channel. Today we're breaking down a moment nobody saw coming." @app.post("/api/voices/design") def design_voice(body: DesignVoiceIn): """Voice Design: preview synthetic voices from a text description.""" try: return {"previews": pipeline.design_voice_eleven(body.description, body.sample_text)} except Exception as e: raise HTTPException(400, str(e)) class CreateVoiceIn(BaseModel): name: str description: str = "" generated_voice_id: str @app.post("/api/voices/create") def create_voice(body: CreateVoiceIn): """Save a chosen designed-voice preview as a permanent narration voice.""" try: res = pipeline.create_voice_eleven(body.name, body.description, body.generated_voice_id) return {**res, "voices": pipeline.voices("elevenlabs")[1]} except Exception as e: raise HTTPException(400, str(e)) class VoiceSampleIn(BaseModel): provider: Optional[str] = None voice: str = "" @app.post("/api/voices/sample") def voice_sample(body: VoiceSampleIn): """Synthesize (and cache) a short preview clip for a provider+voice.""" try: path = pipeline.voice_sample(body.provider, body.voice) return {"url": _url(path)} except Exception as e: raise HTTPException(503, f"Could not generate sample: {e}") @app.get("/api/music") def list_music(): """Royalty-free tracks available in assets/music, each with a preview URL.""" names = pipeline.list_music() return { "tracks": names, # names (kept for compatibility) "items": [{"name": n, "url": f"/music-files/{n}"} for n in names], } @app.post("/api/music/upload") async def upload_music(file: UploadFile = File(...)): try: name = pipeline.save_uploaded_music(file.filename or "track.mp3", await file.read()) except Exception as e: raise HTTPException(400, str(e)) names = pipeline.list_music() return {"filename": name, "tracks": names, "items": [{"name": n, "url": f"/music-files/{n}"} for n in names]} @app.get("/api/music/search") def search_music(q: str = ""): """Search Jamendo for royalty-free tracks (empty if no JAMENDO_CLIENT_ID).""" return {"results": pipeline.music_search(q), "enabled": bool(pipeline.config.jamendo_client_id)} class MusicImportIn(BaseModel): track: dict class GenMusicIn(BaseModel): prompt: str length_sec: float = 15 @app.post("/api/music/generate") def generate_music(body: GenMusicIn): """ElevenLabs: generate a background track from a prompt, add it to the library.""" try: name = pipeline.generate_music_eleven(body.prompt, int(max(3, min(body.length_sec, 120)) * 1000)) except Exception as e: raise HTTPException(400, str(e)) names = pipeline.list_music() return {"filename": name, "tracks": names, "items": [{"name": n, "url": f"/music-files/{n}"} for n in names]} @app.post("/api/music/import") def import_music(body: MusicImportIn): """Download a chosen Jamendo track into the library and return the new list.""" try: name = pipeline.import_music_track(body.track) except Exception as e: raise HTTPException(400, str(e)) return {"filename": name, "tracks": pipeline.list_music()} @app.post("/api/niche/suggest") def suggest_niche(body: NicheSuggestIn): if not body.topic.strip(): raise HTTPException(400, "A niche topic is required") try: return pipeline.suggest_niche_fields(body.topic) except Exception as e: raise HTTPException(503, f"Autofill unavailable: {e}") # --------------------------------------------------------------------------- # Projects # --------------------------------------------------------------------------- @app.get("/api/projects") def list_projects(): return [to_dict(p) for p in pipeline.store.list_projects()] @app.post("/api/projects") def create_project(body: ProjectIn): try: project = pipeline.create_project( body.name, NicheProfile(**body.niche.model_dump()), body.format, [ReferenceEntry(**r.model_dump()) for r in body.references], ) except ValueError as e: raise HTTPException(400, str(e)) return to_dict(project) class ImportIn(BaseModel): version: int = 1 project_id: str files: dict @app.post("/api/projects/import") def import_project(body: ImportIn): """Recreate a project from a browser-held bundle (client-side persistence). The frontend caches each project's bundle in the browser and replays it here after the server (ephemeral on free hosting) has wiped its disk. """ try: pid = pipeline.store.import_bundle(body.model_dump()) except ValueError as e: raise HTTPException(400, str(e)) return {"project_id": pid} @app.get("/api/projects/{project_id}/export") def export_project(project_id: str): """The project's durable state as a small JSON bundle the browser caches.""" bundle = pipeline.store.export_bundle(project_id) if bundle is None: raise HTTPException(404, "project not found") return bundle @app.get("/api/projects/{project_id}") def get_project(project_id: str): """Project-level data + the list of reels (metadata only).""" project = _project_or_404(project_id) return { "project": to_dict(project), "style": to_dict(pipeline.store.load_style(project_id)), "topics": to_dict(pipeline.store.load_topics(project_id)), "reels": [to_dict(r) for r in pipeline.store.list_reels(project_id)], } @app.patch("/api/projects/{project_id}") def update_project(project_id: str, body: ProjectIn): project = _project_or_404(project_id) project.name = body.name or project.name project.niche = NicheProfile(**body.niche.model_dump()) project.format = body.format project.references = [ReferenceEntry(**r.model_dump()) for r in body.references] pipeline.store.save_project(project) return to_dict(project) @app.delete("/api/projects/{project_id}") def delete_project(project_id: str): _project_or_404(project_id) pipeline.store.delete_project(project_id) return {"ok": True} # --------------------------------------------------------------------------- # References / style (project-level) # --------------------------------------------------------------------------- @app.post("/api/projects/{project_id}/references/suggest/prompt") def suggest_prompt(project_id: str, body: SuggestIn): project = _project_or_404(project_id) return {"prompt": pipeline.prepare_suggest_prompt(project, n=body.n)} @app.post("/api/projects/{project_id}/references/suggest") def suggest_references(project_id: str, body: SuggestIn): project = _project_or_404(project_id) job = jobs.submit("suggest", lambda progress: [ to_dict(r) for r in pipeline.suggest_references(project, n=body.n, prompt=body.prompt) ]) return {"job_id": job.id} @app.post("/api/projects/{project_id}/style/prompt") def style_prompt(project_id: str): project = _project_or_404(project_id) job = jobs.submit("style-prompt", lambda progress: {"prompt": pipeline.prepare_style_prompt(project, progress)}) return {"job_id": job.id} @app.post("/api/projects/{project_id}/style/refresh") def refresh_style(project_id: str, body: StyleIn): project = _project_or_404(project_id) job = jobs.submit("style", lambda progress: to_dict(pipeline.build_style(project, prompt=body.prompt, progress=progress))) return {"job_id": job.id} @app.put("/api/projects/{project_id}/style") def save_style(project_id: str, body: dict): """Persist a user-edited style profile (editable output).""" project = _project_or_404(project_id) profile = from_dict(StyleProfile, body) return to_dict(pipeline.save_style(project, profile)) # --------------------------------------------------------------------------- # Topics (project-level pool) # --------------------------------------------------------------------------- @app.post("/api/projects/{project_id}/topics/prompt") def topics_prompt(project_id: str, body: TopicsIn): project = _project_or_404(project_id) n = max(1, min(body.n, 20)) job = jobs.submit("topics-prompt", lambda progress: {"prompt": pipeline.prepare_topics_prompt(project, n=n, progress=progress)}) return {"job_id": job.id} @app.post("/api/projects/{project_id}/topics/generate") def generate_topics(project_id: str, body: TopicsIn): project = _project_or_404(project_id) n = max(1, min(body.n, 20)) job = jobs.submit("topics", lambda progress: to_dict( pipeline.generate_topics(project, n=n, prompt=body.prompt, progress=progress) )) return {"job_id": job.id} @app.put("/api/projects/{project_id}/topics") def edit_topics(project_id: str, body: TopicEditIn): project = _project_or_404(project_id) batch = pipeline.store.load_topics(project_id) or TopicBatch() batch.topics = [from_dict(Topic, t) for t in body.topics] return to_dict(pipeline.save_topics(project, batch)) # --------------------------------------------------------------------------- # Reels # --------------------------------------------------------------------------- def _media_payload(project_id: str, reel_id: str): manifest = pipeline.store.load_media(project_id, reel_id) if not manifest: return None # Backfill true clip durations for any selected clip stored before durations # were probed (older uploads have duration_sec=0), so the trim slider spans # the whole clip. One-time: persist if anything was filled in. from core.utils import ffprobe_duration changed = False for sm in manifest.scenes: cand = sm.candidates[sm.selected] if 0 <= sm.selected < len(sm.candidates) else None if cand and cand.local_path and cand.duration_sec <= 0 and Path(cand.local_path).exists(): try: cand.duration_sec = ffprobe_duration(cand.local_path) changed = True except Exception: pass if changed: pipeline.store.save_media(project_id, reel_id, manifest) data = to_dict(manifest) data["music_url"] = _music_url(manifest.music_path) for scene in data["scenes"]: for cand in scene["candidates"]: cand["local_url"] = _url(cand["local_path"]) return data def _renders_payload(project_id: str, reel_id: str): batch = pipeline.store.load_renders(project_id, reel_id) if not batch: return None data = to_dict(batch) for r in data["results"]: r["url"] = _url(r["path"]) return data def _voice_payload(project_id: str, reel_id: str): voice = pipeline.store.load_voice(project_id, reel_id) if not voice: return None data = to_dict(voice) for line in data["lines"]: line["url"] = _url(line["audio_path"]) # browser-playable for live preview return data def _sfx_payload(project_id: str, reel_id: str): sset = pipeline.store.load_sfx(project_id, reel_id) or SfxSet() data = to_dict(sset) for c in data["cues"]: c["url"] = _url(c["file"]) return data def _reel_payload(project_id: str, reel_id: str): reel = _reel_or_404(project_id, reel_id) return { "reel": to_dict(reel), "script": to_dict(pipeline.store.load_script(project_id, reel_id)), "voice": _voice_payload(project_id, reel_id), "media": _media_payload(project_id, reel_id), "renders": _renders_payload(project_id, reel_id), "clone": to_dict(pipeline.store.load_clone(project_id, reel_id)), "overlays": _overlays_payload(project_id, reel_id), "sfx": _sfx_payload(project_id, reel_id), } @app.get("/api/projects/{project_id}/reels") def list_reels(project_id: str): _project_or_404(project_id) return [to_dict(r) for r in pipeline.store.list_reels(project_id)] @app.post("/api/projects/{project_id}/reels") def create_reel(project_id: str, body: ReelIn): project = _project_or_404(project_id) return to_dict(pipeline.create_reel(project, name=body.name, topic=body.topic)) @app.get("/api/projects/{project_id}/reels/{reel_id}") def get_reel(project_id: str, reel_id: str): _project_or_404(project_id) return _reel_payload(project_id, reel_id) @app.delete("/api/projects/{project_id}/reels/{reel_id}") def delete_reel(project_id: str, reel_id: str): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) pipeline.delete_reel(project, reel_id) return {"ok": True} # -- script (reel-level) ----------------------------------------------------- @app.post("/api/projects/{project_id}/reels/{reel_id}/script/prompt") def script_prompt(project_id: str, reel_id: str, body: ScriptPromptIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) return {"prompt": pipeline.prepare_script_prompt(project, reel_id, body.topic)} @app.post("/api/projects/{project_id}/reels/{reel_id}/script/generate") def generate_script(project_id: str, reel_id: str, body: ScriptGenIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) job = jobs.submit("script", lambda progress: to_dict( pipeline.generate_script(project, reel_id, body.topic, prompt=body.prompt, target_sec=body.target_sec) )) return {"job_id": job.id} @app.patch("/api/projects/{project_id}/reels/{reel_id}/script") def patch_script(project_id: str, reel_id: str, body: dict): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) script = from_dict(Script, body) if not script.scenes: raise HTTPException(400, "Script must keep at least one scene") return to_dict(pipeline.save_script(project, reel_id, script)) @app.post("/api/projects/{project_id}/reels/{reel_id}/script/scene-prompt") def scene_prompt(project_id: str, reel_id: str, body: SceneRegenIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: return {"prompt": pipeline.prepare_scene_prompt(project, reel_id, body.scene)} except (RuntimeError, ValueError) as e: raise HTTPException(400, str(e)) @app.post("/api/projects/{project_id}/reels/{reel_id}/script/regenerate-scene") def regenerate_scene(project_id: str, reel_id: str, body: SceneRegenIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) job = jobs.submit("scene", lambda progress: to_dict( pipeline.regenerate_scene(project, reel_id, body.scene, prompt=body.prompt) )) return {"job_id": job.id} # -- voice / media (reel-level) ---------------------------------------------- @app.post("/api/projects/{project_id}/reels/{reel_id}/voice") def synthesize_voice(project_id: str, reel_id: str, body: VoiceIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) job = jobs.submit("voice", lambda progress: to_dict( pipeline.synthesize_voice(project, reel_id, voice=body.voice, provider=body.provider, progress=progress) )) return {"job_id": job.id} @app.post("/api/projects/{project_id}/reels/{reel_id}/media") def gather_media(project_id: str, reel_id: str): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) def run(progress): pipeline.gather_media(project, reel_id, progress) return _media_payload(project_id, reel_id) return {"job_id": jobs.submit("media", run).id} @app.get("/api/projects/{project_id}/reels/{reel_id}/media") def get_media(project_id: str, reel_id: str): _project_or_404(project_id) _reel_or_404(project_id, reel_id) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/media/swap") def swap_clip(project_id: str, reel_id: str, body: SwapIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.swap_clip(project, reel_id, body.line_index) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/media/select") def select_clip(project_id: str, reel_id: str, body: SelectClipIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.select_clip(project, reel_id, body.line_index, body.index) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/media/clip-start") def set_clip_start(project_id: str, reel_id: str, body: ClipStartIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.set_clip_start(project, reel_id, body.line_index, body.start) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/media/reframe") def set_reframe(project_id: str, reel_id: str, body: ReframeIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.set_reframe(project, reel_id, body.line_index, body.reframe) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/media/extend") def set_extend(project_id: str, reel_id: str, body: ExtendIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.set_extend(project, reel_id, body.line_index, body.extend) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/media/speed") def set_speed(project_id: str, reel_id: str, body: SpeedIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.set_speed(project, reel_id, body.line_index, body.speed, body.freeze) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/track") def track_overlay(project_id: str, reel_id: str, body: TrackIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: if body.anchors and len(body.anchors) >= 2: keys = pipeline.track_overlay_path(project, reel_id, body.line_index, body.anchors, body.box) else: keys = pipeline.track_overlay(project, reel_id, body.line_index, body.box, body.start_offset) except RuntimeError as e: raise HTTPException(400, str(e)) return {"keyframes": keys} @app.post("/api/projects/{project_id}/reels/{reel_id}/media/upload-clip") async def upload_clip(project_id: str, reel_id: str, line_index: int = Form(...), file: UploadFile = File(...)): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.add_uploaded_clip(project, reel_id, line_index, file.filename or "clip.mp4", await file.read()) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) class GenClipIn(BaseModel): line_index: int prompt: Optional[str] = None @app.post("/api/projects/{project_id}/reels/{reel_id}/media/clip-prompt") def clip_prompt(project_id: str, reel_id: str, body: GenClipIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) job = jobs.submit("clip-prompt", lambda progress: {"prompt": pipeline.prepare_clip_prompt(project, reel_id, body.line_index)}) return {"job_id": job.id} @app.post("/api/projects/{project_id}/reels/{reel_id}/media/ai-prompts") def ai_video_prompts(project_id: str, reel_id: str): """Detailed text-to-video prompts for every scene, in one LLM call.""" project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) job = jobs.submit( "ai-prompts", lambda progress: {"prompts": pipeline.ai_video_prompts(project, reel_id, progress)}, ) return {"job_id": job.id} @app.post("/api/projects/{project_id}/reels/{reel_id}/media/generate-clip") def generate_clip(project_id: str, reel_id: str, body: GenClipIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) def run(progress): pipeline.generate_clip(project, reel_id, body.line_index, prompt=body.prompt, progress=progress) return _media_payload(project_id, reel_id) return {"job_id": jobs.submit("generate-clip", run).id} @app.put("/api/projects/{project_id}/reels/{reel_id}/music") def set_reel_music(project_id: str, reel_id: str, body: MusicIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.set_reel_music(project, reel_id, body.filename, volume=body.volume, start=body.start, begin=body.begin, beat_sync=body.beat_sync, beat_sync_intensity=body.beat_sync_intensity) except RuntimeError as e: raise HTTPException(400, str(e)) return _media_payload(project_id, reel_id) # -- render (reel-level) ----------------------------------------------------- @app.post("/api/projects/{project_id}/reels/{reel_id}/render") def start_render(project_id: str, reel_id: str, body: RenderIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) if body.variants: variants = [from_dict(VariantSpec, v) for v in body.variants] for v in variants: if v.caption_preset not in CAPTION_PRESETS: raise HTTPException(400, f"Unknown caption preset {v.caption_preset!r}") else: variants = list(DEFAULT_VARIANTS) def run(progress): pipeline.render(project, reel_id, variants=variants, progress=progress) return _renders_payload(project_id, reel_id) return {"job_id": jobs.submit("render", run).id} @app.get("/api/projects/{project_id}/reels/{reel_id}/render") def get_renders(project_id: str, reel_id: str): _project_or_404(project_id) _reel_or_404(project_id, reel_id) return _renders_payload(project_id, reel_id) class DubIn(BaseModel): result_index: int = 0 target_lang: str = "es" @app.post("/api/projects/{project_id}/reels/{reel_id}/dub") def dub(project_id: str, reel_id: str, body: DubIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) batch = pipeline.store.load_renders(project_id, reel_id) if not batch or not batch.results or body.result_index >= len(batch.results): raise HTTPException(400, "Render the reel first, then dub it") src = batch.results[body.result_index].path def run(progress): return {"url": _url(pipeline.dub_render(project, reel_id, src, body.target_lang, progress))} return {"job_id": jobs.submit("dub", run).id} # -- overlays / effects (reel-level) ----------------------------------------- def _overlays_payload(project_id: str, reel_id: str): oset = pipeline.store.load_overlays(project_id, reel_id) or OverlaySet() data = to_dict(oset) if oset.atmosphere and oset.atmosphere.file: data["atmosphere"]["url"] = _url(oset.atmosphere.file) return data @app.get("/api/projects/{project_id}/reels/{reel_id}/overlays") def get_overlays(project_id: str, reel_id: str): _project_or_404(project_id) _reel_or_404(project_id, reel_id) return _overlays_payload(project_id, reel_id) @app.put("/api/projects/{project_id}/reels/{reel_id}/overlays") def put_overlays(project_id: str, reel_id: str, body: dict): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) pipeline.save_overlays(project, reel_id, from_dict(OverlaySet, body)) return _overlays_payload(project_id, reel_id) @app.post("/api/projects/{project_id}/reels/{reel_id}/overlays/auto-edit/prompt") def auto_edit_prompt(project_id: str, reel_id: str): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: return {"prompt": pipeline.prepare_auto_edit_prompt(project, reel_id)} except RuntimeError as e: raise HTTPException(400, str(e)) class AutoEditIn(BaseModel): prompt: Optional[str] = None @app.post("/api/projects/{project_id}/reels/{reel_id}/overlays/auto-edit") def auto_edit(project_id: str, reel_id: str, body: AutoEditIn = AutoEditIn()): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) job = jobs.submit("auto-edit", lambda progress: to_dict( pipeline.auto_edit(project, reel_id, prompt=body.prompt, progress=progress))) return {"job_id": job.id} @app.post("/api/projects/{project_id}/reels/{reel_id}/atmosphere/upload") async def upload_atmosphere(project_id: str, reel_id: str, file: UploadFile = File(...)): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.upload_atmosphere(project, reel_id, file.filename or "atmo.mp4", await file.read()) except Exception as e: raise HTTPException(400, str(e)) return _overlays_payload(project_id, reel_id) # -- manual SFX cues (reel-level) -------------------------------------------- class SfxImportIn(BaseModel): result: dict start: float = 0.0 @app.get("/api/projects/{project_id}/reels/{reel_id}/sfx") def get_sfx(project_id: str, reel_id: str): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) pipeline.get_sfx(project, reel_id) return _sfx_payload(project_id, reel_id) @app.put("/api/projects/{project_id}/reels/{reel_id}/sfx") def put_sfx(project_id: str, reel_id: str, body: dict): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) pipeline.save_sfx(project, reel_id, from_dict(SfxSet, body)) return _sfx_payload(project_id, reel_id) @app.get("/api/sfx/search") def search_sfx(q: str = ""): return {"results": pipeline.search_sfx(q), "enabled": bool(pipeline.config.freesound_api_key)} @app.post("/api/projects/{project_id}/reels/{reel_id}/sfx/import") def import_sfx(project_id: str, reel_id: str, body: SfxImportIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.import_sfx(project, reel_id, body.result, body.start) return _sfx_payload(project_id, reel_id) except Exception as e: raise HTTPException(400, str(e)) @app.post("/api/projects/{project_id}/reels/{reel_id}/sfx/upload") async def upload_sfx(project_id: str, reel_id: str, start: float = Form(0.0), file: UploadFile = File(...)): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.upload_sfx(project, reel_id, file.filename or "sfx.mp3", await file.read(), start) return _sfx_payload(project_id, reel_id) except Exception as e: raise HTTPException(400, str(e)) class TypewriterSfxIn(BaseModel): start: float = 0.0 duration: float = 1.0 chars: int = 8 class GenSfxIn(BaseModel): text: str start: float = 0.0 duration: Optional[float] = None # 0.5–22s, or None to auto-fit @app.post("/api/projects/{project_id}/reels/{reel_id}/sfx/typewriter") def typewriter_sfx(project_id: str, reel_id: str, body: TypewriterSfxIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.synth_typewriter_sfx(project, reel_id, body.start, body.duration, body.chars) return _sfx_payload(project_id, reel_id) except Exception as e: raise HTTPException(400, str(e)) @app.post("/api/projects/{project_id}/reels/{reel_id}/sfx/generate") def generate_sfx(project_id: str, reel_id: str, body: GenSfxIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) try: pipeline.generate_sfx_eleven(project, reel_id, body.text, body.start, body.duration) return _sfx_payload(project_id, reel_id) except Exception as e: raise HTTPException(400, str(e)) # --------------------------------------------------------------------------- # Clone (creates a new reel) # --------------------------------------------------------------------------- @app.post("/api/projects/{project_id}/clone") def clone_reel(project_id: str, body: CloneIn): project = _project_or_404(project_id) def run(progress): try: result = pipeline.clone_reel(project, body.url, progress) return {"reel": to_dict(result["reel"]), "script": to_dict(result["script"])} except CloneError as e: raise RuntimeError(str(e)) from e return {"job_id": jobs.submit("clone", run).id} # --------------------------------------------------------------------------- # Translate (creates a translated sibling reel) # --------------------------------------------------------------------------- @app.get("/api/translate/languages") def translate_languages(): return {"languages": pipeline.translate_languages()} class TranslateIn(BaseModel): target_lang: str @app.post("/api/projects/{project_id}/reels/{reel_id}/translate") def translate_reel(project_id: str, reel_id: str, body: TranslateIn): project = _project_or_404(project_id) _reel_or_404(project_id, reel_id) def run(progress): result = pipeline.translate_reel(project, reel_id, body.target_lang, progress) return {"reel": to_dict(result["reel"]), "script": to_dict(result["script"]), "language": result["language"]} return {"job_id": jobs.submit("translate", run).id} # --------------------------------------------------------------------------- # Frontend (built SPA). Mounted LAST so /api, /files, /music-files win first. # Falls back to index.html for unknown paths so the single-page app loads. # --------------------------------------------------------------------------- _WEB_DIST = Path(__file__).resolve().parent.parent / "web" / "dist" if (_WEB_DIST / "index.html").exists(): app.mount("/", StaticFiles(directory=str(_WEB_DIST), html=True), name="web")