Files
hao-ai-lab-FastVideo/apps/fastvideo_studio/server.py
T
2026-10-23 09:27:21 +00:00

962 lines
35 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
FastVideo Job Runner — API server only.
Provides REST endpoints for creating, starting, stopping, and deleting
video-generation jobs powered by the FastVideo library.
This is the API-only server. Use web_server.py to serve the frontend.
Usage:
python -m fastvideo_studio.server # from the apps/ directory
python -m fastvideo_studio.server --output-dir ./videos # explicit output dir
"""
from __future__ import annotations
import argparse
import contextlib
import logging
import os
import re
import shutil
import signal
import time
import uuid
from pathlib import Path
import uvicorn
from typing import Annotated, Any
from fastapi import FastAPI, File, HTTPException, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse
from fastvideo.registry import (get_registered_model_paths, get_registered_models_with_workloads)
from fastvideo_studio.database import Database, _get_db_path
from fastvideo_studio.frames import FrameError, last_clip_for_job, last_frame_for_job
from fastvideo_studio.gpu import get_gpu_snapshot
from fastvideo_studio.job_runner import JobRunner, JobStatus
from fastvideo_studio.merge import MergeError, merge_jobs, merged_path
from fastvideo_studio.reference_preview import reference_image_response
from fastvideo_studio.scene_writer.routes import router as scene_writer_router
from fastvideo_studio.scene_writer.runner import use_server_data_dir
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, MergeSceneRequest, QueueJobsRequest,
SettingsUpdate, TrimRequest, UpdateCaptionRequest, model_label)
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
)
logger = logging.getLogger("fastvideo.studio.api")
DEFAULT_OUTPUT_DIR = os.path.join(os.path.dirname(__file__), "..", "outputs", "ui_jobs")
_available_models: list[dict[str, str]] = [{
"id": path,
"label": model_label(path)
} for path in get_registered_model_paths()]
job_runner: JobRunner
database: Database | None = None
upload_dir: str = ""
datasets_upload_dir: str = ""
verbose = 0
app = FastAPI(
title="FastVideo Job Runner API",
version="0.1.0",
description="REST API for FastVideo job management",
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(scene_writer_router)
@app.get("/api/settings")
def get_settings() -> dict[str, Any]:
"""Return persisted default options (for new job creation)."""
if database is None:
raise HTTPException(
status_code=503,
detail="Database not initialized (persistence disabled)",
)
return database.get_settings()
@app.put("/api/settings")
def update_settings(settings: SettingsUpdate) -> dict[str, Any]:
"""Update persisted default options. Only provided fields are updated."""
if database is None:
raise HTTPException(
status_code=503,
detail="Database not initialized (persistence disabled)",
)
updates = settings.model_dump(exclude_unset=True)
if not updates:
return database.get_settings()
database.save_settings(updates)
return database.get_settings()
@app.get("/api/gpus")
def list_gpus() -> dict[str, Any]:
"""Return an NVML snapshot of every GPU on the API server host."""
return get_gpu_snapshot()
@app.get("/api/models")
def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
"""Return the catalogue of available video-generation models.
Query params:
workload_type: If set (t2v, i2v, t2i), only return models that
support this workload. Otherwise return all models.
"""
if workload_type:
models = get_registered_models_with_workloads(workload_type=workload_type)
return [{"id": m["id"], "label": m["label"]} for m in models]
return _available_models
def _safe_upload_name(filename: str | None, ext: str) -> str:
"""A filesystem-safe version of the client's filename, keeping it readable.
Uploads live under a per-file uuid directory, so the basename does not have
to be unique -- only safe. Keeping the original name means the path stays
self-describing wherever it travels: the database, job logs, and payloads
copied back out to the API.
"""
stem = os.path.basename(filename or "").rsplit(".", 1)[0]
stem = re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("._-")
return f"{stem[:80] or 'upload'}{ext}"
def _upload_destination(ext: str, filename: str | None) -> str:
"""<upload_dir>/<uuid4>/<safe original name><ext>"""
directory = os.path.join(upload_dir, uuid.uuid4().hex)
os.makedirs(directory, exist_ok=True)
return os.path.join(directory, _safe_upload_name(filename, ext))
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
ALLOWED_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
ALLOWED_MEDIA_EXTENSIONS = (ALLOWED_IMAGE_EXTENSIONS | ALLOWED_VIDEO_EXTENSIONS | ALLOWED_AUDIO_EXTENSIONS)
@app.post("/api/upload-image")
async def upload_image(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
"""Upload an image file for I2V jobs. Returns the absolute path."""
global upload_dir # noqa: PLW0603
if not upload_dir:
raise HTTPException(
status_code=503,
detail="Upload directory not configured",
)
ext = Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_IMAGE_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=(f"Invalid file type. Allowed: "
f"{', '.join(ALLOWED_IMAGE_EXTENSIONS)}"),
)
os.makedirs(upload_dir, exist_ok=True)
dest_path = _upload_destination(ext, file.filename)
try:
contents = await file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save upload: {e}",
) from e
return {"path": os.path.abspath(dest_path)}
@app.post("/api/upload-media")
async def upload_media(file: Annotated[UploadFile, File()], ) -> dict[str, str]:
"""Upload an image, video or audio file for Ref2VA references.
Returns the absolute path plus the media_type MiniMax-H3 expects, so the
caller does not have to re-derive it from the extension.
"""
global upload_dir # noqa: PLW0603
if not upload_dir:
raise HTTPException(
status_code=503,
detail="Upload directory not configured",
)
ext = Path(file.filename or "").suffix.lower()
if ext not in ALLOWED_MEDIA_EXTENSIONS:
raise HTTPException(
status_code=400,
detail=(f"Invalid file type. Allowed: "
f"{', '.join(sorted(ALLOWED_MEDIA_EXTENSIONS))}"),
)
if ext in ALLOWED_VIDEO_EXTENSIONS:
media_type = "video"
elif ext in ALLOWED_AUDIO_EXTENSIONS:
media_type = "audio"
else:
media_type = "image"
os.makedirs(upload_dir, exist_ok=True)
dest_path = _upload_destination(ext, file.filename)
try:
contents = await file.read()
with open(dest_path, "wb") as f:
f.write(contents)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save upload: {e}",
) from e
return {"path": os.path.abspath(dest_path), "media_type": media_type}
ALLOWED_VIDEO_EXTENSIONS = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
def _filter_video_files(files: list[UploadFile]) -> list[UploadFile]:
"""Filter uploaded files to only include videos."""
return [f for f in files if Path(f.filename or "").suffix.lower() in ALLOWED_VIDEO_EXTENSIONS]
def _path_is_within(child: str, parent: str) -> bool:
"""True if ``child`` resolves to a location inside ``parent``."""
try:
parent_real = os.path.realpath(parent)
return os.path.commonpath([os.path.realpath(child), parent_real]) == parent_real
except (ValueError, OSError):
return False
def _staging_base_path() -> str:
"""Root under which raw uploads are staged (settings override or default)."""
settings = database.get_settings() if database is not None else {}
raw_path = (settings.get("datasetUploadPath") or settings.get("dataset_upload_path") or "")
base_path = (raw_path.strip() if raw_path and isinstance(raw_path, str) else "")
return datasets_upload_dir if not base_path else os.path.abspath(base_path)
@app.post("/api/upload-raw-dataset")
async def upload_raw_dataset(files: Annotated[list[UploadFile], File()], ) -> dict[str, Any]:
"""
Upload raw video dataset. Returns path and file list.
Filters files to only include videos. For folder upload, only adds videos.
"""
if database is None:
raise HTTPException(
status_code=503,
detail="Database not initialized",
)
base_path = _staging_base_path()
if not base_path:
raise HTTPException(
status_code=503,
detail="Dataset upload directory not configured. Set it in Settings.",
)
filtered = _filter_video_files(files)
if not filtered:
raise HTTPException(
status_code=400,
detail=("No video files found. "
f"Allowed: {', '.join(ALLOWED_VIDEO_EXTENSIONS)}"),
)
upload_id = uuid.uuid4().hex
media_folder = os.path.join(base_path, upload_id)
os.makedirs(media_folder, exist_ok=True)
file_names = []
for uf in filtered:
name = uf.filename or f"{uuid.uuid4().hex}"
if "/" in name or "\\" in name:
name = os.path.basename(name)
if not Path(name).suffix:
name += ".mp4"
dest = os.path.join(media_folder, name)
try:
contents = await uf.read()
# Detect Git LFS pointer files (they look like tiny text files, not real videos)
if contents.startswith(b"version https://git-lfs.github.com/spec/v1"):
raise HTTPException(
status_code=400,
detail=(f"File {uf.filename} appears to be a Git LFS pointer, not an actual video. "
"Please run `git lfs pull` (or otherwise download the real video files) "
"and upload the resolved videos instead."),
)
with open(dest, "wb") as f:
f.write(contents)
file_names.append(name)
except HTTPException:
# Re-raise HTTPExceptions (e.g. LFS pointer detection) as-is
raise
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to save {uf.filename}: {e}",
) from e
return {
"path": os.path.abspath(media_folder),
"upload_id": upload_id,
"file_names": file_names,
}
@app.get("/api/jobs")
def list_jobs(job_type: str | None = None) -> list[dict[str, Any]]:
"""Return jobs (newest first). Optionally filter by job_type."""
return [j.to_dict() for j in job_runner.list_jobs(job_type=job_type)]
@app.get("/api/jobs/{job_id}")
def get_job(job_id: str) -> dict[str, Any]:
"""Get details for a single job."""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return job.to_dict()
@app.post("/api/jobs", status_code=201)
def create_job(req: CreateJobRequest) -> dict[str, Any]:
"""Create a new job (does **not** start it automatically)."""
job_type = req.job_type or "inference"
if job_type == "inference":
valid_ids = {m["id"] for m in _available_models}
if req.model_id not in valid_ids:
raise HTTPException(
status_code=400,
detail=(f"Unknown model_id '{req.model_id}'. "
f"Valid options: {sorted(valid_ids)}"),
)
# Training jobs reference a dataset by id; resolve it to the on-disk media
# directory the trainer reads (the UI has no free-text path field). Falls
# through unchanged for inference and for anything already a real path.
def _resolve_dataset_path(value: str) -> str:
media_dir = _dataset_media_dir(value) if value else ""
return media_dir if media_dir and os.path.isdir(media_dir) else value
data_path = req.data_path or ""
validation_dataset_file = req.validation_dataset_file or ""
if job_type != "inference":
data_path = _resolve_dataset_path(data_path)
validation_dataset_file = _resolve_dataset_path(validation_dataset_file)
job = job_runner.create_job(
job_id=str(uuid.uuid4()),
model_id=req.model_id,
name=req.name or "",
prompt=req.prompt,
workload_type=req.workload_type or "t2v",
job_type=job_type,
image_path=req.image_path or "",
last_image_path=req.last_image_path or "",
references=req.references or [],
data_path=data_path,
max_train_steps=req.max_train_steps,
train_batch_size=req.train_batch_size,
learning_rate=req.learning_rate,
num_latent_t=req.num_latent_t,
validation_dataset_file=validation_dataset_file,
lora_rank=req.lora_rank,
negative_prompt=req.negative_prompt,
num_inference_steps=req.num_inference_steps,
num_frames=req.num_frames,
height=req.height,
width=req.width,
guidance_scale=req.guidance_scale,
guidance_rescale=req.guidance_rescale,
fps=req.fps,
seed=req.seed,
num_gpus=req.num_gpus,
dit_cpu_offload=req.dit_cpu_offload,
dit_layerwise_offload=req.dit_layerwise_offload,
text_encoder_cpu_offload=req.text_encoder_cpu_offload,
vae_cpu_offload=req.vae_cpu_offload,
image_encoder_cpu_offload=req.image_encoder_cpu_offload,
use_fsdp_inference=req.use_fsdp_inference,
enable_torch_compile=req.enable_torch_compile,
vsa_sparsity=req.vsa_sparsity,
tp_size=req.tp_size,
sp_size=req.sp_size,
dmd_use_vsa=req.dmd_use_vsa,
dmd_vsa_sparsity=req.dmd_vsa_sparsity,
dmd_denoising_steps=req.dmd_denoising_steps or "1000,757,522",
real_score_guidance_scale=req.real_score_guidance_scale,
generator_update_interval=req.generator_update_interval,
real_score_model_path=req.real_score_model_path or "",
fake_score_model_path=req.fake_score_model_path or "",
)
if database is not None:
settings = database.get_settings()
if settings.get("autoStartJob"):
try:
job = job_runner.start_job(job.id)
except ValueError as exc:
logger.warning(
"Auto-start failed for job %s: %s",
job.id,
exc,
)
return job.to_dict()
@app.post("/api/jobs/{job_id}/duplicate", status_code=201)
def duplicate_job(job_id: str) -> dict[str, Any]:
"""Create a new pending job with the same configuration as an existing one."""
try:
job = job_runner.duplicate_job(job_id, str(uuid.uuid4()))
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return job.to_dict()
@app.patch("/api/jobs/{job_id}")
def update_job(job_id: str, updates: dict[str, Any]) -> dict[str, Any]:
"""Edit a pending job's configuration. Started jobs cannot be edited."""
try:
job = job_runner.update_job_config(job_id, updates)
except ValueError as e:
detail = str(e)
status = 404 if "not found" in detail else 400
raise HTTPException(status_code=status, detail=detail) from e
return job.to_dict()
@app.post("/api/jobs/{job_id}/start")
def start_job(job_id: str) -> dict[str, Any]:
"""Start (or restart) a pending / stopped / failed job."""
try:
job = job_runner.start_job(job_id)
return job.to_dict()
except ValueError as e:
raise HTTPException(
status_code=404 if "not found" in str(e) else 409,
detail=str(e),
) from e
def _queue_error(e: ValueError) -> HTTPException:
return HTTPException(status_code=404 if "not found" in str(e) else 409, detail=str(e))
@app.post("/api/jobs/queue")
def queue_jobs(req: QueueJobsRequest) -> list[dict[str, Any]]:
"""Queue jobs, in the order given, to run as GPU slots free up.
A job that starts from another job's last frame waits for that job to
complete. All-or-nothing: if any job can't be queued, none are.
"""
try:
return [j.to_dict() for j in job_runner.enqueue_jobs(req.job_ids)]
except ValueError as e:
raise _queue_error(e) from e
@app.post("/api/jobs/{job_id}/queue")
def queue_job(job_id: str) -> dict[str, Any]:
"""Queue one job to run when a slot is free."""
try:
return job_runner.enqueue_job(job_id).to_dict()
except ValueError as e:
raise _queue_error(e) from e
@app.post("/api/jobs/{job_id}/dequeue")
def dequeue_job(job_id: str) -> dict[str, Any]:
"""Take a queued job out of the queue (back to pending)."""
try:
return job_runner.dequeue_job(job_id).to_dict()
except ValueError as e:
raise _queue_error(e) from e
@app.post("/api/jobs/{job_id}/stop")
def stop_job(job_id: str) -> dict[str, Any]:
"""Request a running job to stop.
Because video generation is a single atomic call to the FastVideo
library, the stop is *cooperative*: the flag is checked between major
phases (model loading ↔ generation ↔ saving). If the model is
already mid-forward-pass, it will complete before the stop takes
effect.
"""
try:
job = job_runner.stop_job(job_id)
return job.to_dict()
except ValueError as e:
raise HTTPException(
status_code=404 if "not found" in str(e) else 409,
detail=str(e),
) from e
@app.delete("/api/jobs/{job_id}")
def delete_job(job_id: str) -> dict[str, str]:
"""Delete a job. Running jobs are stopped first."""
if not job_runner.delete_job(job_id):
raise HTTPException(status_code=404, detail="Job not found")
return {"detail": f"Job {job_id} deleted"}
# --- Datasets ---
@app.get("/api/datasets")
def list_datasets() -> list[dict[str, Any]]:
"""Return all datasets, newest first, with file_count and size_bytes."""
if database is None:
raise HTTPException(
status_code=503,
detail="Database not initialized",
)
out = []
for ds in database.get_all_datasets():
count, size = _dataset_media_stats(ds["id"])
out.append({**ds, "file_count": count, "size_bytes": size})
return out
@app.get("/api/datasets/{dataset_id}")
def get_dataset(dataset_id: str) -> dict[str, Any]:
"""Get a single dataset by ID, with file_count and size_bytes."""
if database is None:
raise HTTPException(status_code=503, detail="Database not initialized")
ds = database.get_dataset(dataset_id)
if ds is None:
raise HTTPException(status_code=404, detail="Dataset not found")
count, size = _dataset_media_stats(dataset_id)
return {**ds, "file_count": count, "size_bytes": size}
def _dataset_media_dir(dataset_id: str) -> str:
"""Return the path to a dataset's media directory."""
return os.path.join(datasets_upload_dir, dataset_id)
def _dataset_media_stats(dataset_id: str) -> tuple[int, int]:
"""Return (file_count, total_size_bytes) for a dataset's media directory."""
media_dir = _dataset_media_dir(dataset_id)
video_exts = {".mp4", ".webm", ".mov", ".avi", ".mkv"}
count = 0
total = 0
if os.path.isdir(media_dir):
for name in os.listdir(media_dir):
ext = os.path.splitext(name)[1].lower()
path = os.path.join(media_dir, name)
if os.path.isfile(path) and ext in video_exts:
count += 1
with contextlib.suppress(OSError):
total += os.path.getsize(path)
return (count, total)
@app.post("/api/datasets", status_code=201)
def create_dataset(req: CreateDatasetRequest) -> dict[str, Any]:
"""Create a new dataset. Moves upload into datasets_upload_dir/{id}, persists dataset and captions."""
if database is None:
raise HTTPException(status_code=503, detail="Database not initialized")
if not req.upload_path:
raise HTTPException(
status_code=400,
detail="upload_path is required. Upload media files first.",
)
if not req.file_names:
raise HTTPException(
status_code=400,
detail="No media files found. Ensure at least one image or video.",
)
# upload_path must be a staging dir produced by /api/upload-raw-dataset,
# not an arbitrary client path — the code below copies then rmtrees it.
if not _path_is_within(req.upload_path, _staging_base_path()):
raise HTTPException(
status_code=400,
detail="upload_path must be a staged upload directory.",
)
dataset_id = str(uuid.uuid4())
created_at = time.time()
dest_dir = os.path.join(datasets_upload_dir, dataset_id)
if os.path.exists(dest_dir):
raise HTTPException(
status_code=409,
detail="Dataset directory already exists",
)
try:
shutil.copytree(req.upload_path, dest_dir)
except OSError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to move upload: {e}",
) from e
with contextlib.suppress(OSError):
shutil.rmtree(req.upload_path) # Best-effort cleanup of upload folder
dataset = {
"id": dataset_id,
"name": req.name,
"created_at": created_at,
}
database.insert_dataset(dataset)
for fn in req.file_names:
caption = (req.captions.get(fn, "") if req.captions else "") or ""
database.upsert_dataset_caption(dataset_id, fn, caption)
return database.get_dataset(dataset_id) or dataset
@app.get("/api/datasets/{dataset_id}/files")
def get_dataset_files(dataset_id: str) -> dict[str, Any]:
"""List media files in dataset with their captions."""
if database is None:
raise HTTPException(status_code=503, detail="Database not initialized")
ds = database.get_dataset(dataset_id)
if ds is None:
raise HTTPException(status_code=404, detail="Dataset not found")
captions = database.get_dataset_captions(dataset_id)
media_dir = _dataset_media_dir(dataset_id)
video_exts = {".mp4", ".webm", ".mov", ".avi", ".mkv"}
file_names = []
if os.path.isdir(media_dir):
for name in sorted(os.listdir(media_dir)):
ext = os.path.splitext(name)[1].lower()
path = os.path.join(media_dir, name)
if os.path.isfile(path) and ext in video_exts:
file_names.append(name)
return {
"file_names": file_names,
"captions": captions,
}
@app.put("/api/datasets/{dataset_id}/captions")
def update_dataset_caption(dataset_id: str, req: UpdateCaptionRequest) -> dict[str, str]:
"""Update a single caption for a file. Persists to database."""
if database is None:
raise HTTPException(status_code=503, detail="Database not initialized")
ds = database.get_dataset(dataset_id)
if ds is None:
raise HTTPException(status_code=404, detail="Dataset not found")
database.upsert_dataset_caption(dataset_id, req.file_name, req.caption)
return {"detail": "Caption updated"}
@app.get("/api/datasets/{dataset_id}/media/{file_name:path}")
def serve_dataset_media(dataset_id: str, file_name: str) -> FileResponse:
"""Serve a media file from a dataset."""
if database is None:
raise HTTPException(status_code=503, detail="Database not initialized")
ds = database.get_dataset(dataset_id)
if ds is None:
raise HTTPException(status_code=404, detail="Dataset not found")
media_dir = _dataset_media_dir(dataset_id)
media_path = os.path.realpath(os.path.join(media_dir, file_name))
if not (_path_is_within(media_path, media_dir) and os.path.isfile(media_path)):
raise HTTPException(status_code=404, detail="File not found")
import mimetypes
mime, _ = mimetypes.guess_type(media_path)
return FileResponse(media_path, media_type=mime or "video/mp4")
@app.delete("/api/datasets/{dataset_id}")
def delete_dataset(dataset_id: str) -> dict[str, str]:
"""Delete a dataset and its media directory."""
if database is None:
raise HTTPException(status_code=503, detail="Database not initialized")
if not database.delete_dataset(dataset_id):
raise HTTPException(status_code=404, detail="Dataset not found")
dest_dir = os.path.join(datasets_upload_dir, dataset_id)
if os.path.isdir(dest_dir):
with contextlib.suppress(OSError):
shutil.rmtree(dest_dir) # Best-effort cleanup
return {"detail": f"Dataset {dataset_id} deleted"}
@app.get("/api/jobs/{job_id}/logs")
def get_job_logs(job_id: str, after: int = 0) -> dict[str, Any]:
"""Return log lines for a job.
Query params:
after: return only lines after this index (for incremental polling).
"""
try:
return job_runner.get_job_logs(job_id, after=after)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
@app.get("/api/jobs/{job_id}/video")
def get_video(job_id: str) -> FileResponse:
"""Stream the generated video/image for a completed job."""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
if job.status != JobStatus.COMPLETED or not job.output_path:
raise HTTPException(status_code=404, detail="No output available for this job")
if not os.path.isfile(job.output_path):
raise HTTPException(status_code=404, detail="Output file not found on disk")
media_type = ("video/mp4" if job.output_path.endswith(".mp4") else "image/png")
return FileResponse(job.output_path, media_type=media_type)
@app.get("/api/jobs/{job_id}/reference")
def get_reference_image(job_id: str, source: str) -> FileResponse:
"""One of the job's image references, for a thumbnail in the job form."""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
return reference_image_response(job.references, source)
@app.post("/api/jobs/{job_id}/last-frame")
def extract_last_frame(job_id: str) -> dict[str, str]:
"""Save the last frame of a completed job's video and return its path.
The path is meant to be attached to another job as an image reference, so
the next clip of a scene can start from where this one ended.
"""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
try:
path = last_frame_for_job(job, upload_dir)
except FrameError as e:
if e.status_code >= 500:
logger.warning("Last-frame extraction failed for job %s: %s", job_id, e.detail)
raise HTTPException(status_code=e.status_code, detail=e.detail) from e
return {"path": path, "media_type": "image"}
@app.post("/api/jobs/{job_id}/last-clip")
def extract_last_clip(job_id: str) -> dict[str, str]:
"""Save the trailing second of a completed job's video and return its path.
The path is meant to be attached to another job as a video reference, so
the next clip continues with real motion instead of guessing it from a
single still frame.
"""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
try:
path = last_clip_for_job(job, upload_dir)
except FrameError as e:
if e.status_code >= 500:
logger.warning("Last-clip extraction failed for job %s: %s", job_id, e.detail)
raise HTTPException(status_code=e.status_code, detail=e.detail) from e
return {"path": path, "media_type": "video"}
def _trim_error(e: Exception) -> HTTPException:
if isinstance(e, FrameError):
return HTTPException(status_code=e.status_code, detail=e.detail)
return HTTPException(status_code=404 if "not found" in str(e) else 400, detail=str(e))
@app.post("/api/jobs/{job_id}/trim")
def trim_job(job_id: str, req: TrimRequest) -> dict[str, Any]:
"""Cut a completed job's video to [start_seconds, end_seconds) and grade it section by section, in place.
The untouched original is kept, so this can be called again with a
different range and/or sections, or undone with POST .../restore-video.
"""
try:
segments = [s.model_dump() for s in req.segments]
job = job_runner.trim_job(job_id, req.start_seconds, req.end_seconds, segments)
except (ValueError, FrameError) as e:
raise _trim_error(e) from e
return job.to_dict()
@app.post("/api/jobs/{job_id}/restore-video")
def restore_job_video(job_id: str) -> dict[str, Any]:
"""Undo every trim on a job's video, back to what it originally generated."""
try:
job = job_runner.restore_job_video(job_id)
except (ValueError, FrameError) as e:
raise _trim_error(e) from e
return job.to_dict()
@app.post("/api/scenes/merge")
def merge_scene(req: MergeSceneRequest) -> dict[str, Any]:
"""Join the finished clips of a scene, in the order given, into one video.
The clips are copied, not re-encoded, so this takes about a second.
"""
jobs = []
for job_id in req.job_ids:
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
jobs.append(job)
try:
result = merge_jobs(jobs, job_runner.output_dir, req.name)
except MergeError as e:
if e.status_code >= 500:
logger.warning("Scene merge failed: %s", e.detail)
raise HTTPException(status_code=e.status_code, detail=e.detail) from e
logger.info("Merged %d clips into %s", result.clips, result.path)
return {
"filename": result.filename,
"url": f"/api/merged/{result.filename}",
"clips": result.clips,
"seconds": result.seconds,
}
@app.get("/api/merged/{filename}")
def get_merged_video(filename: str) -> FileResponse:
"""Stream a merged scene video."""
try:
path = merged_path(job_runner.output_dir, filename)
except MergeError as e:
raise HTTPException(status_code=e.status_code, detail=e.detail) from e
return FileResponse(path, media_type="video/mp4", filename=filename)
@app.get("/api/jobs/{job_id}/download_log")
def get_job_log_file(job_id: str) -> FileResponse:
"""Download the log file for a job."""
job = job_runner.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Job not found")
if not job.log_file_path:
raise HTTPException(status_code=404, detail="Log file not available for this job")
if not os.path.isfile(job.log_file_path):
raise HTTPException(status_code=404, detail="Log file not found on disk")
return FileResponse(job.log_file_path, media_type="text/plain", filename=f"job_{job_id}.log")
def _setup_signal_handlers() -> None:
def handle_sigquit(signum, frame):
logger.warning("Received SIGQUIT (likely from a crashed worker process). "
"Ignoring to keep server running.")
def handle_sigterm(signum, frame):
logger.info("Received SIGTERM. Shutting down gracefully...")
raise SystemExit(0)
signal.signal(signal.SIGTERM, handle_sigterm)
if hasattr(signal, "SIGQUIT"): # SIGQUIT might not be available on all platforms (e.g., Windows)
signal.signal(signal.SIGQUIT, handle_sigquit)
def create_local_env(host: str, port: int) -> None:
"""Check if .env.local exists in the fastvideo_studio directory, and create it if not."""
ui_dir = os.path.dirname(__file__)
env_local_path = os.path.join(ui_dir, ".env.local")
# Use localhost for the API URL since browsers can't connect to 0.0.0.0
api_host = "localhost" if host == "0.0.0.0" else host
api_url = f"http://{api_host}:{port}/api"
if not os.path.exists(env_local_path):
logger.info("Creating .env.local with API URL: %s", api_url)
with open(env_local_path, "w", encoding="utf-8") as f:
f.write(f"NEXT_PUBLIC_API_BASE_URL={api_url}\n")
else:
logger.debug(".env.local already exists at %s", env_local_path)
def main() -> None:
global job_runner, database, upload_dir, verbose, datasets_upload_dir # noqa: PLW0603
# Set up signal handlers to prevent worker crashes from killing the server
_setup_signal_handlers()
default_log_dir = os.path.join(os.path.dirname(__file__), "..", "outputs", "ui_logs")
default_data_dir = Path(os.path.dirname(__file__), "..", "outputs", "ui_data")
parser = argparse.ArgumentParser(description="FastVideo Job Runner API server")
parser.add_argument(
"--host",
default="0.0.0.0",
help="Bind address (default: 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=8189,
help="Port number (default: 8189)",
)
parser.add_argument(
"--output-dir",
default=DEFAULT_OUTPUT_DIR,
help=("Directory where generated videos are saved "
f"(default: {DEFAULT_OUTPUT_DIR})"),
)
parser.add_argument(
"--log-dir",
default=default_log_dir,
help=("Directory where job log files are saved "
f"(default: {default_log_dir})"),
)
parser.add_argument(
"--data-dir",
default=str(default_data_dir),
help=("Directory for SQLite database (jobs + settings persistence) "
f"(default: {default_data_dir})"),
)
parser.add_argument(
"--max-concurrent-jobs",
type=int,
default=1,
help=("How many inference jobs may run at once (default: 1). Each gets its own "
"long-lived worker that keeps its model loaded between jobs, so raising "
"this loads one copy of the model per worker."),
)
parser.add_argument(
"--verbose",
action="store_true",
help="Print full tracebacks in error messages (default: False)",
)
args = parser.parse_args()
output_dir = os.path.abspath(args.output_dir)
log_dir = os.path.abspath(args.log_dir)
data_dir = Path(args.data_dir).resolve()
upload_dir = str(data_dir / "uploads")
datasets_upload_dir = str(data_dir / "uploads" / "datasets")
use_server_data_dir(data_dir)
create_local_env(args.host, args.port)
db_path = _get_db_path(data_dir)
database = Database(db_path)
verbose = args.verbose
logger.info("Database: %s", db_path)
job_runner = JobRunner(
output_dir=output_dir,
log_dir=log_dir,
verbose=args.verbose,
database=database,
max_concurrent_jobs=args.max_concurrent_jobs,
upload_dir=upload_dir,
)
logger.info("Output directory: %s", output_dir)
logger.info("Log directory: %s", log_dir)
uvicorn.run(
app,
host=args.host,
port=args.port,
log_level="info",
)
if __name__ == "__main__":
main()