Initial commit: Revids REST API for LTX-2.3 image-to-video generation

- FastAPI with job polling: POST /generate, GET /jobs/{id}, /download, /health
- SQLite job persistence (aiosqlite), background task processing
- LTX-2.3 DistilledPipeline via native ltx-pipelines (editable install from submodule)
- Configurable model paths, LoRA support, FP8 quantization via env vars
- Single-concurrency GPU lock for safe inference
- LTX-2 as git submodule under libs/
This commit is contained in:
2026-05-31 05:54:51 -04:00
commit 2d50679ec0
12 changed files with 708 additions and 0 deletions
View File
+42
View File
@@ -0,0 +1,42 @@
from __future__ import annotations
import os
from typing import Optional
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "Revids"
host: str = "0.0.0.0"
port: int = 8000
reload: bool = True
# LTX-2.3 model paths (update after downloading models)
ltx_distilled_checkpoint: str = "models/ltx-2.3-22b-distilled-1.1.safetensors"
ltx_gemma_root: str = "models/gpt-4o-5805-ava-gguf-model"
ltx_spatial_upsampler: str | None = None
ltx_device: str = "cuda"
ltx_quantization: str = "fp8_cast"
# LoRA paths (list of "path:scale" strings, env: REVIDS_LTX_LORAS="models/my-lora.safetensors:1.0")
ltx_loras: list[str] = []
video_output_dir: str = os.path.join(os.path.dirname(os.path.dirname(__file__)), "videos")
# DB
db_path: str = os.path.join(os.path.dirname(__file__), "jobs.db")
# Generation defaults
default_frames: int = 65
default_fps: float = 24.0
default_width: int = 768
default_height: int = 512
# Concurrency
max_concurrent_jobs: int = 1
model_config = {"env_prefix": "REVIDS_", "env_file": ".env"}
settings = Settings()
+118
View File
@@ -0,0 +1,118 @@
from __future__ import annotations
import os
from datetime import datetime, timezone
from typing import Optional
import aiosqlite
from pydantic import BaseModel
VALID_FRAME_COUNTS = {8 * n + 1 for n in range(1, 129)}
VALID_STATUSES = {"pending", "processing", "completed", "failed"}
class JobRecord(BaseModel):
id: str
status: str = "pending"
prompt: Optional[str] = None
params: dict | None = None
error: Optional[str] = None
created_at: str
completed_at: Optional[str] = None
class JobDB:
def __init__(self, db_path: str) -> None:
self.db_path = db_path
async def init(self) -> None:
os.makedirs(os.path.dirname(self.db_path) or ".", exist_ok=True)
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"""
CREATE TABLE IF NOT EXISTS jobs (
id TEXT PRIMARY KEY,
status TEXT NOT NULL DEFAULT 'pending',
prompt TEXT,
params TEXT,
error TEXT,
created_at TEXT NOT NULL,
completed_at TEXT
)
"""
)
await db.commit()
async def create_job(self, job: JobRecord) -> None:
async with aiosqlite.connect(self.db_path) as db:
await db.execute(
"INSERT INTO jobs (id, status, prompt, params, created_at) VALUES (?, ?, ?, ?, ?)",
(
job.id,
job.status,
job.prompt,
_json_encode(job.params),
job.created_at,
),
)
await db.commit()
async def get_job(self, job_id: str) -> JobRecord | None:
async with aiosqlite.connect(self.db_path) as db:
db.row_factory = aiosqlite.Row
async with db.execute("SELECT * FROM jobs WHERE id = ?", (job_id,)) as cursor:
row = await cursor.fetchone()
if row is None:
return None
return JobRecord(
id=row["id"],
status=row["status"],
prompt=row["prompt"],
params=_json_decode(row["params"]),
error=row["error"],
created_at=row["created_at"],
completed_at=row["completed_at"],
)
async def update_status(
self,
job_id: str,
status: str,
error: Optional[str] = None,
) -> None:
now = datetime.now(timezone.utc).isoformat()
async with aiosqlite.connect(self.db_path) as db:
if status in {"completed", "failed"}:
await db.execute(
"UPDATE jobs SET status = ?, error = ?, completed_at = ? WHERE id = ?",
(status, error, now, job_id),
)
else:
await db.execute(
"UPDATE jobs SET status = ?, error = ? WHERE id = ?",
(status, error, job_id),
)
await db.commit()
async def delete_job(self, job_id: str) -> bool:
async with aiosqlite.connect(self.db_path) as db:
cur = await db.execute("DELETE FROM jobs WHERE id = ?", (job_id,))
await db.commit()
return cur.rowcount > 0
def _json_encode(obj: dict | None) -> str | None:
if obj is None:
return None
import json
return json.dumps(obj)
def _json_decode(s: str | None) -> dict | None:
if s is None:
return None
import json
return json.loads(s)
+133
View File
@@ -0,0 +1,133 @@
from __future__ import annotations
import logging
from contextlib import asynccontextmanager
from typing import AsyncGenerator
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse, JSONResponse
from app.config import settings
from app.models import JobSubmitResponse, JobStatusResponse
from app.service import get_service
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator:
logger.info("Starting Revids API...")
await get_service()
yield
logger.info("Shutting down Revids API.")
app = FastAPI(
title=settings.app_name,
version="0.1.0",
lifespan=lifespan,
)
@app.get("/health")
async def health() -> JSONResponse:
import torch
gpu_available = torch.cuda.is_available()
return JSONResponse({
"status": "ok",
"gpu": gpu_available,
"gpu_name": torch.cuda.get_device_name(0) if gpu_available else None,
})
@app.post("/generate", status_code=201)
async def generate(
image: UploadFile = File(..., description="Input image (PNG, JPG, etc.)"),
prompt: str | None = Form(None),
width: int = Form(768),
height: int = Form(512),
num_frames: int = Form(65),
fps: float = Form(24.0),
seed: int | None = Form(None),
) -> JobSubmitResponse:
from PIL import Image
img_bytes = await image.read()
pil_image = Image.open(img_bytes).convert("RGB")
validate_frame_count(num_frames)
svc = await get_service()
job = await svc.submit_job(pil_image, {
"prompt": prompt,
"width": width,
"height": height,
"num_frames": num_frames,
"fps": fps,
"seed": seed,
})
return JobSubmitResponse(job_id=job.id)
@app.get("/jobs/{job_id}")
async def get_job(job_id: str) -> JobStatusResponse:
svc = await get_service()
job = await svc.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return JobStatusResponse(
job_id=job.id,
status=job.status,
prompt=job.prompt,
params=job.params,
error=job.error,
created_at=job.created_at,
completed_at=job.completed_at,
)
@app.get("/jobs/{job_id}/download")
async def download_video(job_id: str) -> FileResponse:
svc = await get_service()
job = await svc.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
if job.status != "completed":
raise HTTPException(status_code=404, detail="Video not ready")
video_path = await svc.get_video_path(job_id)
if video_path is None:
raise HTTPException(status_code=404, detail="Video file missing")
return FileResponse(video_path, media_type="video/mp4", filename=f"{job_id}.mp4")
@app.delete("/jobs/{job_id}")
async def delete_job(job_id: str) -> dict:
svc = await get_service()
job = await svc.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
deleted, _ = await svc.delete_job(job_id)
if not deleted:
raise HTTPException(status_code=404, detail="Job not found")
return {"deleted": job_id}
def validate_frame_count(num_frames: int) -> None:
valid = {8 * n + 1 for n in range(1, 129)}
if num_frames not in valid:
candidates = sorted(f for f in valid if abs(f - num_frames) <= 8)
raise HTTPException(
status_code=400,
detail=f"num_frames must be 8n+1. Got {num_frames}. Closest valid: {candidates[:4]}",
)
@app.exception_handler(HTTPException)
async def http_exception_handler(_, exc: HTTPException) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content={"error": exc.detail},
)
+43
View File
@@ -0,0 +1,43 @@
from __future__ import annotations
import uuid
from typing import Optional
from pydantic import BaseModel, Field
# --- Request ---
class GenerateRequest(BaseModel):
prompt: Optional[str] = Field(None, description="Optional text prompt to guide generation")
width: int = Field(768, ge=256, le=2048, multiple_of=32, description="Video width (must be divisible by 32)")
height: int = Field(512, ge=256, le=2048, multiple_of=32, description="Video height (must be divisible by 32)")
num_frames: int = Field(65, ge=9, le=257, description="Frame count (must be 8n+1)")
fps: float = Field(24.0, gt=0, le=60, description="Frames per second")
seed: Optional[int] = Field(None, description="Random seed for reproducibility")
# --- Job Status ---
class JobStatusResponse(BaseModel):
job_id: str
status: str # pending, processing, completed, failed
prompt: Optional[str] = None
params: Optional[dict] = None
error: Optional[str] = None
created_at: Optional[str] = None
completed_at: Optional[str] = None
# --- Submission Response ---
class JobSubmitResponse(BaseModel):
job_id: str
status: str = "pending"
message: str = "Job submitted. Poll GET /jobs/{job_id} for status."
# --- Error ---
class ErrorResponse(BaseModel):
error: str
+177
View File
@@ -0,0 +1,177 @@
from __future__ import annotations
import asyncio
import os
import uuid
from datetime import datetime, timezone
from typing import Optional
import torch
from ltx_pipelines import DistilledPipeline
from PIL import Image
from app.config import settings
from app.database import JobDB, JobRecord, VALID_FRAME_COUNTS
class LtxService:
def __init__(self, db: JobDB) -> None:
self.db = db
self.pipeline: Optional[DistilledPipeline] = None
self._load_lock = asyncio.Lock()
self._job_semaphore = asyncio.Semaphore(settings.max_concurrent_jobs)
self._video_dir = settings.video_output_dir
async def load_pipeline(self) -> None:
async with self._load_lock:
if self.pipeline is not None:
return
lora_config = self._parse_lora_paths(settings.ltx_loras)
self.pipeline = await asyncio.to_thread(
DistilledPipeline,
distilled_checkpoint_path=settings.ltx_distilled_checkpoint,
gemma_root=settings.ltx_gemma_root,
spatial_upsampler_path=settings.ltx_spatial_upsampler,
loras=lora_config,
device=settings.ltx_device,
quantization=settings.ltx_quantization,
)
@staticmethod
def _parse_lora_paths(lora_specs: list[str]) -> list[dict]:
result = []
for spec in lora_specs:
if ":" in spec:
path, scale = spec.rsplit(":", 1)
result.append({"path": path.strip(), "scale": float(scale.strip())})
else:
result.append({"path": spec.strip(), "scale": 1.0})
return result
async def submit_job(self, image: Image.Image, request_params: dict) -> JobRecord:
job_id = uuid.uuid4().hex[:12]
now = datetime.now(timezone.utc).isoformat()
prompt = request_params.get("prompt")
width = request_params.get("width", settings.default_width)
height = request_params.get("height", settings.default_height)
num_frames = request_params.get("num_frames", settings.default_frames)
fps = request_params.get("fps", settings.default_fps)
seed = request_params.get("seed")
if num_frames not in VALID_FRAME_COUNTS:
candidates = sorted(
f for f in VALID_FRAME_COUNTS if abs(f - num_frames) <= 8
)
raise ValueError(
f"num_frames must be 8n+1. Got {num_frames}. "
f"Closest valid: {candidates[:4]}"
)
job = JobRecord(
id=job_id,
status="pending",
prompt=prompt,
params={
"width": width,
"height": height,
"num_frames": num_frames,
"fps": fps,
"seed": seed,
},
created_at=now,
)
await self.db.create_job(job)
asyncio.create_task(self._process_job(job_id, image, job.params))
return job
async def _process_job(
self,
job_id: str,
image: Image.Image,
params: dict,
) -> None:
async with self._job_semaphore:
try:
await self.db.update_status(job_id, "processing")
await self.load_pipeline()
prompt = params.get("prompt", "") or ""
width = params.get("width", settings.default_width)
height = params.get("height", settings.default_height)
num_frames = params.get("num_frames", settings.default_frames)
fps = params.get("fps", settings.default_fps)
seed = params.get("seed") if params.get("seed") is not None else 42
if image.mode != "RGB":
image = image.convert("RGB")
image = image.resize((width, height), Image.LANCZOS)
output = await asyncio.to_thread(
self.pipeline,
prompt=prompt,
images=image,
width=width,
height=height,
num_frames=num_frames,
frame_rate=fps,
seed=seed,
)
video_path = os.path.join(self._video_dir, f"{job_id}.mp4")
os.makedirs(self._video_dir, exist_ok=True)
await asyncio.to_thread(self._save_video, output, video_path, fps)
await self.db.update_status(job_id, "completed")
except Exception as e:
await self.db.update_status(job_id, "failed", error=str(e))
@staticmethod
def _save_video(output: any, path: str, fps: float) -> None:
import imageio.v3 as iio
if isinstance(output, torch.Tensor):
frames = output.cpu().numpy()
elif isinstance(output, dict) and "video" in output:
v = output["video"]
frames = v.cpu().numpy() if isinstance(v, torch.Tensor) else v
else:
frames = output
if isinstance(frames, torch.Tensor):
frames = frames.cpu().numpy()
if frames.ndim == 4:
frames = frames[0]
if frames.max() <= 1.0:
frames = (frames * 255).astype("uint8")
elif frames.dtype != "uint8":
frames = frames.clip(0, 255).astype("uint8")
iio.imwrite(path, frames, fps=float(fps), codec="libx264")
async def get_job(self, job_id: str) -> JobRecord | None:
return await self.db.get_job(job_id)
async def delete_job(self, job_id: str) -> tuple[bool, str | None]:
video_path = os.path.join(self._video_dir, f"{job_id}.mp4")
deleted = await self.db.delete_job(job_id)
if os.path.exists(video_path):
os.remove(video_path)
return deleted, job_id
async def get_video_path(self, job_id: str) -> str | None:
video_path = os.path.join(self._video_dir, f"{job_id}.mp4")
if os.path.exists(video_path):
return video_path
return None
_service: Optional[LtxService] = None
async def get_service() -> LtxService:
global _service
if _service is None:
db = JobDB(settings.db_path)
await db.init()
_service = LtxService(db)
return _service