xvepkj's picture
Exempt /api/health from auth; point keep-alive at it
3410c75
Raw
History Blame
40.3 kB
"""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")