Files

793 lines
29 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.gpu import get_gpu_snapshot
from fastvideo_studio.job_runner import JobRunner, JobStatus
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, SettingsUpdate, 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.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
@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}/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(
"--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")
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,
)
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()