Spaces:
Paused
Paused
| """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: | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| 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(), | |
| } | |
| def get_job(job_id: str): | |
| job = jobs.get(job_id) | |
| if not job: | |
| raise HTTPException(404, "No such job") | |
| return job.to_dict() | |
| 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}") | |
| def elevenlabs_usage(): | |
| """ElevenLabs credit usage (characters used / limit) for the UI.""" | |
| return pipeline.elevenlabs_usage() or {"available": False} | |
| 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." | |
| 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 | |
| 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 = "" | |
| 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}") | |
| 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], | |
| } | |
| 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]} | |
| 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 | |
| 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]} | |
| 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()} | |
| 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 | |
| # --------------------------------------------------------------------------- | |
| def list_projects(): | |
| return [to_dict(p) for p in pipeline.store.list_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 | |
| 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} | |
| 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 | |
| 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)], | |
| } | |
| 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) | |
| def delete_project(project_id: str): | |
| _project_or_404(project_id) | |
| pipeline.store.delete_project(project_id) | |
| return {"ok": True} | |
| # --------------------------------------------------------------------------- | |
| # References / style (project-level) | |
| # --------------------------------------------------------------------------- | |
| def suggest_prompt(project_id: str, body: SuggestIn): | |
| project = _project_or_404(project_id) | |
| return {"prompt": pipeline.prepare_suggest_prompt(project, n=body.n)} | |
| 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} | |
| 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} | |
| 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} | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| 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} | |
| 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} | |
| 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), | |
| } | |
| def list_reels(project_id: str): | |
| _project_or_404(project_id) | |
| return [to_dict(r) for r in pipeline.store.list_reels(project_id)] | |
| 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)) | |
| def get_reel(project_id: str, reel_id: str): | |
| _project_or_404(project_id) | |
| return _reel_payload(project_id, 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) ----------------------------------------------------- | |
| 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)} | |
| 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} | |
| 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)) | |
| 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)) | |
| 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) ---------------------------------------------- | |
| 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} | |
| 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} | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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) | |
| 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} | |
| 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 | |
| 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} | |
| 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} | |
| 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} | |
| 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) ----------------------------------------------------- | |
| 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} | |
| 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" | |
| 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 | |
| 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) | |
| 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) | |
| 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 | |
| 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} | |
| 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 | |
| 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) | |
| 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) | |
| def search_sfx(q: str = ""): | |
| return {"results": pipeline.search_sfx(q), "enabled": bool(pipeline.config.freesound_api_key)} | |
| 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)) | |
| 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 | |
| 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)) | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| 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) | |
| # --------------------------------------------------------------------------- | |
| def translate_languages(): | |
| return {"languages": pipeline.translate_languages()} | |
| class TranslateIn(BaseModel): | |
| target_lang: str | |
| 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") | |