1318 lines
51 KiB
Python
1318 lines
51 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
In-memory mock of the FastVideo Studio API (``server.py``).
|
|
|
|
This mock implements the same ``/api`` routes and response shapes as the real
|
|
FastAPI server but keeps everything in memory and never touches the FastVideo
|
|
library or a GPU. It is meant to back the Playwright e2e suite so the Next.js
|
|
frontend can be exercised end-to-end without a real backend.
|
|
|
|
Job lifecycle is simulated by recording a start timestamp and *computing* the
|
|
status on read: a started job reports ``running`` for a few seconds and then
|
|
flips to ``completed`` with an ``output_path``, so polling the job list / logs
|
|
shows progression. Generated media is a tiny 1s ``testsrc`` MP4 built lazily
|
|
with ffmpeg and cached.
|
|
|
|
Pass ``--data-dir`` to seed jobs/datasets/settings from a real server's
|
|
persisted SQLite database instead of the built-in fake demo data, so you can
|
|
browse real job history through the mock without a GPU. This is a read-only
|
|
snapshot taken once at startup: nothing created or changed while the mock
|
|
runs is ever written back to that database, so it's safe to point at the
|
|
same ``--data-dir`` a real ``server.py`` is using.
|
|
|
|
Pass ``--state-file`` to keep the mock's own job changes across restarts:
|
|
after every job create/edit/queue/delete the mock writes the jobs it created
|
|
or changed (and the snapshot jobs it deleted) to that JSON file, and applies
|
|
it on top of the snapshot at the next startup. The SQLite database is still
|
|
never written; delete the file to reset the mock.
|
|
|
|
Usage (from the ``apps/`` directory)::
|
|
|
|
PYTHONPATH=.. python -m fastvideo_studio.mock_server --port 8190
|
|
PYTHONPATH=.. python -m fastvideo_studio.mock_server --port 8190 --data-dir ../outputs/ui_data \
|
|
--state-file ../outputs/ui_data/mock_state.json
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import copy
|
|
import json
|
|
import logging
|
|
import os
|
|
import random
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import FastAPI, File, HTTPException, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.responses import FileResponse, PlainTextResponse
|
|
|
|
from fastvideo_studio.database import Database, default_settings_dict
|
|
from fastvideo_studio.job_queue import circular_dependency, dependencies, plan_dispatch
|
|
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.trim import BRIGHTNESS_RANGE, CONTRAST_RANGE, SATURATION_RANGE
|
|
from fastvideo_studio.models import (CreateDatasetRequest, CreateJobRequest, GradeSegment, 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.mock")
|
|
|
|
# --- Config -----------------------------------------------------------------
|
|
|
|
# How long a started job stays "running" before it flips to "completed".
|
|
COMPLETE_AFTER_SECONDS = 3.0
|
|
FFMPEG_BIN = shutil.which(os.getenv("FASTVIDEO_FFMPEG_BIN", "ffmpeg"))
|
|
|
|
# Marks a job dict as a frozen row loaded from a real --data-dir snapshot
|
|
# (see _load_snapshot_from_db) rather than something this process is
|
|
# simulating. Stripped from API responses by _public_job.
|
|
_FROM_SNAPSHOT_KEY = "_from_db_snapshot"
|
|
|
|
# A small catalogue of fake models keyed by workload type. Mirrors the real
|
|
# server's {id, label} shape (label derived from the HF-style path).
|
|
_MODELS_BY_WORKLOAD: dict[str, list[str]] = {
|
|
"t2v": [
|
|
"Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
"FastVideo/FastHunyuan-diffusers",
|
|
],
|
|
"i2v": [
|
|
"Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
|
|
# Real registered id (fastvideo/registry.py) -- needed so the frontend's
|
|
# `modelId.toLowerCase().includes('minimax-h3')` gate (speech-tag
|
|
# console, references, last-frame upload) actually lights up in the mock.
|
|
"MiniMaxAI/MiniMax-H3",
|
|
],
|
|
"t2i": [
|
|
"black-forest-labs/FLUX.1-schnell",
|
|
],
|
|
}
|
|
|
|
|
|
def _models_for(workload_type: str | None) -> list[dict[str, str]]:
|
|
if workload_type:
|
|
paths = _MODELS_BY_WORKLOAD.get(workload_type, [])
|
|
else:
|
|
seen: dict[str, None] = {}
|
|
for paths_for_workload in _MODELS_BY_WORKLOAD.values():
|
|
for path in paths_for_workload:
|
|
seen.setdefault(path, None)
|
|
paths = list(seen)
|
|
return [{"id": path, "label": model_label(path)} for path in paths]
|
|
|
|
|
|
# The real settings catalogue lives in database.py; the mock only pre-fills
|
|
# the default model ids so the Create Job modal auto-selects one.
|
|
_DEFAULT_SETTINGS: dict[str, Any] = {
|
|
**default_settings_dict(),
|
|
"defaultModelId": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
"defaultModelIdT2v": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
"defaultModelIdI2v": "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers",
|
|
"defaultModelIdT2i": "black-forest-labs/FLUX.1-schnell",
|
|
}
|
|
|
|
# --- In-memory state --------------------------------------------------------
|
|
|
|
_state_lock = threading.Lock()
|
|
_settings: dict[str, Any] = dict(_DEFAULT_SETTINGS)
|
|
_jobs: dict[str, dict[str, Any]] = {}
|
|
_datasets: dict[str, dict[str, Any]] = {}
|
|
# dataset_id -> {"file_names": [...], "captions": {file_name: caption}}
|
|
_dataset_files: dict[str, dict[str, Any]] = {}
|
|
# Each snapshot job as loaded, so --state-file saves only what the mock changed.
|
|
_snapshot_originals: dict[str, dict[str, Any]] = {}
|
|
_state_file: Path | None = None
|
|
|
|
# Lazily-built, cached media clips (shared by every job/dataset media response),
|
|
# keyed by extension: "mp4" (1s testsrc video) or "png" (single testsrc frame).
|
|
_mock_media_cache: dict[str, str] = {}
|
|
_mock_media_lock = threading.Lock()
|
|
|
|
|
|
def _testsrc_frame(width: int, height: int, index: int):
|
|
"""A cheap, dependency-light stand-in for ffmpeg's `testsrc` pattern:
|
|
a gradient that shifts over `index` so an encoded clip visibly animates."""
|
|
import numpy as np
|
|
|
|
frame = np.zeros((height, width, 3), dtype=np.uint8)
|
|
frame[:, :, 0] = np.linspace(0, 255, width, dtype=np.uint8)[None, :]
|
|
frame[:, :, 1] = np.linspace(0, 255, height, dtype=np.uint8)[:, None]
|
|
frame[:, :, 2] = (index * 10) % 256
|
|
return frame
|
|
|
|
|
|
def _build_mock_media_with_pyav(kind: str, path: str) -> None:
|
|
"""ffmpeg isn't installed on every dev box; PyAV (a real FastVideo
|
|
dependency already, used for reference-video decoding) can encode the
|
|
same tiny clip directly via its bundled libav, no external binary."""
|
|
from PIL import Image
|
|
|
|
if kind == "png":
|
|
Image.fromarray(_testsrc_frame(320, 240, 0)).save(path)
|
|
return
|
|
|
|
import av as pyav
|
|
|
|
# h264 is what every browser's <video> element actually plays; mpeg4
|
|
# (MPEG-4 Part 2) is a PyAV-always-has-it fallback that no mainstream
|
|
# browser decodes, so gallery/job previews would just fail to load.
|
|
try:
|
|
pyav.codec.Codec("h264", "w")
|
|
codec_name = "h264"
|
|
except Exception:
|
|
logger.warning("PyAV has no h264 encoder available; falling back to mpeg4, which most "
|
|
"browsers cannot play in <video>. Install ffmpeg for real preview playback.")
|
|
codec_name = "mpeg4"
|
|
|
|
container = pyav.open(path, mode="w")
|
|
try:
|
|
stream = container.add_stream(codec_name, rate=24)
|
|
stream.width = 320
|
|
stream.height = 240
|
|
stream.pix_fmt = "yuv420p"
|
|
for i in range(24): # 1s at 24fps, matching the ffmpeg path's clip length
|
|
frame = pyav.VideoFrame.from_ndarray(_testsrc_frame(320, 240, i), format="rgb24")
|
|
for packet in stream.encode(frame):
|
|
container.mux(packet)
|
|
for packet in stream.encode():
|
|
container.mux(packet)
|
|
finally:
|
|
container.close()
|
|
|
|
|
|
def _build_mock_media(kind: str) -> str:
|
|
"""Build (once) a tiny testsrc-like clip of the given ``kind``; cache the path."""
|
|
with _mock_media_lock:
|
|
cached = _mock_media_cache.get(kind)
|
|
if cached and os.path.isfile(cached):
|
|
return cached
|
|
fd, path = tempfile.mkstemp(prefix="fvstudio_mock_", suffix=f".{kind}")
|
|
os.close(fd)
|
|
if not FFMPEG_BIN:
|
|
try:
|
|
_build_mock_media_with_pyav(kind, path)
|
|
except Exception as exc:
|
|
with contextlib.suppress(OSError):
|
|
os.remove(path)
|
|
raise HTTPException(status_code=500,
|
|
detail=f"PyAV fallback failed to build mock {kind}: {exc}") from exc
|
|
if not os.path.isfile(path) or os.path.getsize(path) == 0:
|
|
raise HTTPException(status_code=500, detail=f"PyAV fallback produced no mock {kind} bytes")
|
|
_mock_media_cache[kind] = path
|
|
return path
|
|
if kind == "png":
|
|
command = [
|
|
FFMPEG_BIN,
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc=size=320x240:rate=1",
|
|
"-frames:v",
|
|
"1",
|
|
"-f",
|
|
"image2",
|
|
path,
|
|
]
|
|
else:
|
|
command = [
|
|
FFMPEG_BIN,
|
|
"-hide_banner",
|
|
"-loglevel",
|
|
"error",
|
|
"-y",
|
|
"-f",
|
|
"lavfi",
|
|
"-i",
|
|
"testsrc=size=320x240:rate=24",
|
|
"-t",
|
|
"1",
|
|
"-c:v",
|
|
"libx264",
|
|
"-preset",
|
|
"ultrafast",
|
|
"-pix_fmt",
|
|
"yuv420p",
|
|
"-movflags",
|
|
"+faststart",
|
|
"-f",
|
|
"mp4",
|
|
path,
|
|
]
|
|
try:
|
|
subprocess.run(command, check=True, capture_output=True)
|
|
except (subprocess.CalledProcessError, OSError) as exc:
|
|
with contextlib.suppress(OSError):
|
|
os.remove(path)
|
|
raise HTTPException(status_code=500, detail=f"ffmpeg failed to build mock {kind}: {exc}") from exc
|
|
if not os.path.isfile(path) or os.path.getsize(path) == 0:
|
|
raise HTTPException(status_code=500, detail=f"ffmpeg produced no mock {kind} bytes")
|
|
_mock_media_cache[kind] = path
|
|
return path
|
|
|
|
|
|
# --- Job helpers ------------------------------------------------------------
|
|
|
|
|
|
def _new_job_dict(req: CreateJobRequest) -> dict[str, Any]:
|
|
"""Build a job dict (mirrors job_runner.Job.to_dict()) in the pending state.
|
|
|
|
Starts from req.model_dump() rather than re-listing CreateJobRequest's
|
|
fields by hand -- the previous version silently dropped name,
|
|
last_image_path, references, and every offload/tuning flag because it
|
|
was never updated as CreateJobRequest grew those fields. Spreading the
|
|
request means new fields flow through automatically.
|
|
"""
|
|
job_id = str(uuid.uuid4())
|
|
return {
|
|
**req.model_dump(),
|
|
"id": job_id,
|
|
"workload_type": req.workload_type or "t2v",
|
|
"job_type": req.job_type or "inference",
|
|
"image_path": req.image_path or "",
|
|
"negative_prompt": req.negative_prompt or "",
|
|
"data_path": req.data_path or "",
|
|
"status": "pending",
|
|
"created_at": time.time(),
|
|
"queued_at": None,
|
|
"started_at": None,
|
|
"finished_at": None,
|
|
"error": None,
|
|
"output_path": None,
|
|
"log_file_path": None,
|
|
"edit_start_seconds": 0.0,
|
|
"edit_end_seconds": None,
|
|
"edit_segments": [],
|
|
"progress": 0.0,
|
|
"progress_msg": "",
|
|
"phase": "pending",
|
|
}
|
|
|
|
|
|
def _advance_job(job: dict[str, Any]) -> None:
|
|
"""Flip a running job to completed once enough wall-clock time has passed.
|
|
|
|
Status is *computed on read* from the recorded start timestamp, so polling
|
|
the job list / logs naturally shows pending -> running -> completed.
|
|
|
|
Jobs loaded from a persisted DB snapshot (``_FROM_SNAPSHOT_KEY``) are a
|
|
frozen historical record, not something this process is simulating — skip
|
|
them so a real "pending" or "running" row doesn't silently start ticking
|
|
toward a fake "completed" just because someone polled it.
|
|
"""
|
|
if job.get(_FROM_SNAPSHOT_KEY) or job["status"] != "running" or not job.get("started_at"):
|
|
return
|
|
elapsed = time.time() - job["started_at"]
|
|
if elapsed >= COMPLETE_AFTER_SECONDS:
|
|
job["status"] = "completed"
|
|
job["finished_at"] = job["started_at"] + COMPLETE_AFTER_SECONDS
|
|
job["progress"] = 100.0
|
|
job["progress_msg"] = "50/50 steps"
|
|
job["phase"] = "done"
|
|
ext = "png" if job.get("workload_type") == "t2i" else "mp4"
|
|
job["output_path"] = f"/mock/outputs/{job['id']}/output.{ext}"
|
|
|
|
|
|
def _public_job(job: dict[str, Any]) -> dict[str, Any]:
|
|
"""Advance + return a copy safe to serialize (internal-only keys stripped)."""
|
|
_advance_job(job)
|
|
return {k: v for k, v in job.items() if not k.startswith("_")}
|
|
|
|
|
|
_LOG_TAIL = [
|
|
"Loading model...",
|
|
"Model loaded.",
|
|
"Starting generation...",
|
|
"Denoising step 10/50",
|
|
"Denoising step 20/50",
|
|
"Denoising step 30/50",
|
|
"Denoising step 40/50",
|
|
"Denoising step 50/50",
|
|
"Generation complete. Saving output...",
|
|
"Saved output file.",
|
|
"Job completed successfully.",
|
|
]
|
|
|
|
|
|
def _log_sequence(job: dict[str, Any]) -> list[str]:
|
|
return [
|
|
f"Job {job['id']} started",
|
|
f"Model: {job['model_id']}",
|
|
f"Prompt: {job['prompt']}",
|
|
*_LOG_TAIL,
|
|
]
|
|
|
|
|
|
def _compute_logs(job: dict[str, Any]) -> dict[str, Any]:
|
|
"""Return JobLogs-shaped data, growing the visible lines as time passes."""
|
|
seq = _log_sequence(job)
|
|
status = job["status"]
|
|
if status in ("pending", "queued"):
|
|
return {"lines": [], "progress": 0.0, "progress_msg": "", "phase": status}
|
|
if status == "completed":
|
|
return {"lines": seq, "progress": 100.0, "progress_msg": "50/50 steps", "phase": "done"}
|
|
if status in ("failed", "stopped"):
|
|
# Keep the total line count monotonic across running -> terminal (the
|
|
# `after` cursor relies on it): show every non-final line plus a terminal
|
|
# notice, which is always >= any prefix a running job revealed.
|
|
lines = seq[:-1] + [f"Job {status}."]
|
|
return {"lines": lines, "progress": job.get("progress", 0.0), "progress_msg": "", "phase": status}
|
|
# running: reveal a prefix proportional to elapsed time, but never the final
|
|
# "completed" line — that appears only once the job is actually completed.
|
|
elapsed = time.time() - (job.get("started_at") or time.time())
|
|
frac = max(0.0, min(elapsed / COMPLETE_AFTER_SECONDS, 0.99))
|
|
reveal = min(max(4, round(frac * len(seq))), len(seq) - 1)
|
|
lines = seq[:reveal]
|
|
if frac < 0.2:
|
|
phase = "loading model"
|
|
elif frac < 0.85:
|
|
phase = "denoising"
|
|
else:
|
|
phase = "saving"
|
|
return {
|
|
"lines": lines,
|
|
"progress": round(frac * 100.0, 1),
|
|
"progress_msg": f"{int(frac * 50)}/50 steps",
|
|
"phase": phase,
|
|
}
|
|
|
|
|
|
# --- Dataset helpers --------------------------------------------------------
|
|
|
|
|
|
def _dataset_stats(dataset_id: str) -> tuple[int, int]:
|
|
files = _dataset_files.get(dataset_id, {}).get("file_names", [])
|
|
count = len(files)
|
|
# Fake but stable per-file size so the UI shows a non-zero footprint.
|
|
return count, count * 1_048_576
|
|
|
|
|
|
def _public_dataset(dataset: dict[str, Any]) -> dict[str, Any]:
|
|
count, size = _dataset_stats(dataset["id"])
|
|
return {**dataset, "file_count": count, "size_bytes": size}
|
|
|
|
|
|
def _seed() -> None:
|
|
"""Seed a couple of datasets and one completed inference job."""
|
|
now = time.time()
|
|
seeds = [
|
|
("Sunset Clips", ["sunset_01.mp4", "sunset_02.mp4"], {
|
|
"sunset_01.mp4": "A sunset over the ocean",
|
|
"sunset_02.mp4": "A sunset over the mountains"
|
|
}),
|
|
("City Timelapse", ["city_01.mp4"], {
|
|
"city_01.mp4": "A busy city intersection at night"
|
|
}),
|
|
]
|
|
for idx, (name, file_names, captions) in enumerate(seeds):
|
|
dataset_id = str(uuid.uuid4())
|
|
_datasets[dataset_id] = {"id": dataset_id, "name": name, "created_at": now - 600 + idx}
|
|
_dataset_files[dataset_id] = {"file_names": list(file_names), "captions": dict(captions)}
|
|
|
|
job_id = str(uuid.uuid4())
|
|
_jobs[job_id] = {
|
|
"id": job_id,
|
|
"model_id": "Wan-AI/Wan2.1-T2V-1.3B-Diffusers",
|
|
"prompt": "A curious raccoon peers through a field of yellow sunflowers",
|
|
"workload_type": "t2v",
|
|
"job_type": "inference",
|
|
"image_path": "",
|
|
"status": "completed",
|
|
"created_at": now - 300,
|
|
"started_at": now - 280,
|
|
"finished_at": now - 250,
|
|
"error": None,
|
|
"output_path": f"/mock/outputs/{job_id}/output.mp4",
|
|
"log_file_path": f"/mock/logs/{job_id}.log",
|
|
"num_inference_steps": 50,
|
|
"num_frames": 81,
|
|
"height": 480,
|
|
"width": 832,
|
|
"guidance_scale": 5.0,
|
|
"guidance_rescale": 0.0,
|
|
"fps": 24,
|
|
"seed": 1024,
|
|
"negative_prompt": "",
|
|
"num_gpus": 1,
|
|
"data_path": "",
|
|
"progress": 100.0,
|
|
"progress_msg": "50/50 steps",
|
|
"phase": "done",
|
|
}
|
|
|
|
|
|
# --- DB snapshot (--data-dir) ------------------------------------------------
|
|
|
|
|
|
def _decode_json_list(value: Any) -> list[dict[str, Any]]:
|
|
"""References and edit_segments both round-trip through the DB as JSON text."""
|
|
if not value:
|
|
return []
|
|
if isinstance(value, list):
|
|
return list(value)
|
|
try:
|
|
decoded = json.loads(value)
|
|
except (TypeError, ValueError):
|
|
return []
|
|
return list(decoded) if isinstance(decoded, list) else []
|
|
|
|
|
|
def _job_from_snapshot(row: dict[str, Any]) -> dict[str, Any]:
|
|
"""Adapt a persisted job row into the mock's job dict shape.
|
|
|
|
Frozen as a read-only historical record (see _FROM_SNAPSHOT_KEY): the mock
|
|
never actually ran this job, so it doesn't get to simulate progress for
|
|
one. A row still marked "running" is orphaned -- the real JobRunner
|
|
reclassifies those as failed on its own restart, so mirror that here.
|
|
"""
|
|
job = dict(row)
|
|
status = job["status"]
|
|
if status == "running":
|
|
status = "failed"
|
|
job["error"] = job.get("error") or "Server restarted (job was running)"
|
|
job["finished_at"] = job.get("finished_at") or time.time()
|
|
elif status == "queued":
|
|
# The real server resumes its queue on restart; the mock never runs
|
|
# snapshot jobs, so leave it as something the user can queue again.
|
|
status = "pending"
|
|
job["queued_at"] = None
|
|
job["status"] = status
|
|
job["references"] = _decode_json_list(row.get("references"))
|
|
job["edit_segments"] = _decode_json_list(row.get("edit_segments"))
|
|
job["progress"] = 100.0 if status == "completed" else 0.0
|
|
job["progress_msg"] = "50/50 steps" if status == "completed" else ""
|
|
job["phase"] = "done" if status == "completed" else status
|
|
job[_FROM_SNAPSHOT_KEY] = True
|
|
return job
|
|
|
|
|
|
def _load_snapshot_from_db(data_dir: Path) -> bool:
|
|
"""Seed jobs/datasets/settings from a real server's persisted SQLite db.
|
|
|
|
Read-only: opens the db (Database.__init__ runs the same idempotent
|
|
schema migrations a real server startup would) and copies rows into the
|
|
mock's in-memory state; nothing the mock does afterward is written back.
|
|
Returns False (leaving the caller to fall back to _seed()) if no db file
|
|
is found at that path, rather than silently creating an empty one.
|
|
"""
|
|
db_path = data_dir / "fastvideo_ui.db"
|
|
if not db_path.is_file():
|
|
logger.warning("--data-dir %s has no fastvideo_ui.db; falling back to fake demo data.", data_dir)
|
|
return False
|
|
|
|
db = Database(db_path)
|
|
settings = db.get_settings()
|
|
jobs = [_job_from_snapshot(row) for row in db.get_all_jobs()]
|
|
datasets = db.get_all_datasets()
|
|
|
|
with _state_lock:
|
|
_settings.update({k: v for k, v in settings.items() if v is not None})
|
|
_jobs.clear()
|
|
_jobs.update({j["id"]: j for j in jobs})
|
|
_snapshot_originals.clear()
|
|
_snapshot_originals.update({j["id"]: copy.deepcopy(j) for j in jobs})
|
|
_datasets.clear()
|
|
_datasets.update({d["id"]: d for d in datasets})
|
|
_dataset_files.clear()
|
|
for d in datasets:
|
|
captions = db.get_dataset_captions(d["id"])
|
|
_dataset_files[d["id"]] = {"file_names": list(captions.keys()), "captions": captions}
|
|
|
|
logger.info(
|
|
"Loaded read-only snapshot from %s: %d job(s), %d dataset(s).",
|
|
db_path,
|
|
len(jobs),
|
|
len(datasets),
|
|
)
|
|
return True
|
|
|
|
|
|
# --- Saved mock state (--state-file) -----------------------------------------
|
|
|
|
|
|
def _save_state() -> None:
|
|
"""Write the jobs the mock created or changed to _state_file. Lock held.
|
|
|
|
Snapshot jobs left as loaded are not saved, so later changes to the real
|
|
database still show through; deleted snapshot jobs are listed by id.
|
|
"""
|
|
if _state_file is None:
|
|
return
|
|
jobs = [j for jid, j in _jobs.items() if _snapshot_originals.get(jid) != j]
|
|
deleted = [jid for jid in _snapshot_originals if jid not in _jobs]
|
|
tmp = _state_file.with_name(_state_file.name + ".tmp")
|
|
try:
|
|
tmp.write_text(json.dumps({"jobs": jobs, "deleted_job_ids": deleted}, indent=1))
|
|
os.replace(tmp, _state_file)
|
|
except (OSError, TypeError, ValueError):
|
|
logger.exception("Could not save mock state to %s", _state_file)
|
|
|
|
|
|
def _load_state(path: Path) -> None:
|
|
"""Apply a saved --state-file on top of whatever was seeded or snapshotted."""
|
|
if not path.is_file():
|
|
logger.info("No mock state at %s yet; it will be created on the first job change.", path)
|
|
return
|
|
try:
|
|
state = json.loads(path.read_text())
|
|
except (OSError, ValueError):
|
|
logger.exception("Could not read mock state from %s; starting without it.", path)
|
|
return
|
|
with _state_lock:
|
|
for job_id in state.get("deleted_job_ids", []):
|
|
_jobs.pop(job_id, None)
|
|
for job in state.get("jobs", []):
|
|
_jobs[job["id"]] = job
|
|
logger.info("Loaded %d saved job(s) from %s.", len(state.get("jobs", [])), path)
|
|
|
|
|
|
# --- App --------------------------------------------------------------------
|
|
|
|
app = FastAPI(title="FastVideo Studio Mock API", version="0.1.0")
|
|
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origins=["*"],
|
|
allow_methods=["*"],
|
|
allow_headers=["*"],
|
|
)
|
|
# The real scene writer: drafting runs the agent against a real model, but
|
|
# writes only job files and creates nothing, so it is safe beside mock jobs.
|
|
app.include_router(scene_writer_router)
|
|
|
|
|
|
@app.middleware("http")
|
|
async def save_state_after_job_changes(request, call_next):
|
|
response = await call_next(request)
|
|
if _state_file is not None and request.method != "GET" and request.url.path.startswith("/api/jobs"):
|
|
with _state_lock:
|
|
_save_state()
|
|
return response
|
|
|
|
|
|
@app.get("/api/__mock__")
|
|
def mock_sentinel() -> dict[str, bool]:
|
|
"""Mock-only marker so the e2e suite can prove it isn't hitting a real
|
|
backend before running its mutating specs (the real server has no such
|
|
route)."""
|
|
return {"mock": True}
|
|
|
|
|
|
# --- Settings ---------------------------------------------------------------
|
|
|
|
|
|
@app.get("/api/settings")
|
|
def get_settings() -> dict[str, Any]:
|
|
with _state_lock:
|
|
return dict(_settings)
|
|
|
|
|
|
@app.put("/api/settings")
|
|
def update_settings(settings: SettingsUpdate) -> dict[str, Any]:
|
|
updates = settings.model_dump(exclude_unset=True)
|
|
with _state_lock:
|
|
_settings.update({k: v for k, v in updates.items() if v is not None})
|
|
return dict(_settings)
|
|
|
|
|
|
# --- Models -----------------------------------------------------------------
|
|
|
|
|
|
@app.get("/api/models")
|
|
def list_models(workload_type: str | None = None) -> list[dict[str, Any]]:
|
|
return _models_for(workload_type)
|
|
|
|
|
|
# --- GPUs -------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/api/gpus")
|
|
def list_gpus() -> dict[str, Any]:
|
|
"""Two fake GPUs with slight per-request jitter so the page looks live."""
|
|
gpus = []
|
|
for index, (base_util, used_mib) in enumerate([(62, 61_440), (7, 4_096)]):
|
|
gpus.append({
|
|
"index": index,
|
|
"name": "NVIDIA Mock GPU 80GB",
|
|
"utilization": max(0, min(100, base_util + random.randint(-5, 5))),
|
|
"memory_used_mib": used_mib + random.randint(-256, 256),
|
|
"memory_total_mib": 81_920,
|
|
"temperature_c": 55 + random.randint(-3, 3),
|
|
"power_watts": 310.0 + random.randint(-20, 20),
|
|
"power_limit_watts": 700.0,
|
|
})
|
|
return {"available": True, "gpus": gpus, "error": None}
|
|
|
|
|
|
# --- Uploads ----------------------------------------------------------------
|
|
|
|
|
|
@app.post("/api/upload-image")
|
|
async def upload_image(file: Annotated[UploadFile, File()]) -> dict[str, str]:
|
|
name = file.filename or "image.png"
|
|
return {"path": f"/mock/uploads/{uuid.uuid4().hex}_{os.path.basename(name)}"}
|
|
|
|
|
|
ALLOWED_REFERENCE_VIDEO_EXTENSIONS = {".mp4", ".mov", ".mkv", ".webm", ".avi"}
|
|
ALLOWED_REFERENCE_AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".m4a", ".ogg"}
|
|
ALLOWED_REFERENCE_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
|
|
|
|
|
@app.post("/api/upload-media")
|
|
async def upload_media(file: Annotated[UploadFile, File()]) -> dict[str, str]:
|
|
"""Ref2VA reference upload (image/video/audio) -- the real server's
|
|
counterpart to upload_image, which only ever handles images."""
|
|
name = file.filename or "reference"
|
|
ext = os.path.splitext(name)[1].lower()
|
|
allowed = ALLOWED_REFERENCE_IMAGE_EXTENSIONS | ALLOWED_REFERENCE_VIDEO_EXTENSIONS | ALLOWED_REFERENCE_AUDIO_EXTENSIONS
|
|
if ext not in allowed:
|
|
raise HTTPException(status_code=400, detail=f"Invalid file type. Allowed: {', '.join(sorted(allowed))}")
|
|
if ext in ALLOWED_REFERENCE_VIDEO_EXTENSIONS:
|
|
media_type = "video"
|
|
elif ext in ALLOWED_REFERENCE_AUDIO_EXTENSIONS:
|
|
media_type = "audio"
|
|
else:
|
|
media_type = "image"
|
|
return {
|
|
"path": f"/mock/uploads/{uuid.uuid4().hex}_{os.path.basename(name)}",
|
|
"media_type": media_type,
|
|
}
|
|
|
|
|
|
@app.post("/api/upload-raw-dataset")
|
|
async def upload_raw_dataset(files: Annotated[list[UploadFile], File()]) -> dict[str, Any]:
|
|
video_exts = {".mp4", ".webm", ".avi", ".mov", ".mkv"}
|
|
file_names: list[str] = []
|
|
for uf in files:
|
|
name = os.path.basename(uf.filename or f"{uuid.uuid4().hex}.mp4")
|
|
if os.path.splitext(name)[1].lower() in video_exts:
|
|
file_names.append(name)
|
|
if not file_names:
|
|
raise HTTPException(status_code=400, detail="No video files found.")
|
|
upload_id = uuid.uuid4().hex
|
|
path = f"/mock/uploads/{upload_id}"
|
|
return {"path": path, "upload_id": upload_id, "file_names": file_names}
|
|
|
|
|
|
# --- Jobs -------------------------------------------------------------------
|
|
|
|
|
|
@app.get("/api/jobs")
|
|
def list_jobs(job_type: str | None = None) -> list[dict[str, Any]]:
|
|
# _public_job mutates the shared job dict via _advance_job, so it must run
|
|
# under the lock (matching every other job route) to avoid racing start/stop.
|
|
with _state_lock:
|
|
_drain_queue()
|
|
jobs = list(_jobs.values())
|
|
if job_type:
|
|
jobs = [j for j in jobs if j.get("job_type") == job_type]
|
|
jobs.sort(key=lambda j: j["created_at"], reverse=True)
|
|
return [_public_job(j) for j in jobs]
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}")
|
|
def get_job(job_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
return _public_job(job)
|
|
|
|
|
|
@app.post("/api/jobs", status_code=201)
|
|
def create_job(req: CreateJobRequest) -> dict[str, Any]:
|
|
job = _new_job_dict(req)
|
|
with _state_lock:
|
|
_drain_queue()
|
|
_jobs[job["id"]] = job
|
|
if _settings.get("autoStartJob"):
|
|
_start(job)
|
|
return _public_job(job)
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/duplicate", status_code=201)
|
|
def duplicate_job(job_id: str) -> dict[str, Any]:
|
|
"""A new pending job with an existing job's configuration, like the real server's."""
|
|
with _state_lock:
|
|
source = _jobs.get(job_id)
|
|
if source is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
config = {k: copy.deepcopy(source[k]) for k in CreateJobRequest.model_fields if k in source}
|
|
return create_job(CreateJobRequest(**config))
|
|
|
|
|
|
def _start(job: dict[str, Any], at: float | None = None) -> None:
|
|
# Once the mock is actually driving this job, it's no longer a frozen
|
|
# historical record -- let _advance_job simulate it like any other.
|
|
job.pop(_FROM_SNAPSHOT_KEY, None)
|
|
job["status"] = "running"
|
|
job["queued_at"] = None
|
|
job["started_at"] = at if at is not None else time.time()
|
|
job["finished_at"] = None
|
|
job["error"] = None
|
|
job["output_path"] = None
|
|
# The real server assigns the log path once the job starts running; mirror
|
|
# that so the Job Details "Download Log" button (gated on log_file_path) works.
|
|
job["log_file_path"] = f"/mock/logs/{job['id']}.log"
|
|
job["progress"] = 0.0
|
|
job["progress_msg"] = ""
|
|
job["phase"] = "starting"
|
|
|
|
|
|
class _QueueView:
|
|
"""The scheduler's view of a mock job dict (it reads attributes, not keys)."""
|
|
|
|
def __init__(self, job: dict[str, Any]):
|
|
self.id = job["id"]
|
|
self.name = job.get("name", "")
|
|
self.status = job["status"]
|
|
self.references = job.get("references")
|
|
self.queued_at = job.get("queued_at")
|
|
|
|
|
|
def _drain_queue() -> None:
|
|
"""Run the queue up to now. Call with ``_state_lock`` held.
|
|
|
|
Like everything else in the mock this is computed on read: each pass
|
|
finishes jobs whose time is up, then starts whatever the queue allows.
|
|
A job takes over the slot at the moment the previous job *finished* (not at
|
|
the moment someone happened to poll), so a queue that has been sitting for
|
|
a minute shows every clip already done rather than advancing one per poll.
|
|
Same rules as the real runner: one at a time, oldest first, and a clip that
|
|
takes another's last frame waits for it -- see job_queue.plan_dispatch.
|
|
"""
|
|
while True:
|
|
for job in _jobs.values():
|
|
_advance_job(job)
|
|
plan = plan_dispatch({jid: _QueueView(j) for jid, j in _jobs.items()}, max_concurrent=1)
|
|
now = time.time()
|
|
for job_id, reason in plan.fail.items():
|
|
job = _jobs[job_id]
|
|
job["status"] = "failed"
|
|
job["error"] = reason
|
|
job["finished_at"] = now
|
|
job["queued_at"] = None
|
|
job["phase"] = "failed"
|
|
if not plan.start:
|
|
if plan.fail:
|
|
continue # a failure can free a slot or fail more jobs
|
|
return
|
|
for job_id in plan.start:
|
|
job = _jobs[job_id]
|
|
finished = [j["finished_at"] for j in _jobs.values() if j.get("finished_at")]
|
|
# The slot came free when the last job finished, if that was after the job was queued.
|
|
slot_free = max([job.get("queued_at") or 0.0, *[f for f in finished if f <= now]])
|
|
_start(job, at=min(now, slot_free))
|
|
|
|
|
|
def _check_dependencies_finished(job: dict[str, Any]) -> None:
|
|
for dep_id in dependencies(job.get("references")):
|
|
dep = _jobs.get(dep_id)
|
|
if dep is None:
|
|
raise HTTPException(status_code=409,
|
|
detail="This job starts from the ending of a job that no longer exists.")
|
|
if dep["status"] != "completed":
|
|
label = f"'{dep['name']}'" if dep.get("name") else dep["id"]
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail=(f"This job starts from the ending of {label}, which hasn't finished "
|
|
f"(it is {dep['status']}). Queue it to run after that job."),
|
|
)
|
|
|
|
|
|
# Mirrors JobRunner.EDITABLE_STATUSES: exactly the jobs that can still be started.
|
|
_EDITABLE_STATUSES = ("pending", "failed", "stopped")
|
|
|
|
|
|
@app.patch("/api/jobs/{job_id}")
|
|
def update_job(job_id: str, updates: dict[str, Any]) -> dict[str, Any]:
|
|
"""Edit a job's configuration; only jobs that have not produced a result."""
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
_advance_job(job)
|
|
if job["status"] not in _EDITABLE_STATUSES:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=(f"Job is {job['status']}; only {', '.join(_EDITABLE_STATUSES)} jobs can be edited. "
|
|
"Duplicate it instead."),
|
|
)
|
|
unknown = set(updates) - set(CreateJobRequest.model_fields)
|
|
if unknown:
|
|
raise HTTPException(status_code=400, detail=f"Not editable: {', '.join(sorted(unknown))}")
|
|
job.update(updates)
|
|
return _public_job(job)
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/last-frame")
|
|
def extract_last_frame(job_id: str) -> dict[str, str]:
|
|
"""Stand-in for the real route: there is no video to read, so hand back a made-up path."""
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
_advance_job(job)
|
|
if job["status"] != "completed" or not job.get("output_path"):
|
|
raise HTTPException(status_code=404, detail="No output available for this job")
|
|
return {"path": f"/mock/last_frames/last_frame_{job_id}.png", "media_type": "image"}
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/last-clip")
|
|
def extract_last_clip(job_id: str) -> dict[str, str]:
|
|
"""Stand-in for the real route: there is no video to read, so hand back a made-up path."""
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
_advance_job(job)
|
|
if job["status"] != "completed" or not job.get("output_path"):
|
|
raise HTTPException(status_code=404, detail="No output available for this job")
|
|
return {"path": f"/mock/last_clips/last_clip_{job_id}.mp4", "media_type": "video"}
|
|
|
|
|
|
#: Key holding a trimmed job's frame count before any trim, so it can be restored.
|
|
_ORIGINAL_NUM_FRAMES_KEY = "_original_num_frames"
|
|
|
|
|
|
def _trimmable_job(job_id: str) -> dict[str, Any]:
|
|
"""Look up a completed job with an output, or raise. Call with _state_lock held."""
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
_advance_job(job)
|
|
if job["status"] != "completed" or not job.get("output_path"):
|
|
raise HTTPException(status_code=404, detail="No output available for this job")
|
|
return job
|
|
|
|
|
|
def _check_grade_range(name: str, value: float, bounds: tuple[float, float]) -> None:
|
|
low, high = bounds
|
|
if not (low <= value <= high):
|
|
raise HTTPException(status_code=400, detail=f"{name} must be between {low:g} and {high:g}, got {value:g}.")
|
|
|
|
|
|
def _check_segments(segments: list[GradeSegment]) -> None:
|
|
"""Structural checks only (ordering, open-endedness, color bounds) -- the mock has no
|
|
frames to count, so it doesn't reproduce trim.py's frame-rounding/coverage checks.
|
|
|
|
An empty list isn't an error here either, matching trim.py: it defaults to
|
|
one neutral section covering everything (Pydantic's own default_factory
|
|
already supplies that when the field is omitted, but an explicit "segments":
|
|
[] reaches here as a real empty list).
|
|
"""
|
|
prev_end = 0.0
|
|
for i, s in enumerate(segments):
|
|
_check_grade_range("brightness", s.brightness, BRIGHTNESS_RANGE)
|
|
_check_grade_range("contrast", s.contrast, CONTRAST_RANGE)
|
|
_check_grade_range("saturation", s.saturation, SATURATION_RANGE)
|
|
if s.end_seconds is None:
|
|
if i != len(segments) - 1:
|
|
raise HTTPException(status_code=400, detail="Only the last section can be open-ended.")
|
|
else:
|
|
if s.end_seconds <= prev_end:
|
|
raise HTTPException(status_code=400, detail="Sections must be in increasing order.")
|
|
prev_end = s.end_seconds
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/trim")
|
|
def trim_job(job_id: str, req: TrimRequest) -> dict[str, Any]:
|
|
"""Stand-in for the real route: no video to cut or grade, so just resize num_frames to match."""
|
|
with _state_lock:
|
|
job = _trimmable_job(job_id)
|
|
if req.start_seconds < 0:
|
|
raise HTTPException(status_code=400, detail="The start of the range can't be negative.")
|
|
_check_segments(req.segments)
|
|
fps = job.get("fps") or 24
|
|
original_frames = job.setdefault(_ORIGINAL_NUM_FRAMES_KEY, job["num_frames"])
|
|
duration = original_frames / fps
|
|
end = duration if req.end_seconds is None else min(req.end_seconds, duration)
|
|
if end <= req.start_seconds:
|
|
raise HTTPException(status_code=400, detail="The end of the range must come after the start.")
|
|
job["num_frames"] = max(1, round((end - req.start_seconds) * fps))
|
|
# Recorded so the dialog can reopen showing what's actually applied,
|
|
# like the real server's edit_* job fields.
|
|
job["edit_start_seconds"] = req.start_seconds
|
|
job["edit_end_seconds"] = req.end_seconds
|
|
job["edit_segments"] = [s.model_dump() for s in req.segments]
|
|
return _public_job(job)
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/restore-video")
|
|
def restore_job_video(job_id: str) -> dict[str, Any]:
|
|
"""Stand-in for the real route: undo the simulated edit by restoring num_frames."""
|
|
with _state_lock:
|
|
job = _trimmable_job(job_id)
|
|
if _ORIGINAL_NUM_FRAMES_KEY not in job:
|
|
raise HTTPException(status_code=404, detail="This job has not been edited.")
|
|
job["num_frames"] = job.pop(_ORIGINAL_NUM_FRAMES_KEY)
|
|
job["edit_start_seconds"] = 0.0
|
|
job["edit_end_seconds"] = None
|
|
job["edit_segments"] = []
|
|
return _public_job(job)
|
|
|
|
|
|
def _check_startable(job: dict[str, Any]) -> None:
|
|
if job["status"] == "running":
|
|
raise HTTPException(status_code=409, detail="Job is already running")
|
|
if job["status"] == "queued":
|
|
raise HTTPException(status_code=409, detail="Job is already queued")
|
|
if job["status"] == "completed":
|
|
raise HTTPException(status_code=409, detail="Job already completed. Delete and re-create to run again.")
|
|
|
|
|
|
def _enqueue(job_ids: list[str]) -> list[dict[str, Any]]:
|
|
"""Queue jobs in order, all-or-nothing (mirrors JobRunner.enqueue_jobs). Lock held."""
|
|
if len(set(job_ids)) != len(job_ids):
|
|
raise HTTPException(status_code=409, detail="A job was listed more than once.")
|
|
jobs = []
|
|
for job_id in job_ids:
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
_check_startable(job)
|
|
jobs.append(job)
|
|
views = {jid: _QueueView(j) for jid, j in _jobs.items()}
|
|
for job in jobs:
|
|
loop = circular_dependency(views, job["id"])
|
|
if loop:
|
|
raise HTTPException(
|
|
status_code=409,
|
|
detail="Jobs would wait on each other's last frames in a loop: " + " -> ".join(loop),
|
|
)
|
|
for job in jobs:
|
|
job.pop(_FROM_SNAPSHOT_KEY, None)
|
|
job.update(status="queued",
|
|
queued_at=time.time(),
|
|
error=None,
|
|
output_path=None,
|
|
log_file_path=None,
|
|
started_at=None,
|
|
finished_at=None,
|
|
progress=0.0,
|
|
progress_msg="",
|
|
phase="queued")
|
|
# Strictly increasing, so jobs queued together keep their order.
|
|
others = [j["queued_at"] for j in _jobs.values() if j.get("queued_at") and j is not job]
|
|
job["queued_at"] = max([job["queued_at"], *[o + 1e-6 for o in others]])
|
|
_drain_queue()
|
|
return [_public_job(j) for j in jobs]
|
|
|
|
|
|
@app.post("/api/jobs/queue")
|
|
def queue_jobs(req: QueueJobsRequest) -> list[dict[str, Any]]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
return _enqueue(req.job_ids)
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/queue")
|
|
def queue_job(job_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
return _enqueue([job_id])[0]
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/dequeue")
|
|
def dequeue_job(job_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
if job["status"] != "queued":
|
|
raise HTTPException(status_code=409, detail=f"Job is not queued (status={job['status']})")
|
|
job["status"] = "pending"
|
|
job["queued_at"] = None
|
|
_drain_queue()
|
|
return _public_job(job)
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/start")
|
|
def start_job(job_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
_advance_job(job)
|
|
_check_startable(job)
|
|
_check_dependencies_finished(job)
|
|
_start(job)
|
|
return _public_job(job)
|
|
|
|
|
|
@app.post("/api/jobs/{job_id}/stop")
|
|
def stop_job(job_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
_advance_job(job)
|
|
if job["status"] == "queued":
|
|
job["status"] = "pending"
|
|
job["queued_at"] = None
|
|
_drain_queue()
|
|
return _public_job(job)
|
|
if job["status"] != "running":
|
|
raise HTTPException(status_code=409, detail=f"Job is not running (status={job['status']})")
|
|
job["status"] = "stopped"
|
|
job["finished_at"] = time.time()
|
|
job["phase"] = "stopped"
|
|
return dict(job)
|
|
|
|
|
|
@app.delete("/api/jobs/{job_id}")
|
|
def delete_job(job_id: str) -> dict[str, str]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
if _jobs.pop(job_id, None) is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
_drain_queue() # clips waiting on this one's last frame can no longer run
|
|
return {"detail": f"Job {job_id} deleted"}
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}/logs")
|
|
def get_job_logs(job_id: str, after: int = 0) -> dict[str, Any]:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
_advance_job(job)
|
|
result = _compute_logs(job)
|
|
all_lines = result["lines"]
|
|
return {
|
|
"lines": all_lines[after:],
|
|
"total": len(all_lines),
|
|
"progress": result["progress"],
|
|
"progress_msg": result["progress_msg"],
|
|
"phase": result["phase"],
|
|
}
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}/video")
|
|
def get_video(job_id: str) -> FileResponse:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
_advance_job(job)
|
|
output_path = job.get("output_path")
|
|
if job["status"] != "completed" or not output_path:
|
|
raise HTTPException(status_code=404, detail="No output available for this job")
|
|
# A job loaded from a --data-dir snapshot may point at a real file this
|
|
# process never wrote, only reads -- serve it if it's actually there,
|
|
# otherwise fall back to the same synthetic clip every other job gets.
|
|
if os.path.isfile(output_path):
|
|
media_type = "video/mp4" if output_path.endswith(".mp4") else "image/png"
|
|
return FileResponse(output_path, media_type=media_type, filename=os.path.basename(output_path))
|
|
# Mirror the real server: image workloads (t2i) output a .png served as an
|
|
# image; everything else is a video.
|
|
if output_path.endswith(".png"):
|
|
return FileResponse(_build_mock_media("png"), media_type="image/png", filename=f"job_{job_id}.png")
|
|
return FileResponse(_build_mock_media("mp4"), media_type="video/mp4", filename=f"job_{job_id}.mp4")
|
|
|
|
|
|
@app.get("/api/jobs/{job_id}/reference")
|
|
def get_reference_image(job_id: str, source: str) -> FileResponse:
|
|
with _state_lock:
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
references = list(job.get("references") or [])
|
|
return reference_image_response(references, source)
|
|
|
|
|
|
_MERGED_DIR = tempfile.mkdtemp(prefix="fvstudio_merged_")
|
|
|
|
|
|
@app.post("/api/scenes/merge")
|
|
def merge_scene(req: MergeSceneRequest) -> dict[str, Any]:
|
|
"""Real merge (same code as the real server) of whatever videos the jobs have.
|
|
|
|
Jobs whose video only exists in the mock get the stand-in clip, so the
|
|
flow can be tried end to end without generating anything.
|
|
"""
|
|
with _state_lock:
|
|
_drain_queue()
|
|
picked = []
|
|
for job_id in req.job_ids:
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
|
|
picked.append(dict(job))
|
|
all_real = all(j.get("output_path") and os.path.isfile(j["output_path"]) for j in picked)
|
|
stand_in = None if all_real else _build_mock_media("mp4")
|
|
jobs = [
|
|
SimpleNamespace(
|
|
id=j["id"],
|
|
name=j.get("name", ""),
|
|
status=j["status"],
|
|
output_path=(j["output_path"] if all_real else (stand_in if j["status"] == "completed" else None)),
|
|
) for j in picked
|
|
]
|
|
try:
|
|
result = merge_jobs(jobs, _MERGED_DIR, req.name)
|
|
except MergeError as e:
|
|
raise HTTPException(status_code=e.status_code, detail=e.detail) from e
|
|
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:
|
|
try:
|
|
path = merged_path(_MERGED_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 download_log(job_id: str) -> PlainTextResponse:
|
|
with _state_lock:
|
|
_drain_queue()
|
|
job = _jobs.get(job_id)
|
|
if job is None:
|
|
raise HTTPException(status_code=404, detail="Job not found")
|
|
_advance_job(job)
|
|
lines = _compute_logs(job)["lines"]
|
|
return PlainTextResponse("\n".join(lines) + "\n", media_type="text/plain")
|
|
|
|
|
|
# --- Datasets ---------------------------------------------------------------
|
|
|
|
|
|
@app.get("/api/datasets")
|
|
def list_datasets() -> list[dict[str, Any]]:
|
|
with _state_lock:
|
|
datasets = sorted(_datasets.values(), key=lambda d: d["created_at"], reverse=True)
|
|
return [_public_dataset(d) for d in datasets]
|
|
|
|
|
|
@app.get("/api/datasets/{dataset_id}")
|
|
def get_dataset(dataset_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
dataset = _datasets.get(dataset_id)
|
|
if dataset is None:
|
|
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
return _public_dataset(dataset)
|
|
|
|
|
|
@app.post("/api/datasets", status_code=201)
|
|
def create_dataset(req: CreateDatasetRequest) -> dict[str, Any]:
|
|
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.")
|
|
dataset_id = str(uuid.uuid4())
|
|
dataset = {"id": dataset_id, "name": req.name, "created_at": time.time()}
|
|
captions = {fn: (req.captions.get(fn, "") if req.captions else "") for fn in req.file_names}
|
|
with _state_lock:
|
|
_datasets[dataset_id] = dataset
|
|
_dataset_files[dataset_id] = {"file_names": list(req.file_names), "captions": captions}
|
|
return _public_dataset(dataset)
|
|
|
|
|
|
@app.get("/api/datasets/{dataset_id}/files")
|
|
def get_dataset_files(dataset_id: str) -> dict[str, Any]:
|
|
with _state_lock:
|
|
if dataset_id not in _datasets:
|
|
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
files = _dataset_files.get(dataset_id, {"file_names": [], "captions": {}})
|
|
return {"file_names": list(files["file_names"]), "captions": dict(files["captions"])}
|
|
|
|
|
|
@app.put("/api/datasets/{dataset_id}/captions")
|
|
def update_dataset_caption(dataset_id: str, req: UpdateCaptionRequest) -> dict[str, str]:
|
|
with _state_lock:
|
|
if dataset_id not in _datasets:
|
|
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
files = _dataset_files.setdefault(dataset_id, {"file_names": [], "captions": {}})
|
|
files["captions"][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:
|
|
with _state_lock:
|
|
if dataset_id not in _datasets:
|
|
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
if file_name not in _dataset_files.get(dataset_id, {}).get("file_names", []):
|
|
raise HTTPException(status_code=404, detail="File not found")
|
|
return FileResponse(_build_mock_media("mp4"), media_type="video/mp4")
|
|
|
|
|
|
@app.delete("/api/datasets/{dataset_id}")
|
|
def delete_dataset(dataset_id: str) -> dict[str, str]:
|
|
with _state_lock:
|
|
if _datasets.pop(dataset_id, None) is None:
|
|
raise HTTPException(status_code=404, detail="Dataset not found")
|
|
_dataset_files.pop(dataset_id, None)
|
|
return {"detail": f"Dataset {dataset_id} deleted"}
|
|
|
|
|
|
def main() -> None:
|
|
global _state_file
|
|
import argparse
|
|
|
|
import uvicorn
|
|
|
|
parser = argparse.ArgumentParser(description="FastVideo Studio mock API server")
|
|
parser.add_argument("--host", default="127.0.0.1", help="Bind address (default: 127.0.0.1)")
|
|
# Default off the real server's 8189 so the mock never shadows a real API.
|
|
parser.add_argument("--port", type=int, default=8190, help="Port number (default: 8190)")
|
|
parser.add_argument(
|
|
"--data-dir",
|
|
default=None,
|
|
help=("Read-only: seed jobs/datasets/settings from a real server's "
|
|
"fastvideo_ui.db under this directory (its --data-dir) instead "
|
|
"of fake demo data. Nothing the mock does is written back."),
|
|
)
|
|
parser.add_argument(
|
|
"--state-file",
|
|
default=None,
|
|
help=("JSON file that keeps jobs created or changed in the mock across "
|
|
"restarts (applied on top of --data-dir). Delete it to reset."),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
if args.data_dir is None or not _load_snapshot_from_db(Path(args.data_dir).resolve()):
|
|
_seed()
|
|
if args.data_dir is not None:
|
|
use_server_data_dir(Path(args.data_dir).resolve())
|
|
if args.state_file:
|
|
_state_file = Path(args.state_file).resolve()
|
|
_load_state(_state_file)
|
|
|
|
uvicorn.run(app, host=args.host, port=args.port, log_level="info")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|