Add reference image previews

This commit is contained in:
kevin314
2026-10-23 09:27:21 +00:00
parent 6b039e80ac
commit c9e02f666d
8 changed files with 300 additions and 9 deletions
+96 -7
View File
@@ -20,10 +20,17 @@ 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
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
@@ -51,12 +58,12 @@ 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)
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")
@@ -122,6 +129,9 @@ _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).
@@ -161,7 +171,7 @@ def _build_mock_media_with_pyav(kind: str, path: str) -> None:
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.")
"browsers cannot play in <video>. Install ffmpeg for real preview playback.")
codec_name = "mpeg4"
container = pyav.open(path, mode="w")
@@ -194,7 +204,8 @@ def _build_mock_media(kind: str) -> str:
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
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
@@ -511,6 +522,8 @@ def _load_snapshot_from_db(data_dir: Path) -> bool:
_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()
@@ -527,6 +540,45 @@ def _load_snapshot_from_db(data_dir: Path) -> bool:
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")
@@ -542,6 +594,15 @@ app.add_middleware(
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
@@ -939,8 +1000,16 @@ def _enqueue(job_ids: list[str]) -> list[dict[str, Any]]:
)
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")
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]])
@@ -1065,6 +1134,16 @@ def get_video(job_id: str) -> FileResponse:
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_")
@@ -1199,6 +1278,7 @@ def delete_dataset(dataset_id: str) -> dict[str, str]:
def main() -> None:
global _state_file
import argparse
import uvicorn
@@ -1214,12 +1294,21 @@ def main() -> None:
"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")
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: Apache-2.0
"""Serve a job's image reference so the job form can show a thumbnail of it.
Only a file the job itself lists as an image reference is served: the API may
be reachable from outside (a tunnel), so a path from the request alone never
opens a file.
"""
from __future__ import annotations
import mimetypes
import os
from typing import Any
from fastapi import HTTPException
from fastapi.responses import FileResponse
IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
def reference_image_response(references: list[dict[str, Any]] | None, source: str) -> FileResponse:
listed = any(r.get("source") == source and r.get("media_type") == "image" for r in references or [])
ext = os.path.splitext(source)[1].lower()
if not listed or ext not in IMAGE_EXTENSIONS:
raise HTTPException(status_code=404, detail="No such image reference on this job")
if not os.path.isfile(source):
raise HTTPException(status_code=404, detail="Reference file not found on disk")
return FileResponse(source, media_type=mimetypes.guess_type(source)[0] or "application/octet-stream")
+10
View File
@@ -37,6 +37,7 @@ from fastvideo_studio.frames import FrameError, last_clip_for_job, last_frame_fo
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,
@@ -706,6 +707,15 @@ def get_video(job_id: str) -> FileResponse:
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.
@@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import CreateJobModal from './CreateJobModal';
import { toast } from 'sonner';
import { createJob, getDatasets, getModels, uploadImage } from '@/lib/api';
import { createJob, getDatasets, getModels, uploadImage, uploadMedia } from '@/lib/api';
import { defaultOptionsStore } from '@/stores/defaultOptions';
import { DEFAULT_OPTIONS } from '@/lib/defaultOptions';
@@ -16,6 +16,9 @@ vi.mock('@/lib/api', () => ({
getModels: vi.fn(),
getDatasets: vi.fn(),
uploadImage: vi.fn(),
uploadMedia: vi.fn(),
updateJob: vi.fn(),
getJobReferenceUrl: (jobId: string, source: string) => `/api/jobs/${jobId}/reference?source=${encodeURIComponent(source)}`,
getSettings: vi.fn(),
updateSettings: vi.fn(),
}));
@@ -385,3 +388,43 @@ describe('CreateJobModal shot list on a raw prompt', () => {
expect(screen.queryByRole('button', { name: 'Open shot list' })).not.toBeInTheDocument();
});
});
describe('CreateJobModal reference previews', () => {
beforeEach(() => {
vi.mocked(getModels).mockResolvedValue([...MODELS, H3]);
});
const job = {
id: 'job-9',
model_id: H3.id,
name: 'diner-clip-01',
prompt: BASE_PROMPT,
workload_type: 'i2v',
status: 'pending',
references: [
{ source: '/refs/dana.jpg', media_type: 'image' },
{ source: '/refs/voice.mp3', media_type: 'audio' },
],
};
it("shows a saved job's image references from the server and an icon for other media", async () => {
renderModal({ workloadType: 'i2v', editingJob: job as never });
const thumb = await screen.findByRole('img', { name: 'dana.jpg' });
expect(thumb).toHaveAttribute('src', '/api/jobs/job-9/reference?source=%2Frefs%2Fdana.jpg');
expect(screen.queryByRole('img', { name: 'voice.mp3' })).not.toBeInTheDocument();
});
it('previews a newly uploaded image from the local file', async () => {
const user = userEvent.setup();
URL.createObjectURL = vi.fn(() => 'blob:local-preview');
URL.revokeObjectURL = vi.fn();
vi.mocked(uploadMedia).mockResolvedValue({ path: '/uploads/u1/marcus.jpg', media_type: 'image' });
renderModal({ workloadType: 'i2v', editingJob: job as never });
await screen.findByRole('img', { name: 'dana.jpg' });
await user.upload(screen.getByLabelText('References (Ref2VA)'), new File(['x'], 'marcus.jpg', { type: 'image/jpeg' }));
expect(await screen.findByRole('img', { name: 'marcus.jpg' })).toHaveAttribute('src', 'blob:local-preview');
});
});
@@ -24,6 +24,7 @@ import {
createJob,
updateJob,
getDatasets,
getJobReferenceUrl,
getModels,
uploadImage,
uploadMedia,
@@ -55,6 +56,7 @@ import { applyH3Tag, type H3Tag } from '@/lib/h3Tags';
import { H3TagConsole } from '@/components/jobs/H3TagConsole';
import { H3DialogueEditor } from '@/components/jobs/H3DialogueEditor';
import { H3ShotListEditor } from '@/components/jobs/H3ShotListEditor';
import { ReferenceThumbnail } from '@/components/jobs/ReferenceThumbnail';
import { jobToFormFields, type JobLike } from '@/lib/jobToFields';
import { readField, shotTarget, writeField, writeShotTarget } from '@/lib/h3PromptFields';
import { toast } from 'sonner';
@@ -100,6 +102,16 @@ export default function CreateJobModal({
const [lastImagePath, setLastImagePath] = React.useState('');
const [references, setReferences] = React.useState<H3Reference[]>([]);
const [isUploadingReference, setIsUploadingReference] = React.useState(false);
// Local previews of references uploaded in this session, by reference id; a saved job's own
// references are previewed from the server instead.
const [uploadPreviews, setUploadPreviews] = React.useState<Record<string, string>>({});
React.useEffect(() => {
if (isOpen) return;
setUploadPreviews((previews) => {
Object.values(previews).forEach((url) => URL.revokeObjectURL(url));
return {};
});
}, [isOpen]);
const [referenceError, setReferenceError] = React.useState<string | null>(null);
const [promptFields, setPromptFields] = React.useState<H3PromptFields>(
EMPTY_H3_PROMPT_FIELDS,
@@ -531,10 +543,15 @@ export default function CreateJobModal({
setReferenceError(null);
try {
const { path, media_type } = await uploadMedia(file);
const id = `${Date.now()}-${file.name}`;
if (media_type === 'image') {
const url = URL.createObjectURL(file);
setUploadPreviews((previews) => ({ ...previews, [id]: url }));
}
const next: H3Reference[] = [
...references,
{
id: `${Date.now()}-${file.name}`,
id,
source: path,
media_type,
fileName: file.name,
@@ -911,6 +928,14 @@ export default function CreateJobModal({
key={reference.id}
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<ReferenceThumbnail
mediaType={reference.media_type}
src={
uploadPreviews[reference.id] ??
(editingJob ? getJobReferenceUrl(editingJob.id, reference.source) : null)
}
alt={reference.fileName}
/>
<code className="font-mono text-accent-blue">
{labelReferences(references)[index]}
</code>
@@ -0,0 +1,35 @@
'use client';
import * as React from 'react';
import { FileAudio, FileVideo, ImageOff } from 'lucide-react';
import type { MediaType } from '@/lib/api';
export interface ReferenceThumbnailProps {
mediaType: MediaType;
/** where the image can be loaded from; null when it cannot be previewed */
src: string | null;
alt: string;
}
/** A small square preview of a reference: the image itself, or an icon for video, audio, or an unreadable image. */
export function ReferenceThumbnail({ mediaType, src, alt }: ReferenceThumbnailProps) {
const [failed, setFailed] = React.useState(false);
React.useEffect(() => setFailed(false), [src]);
const box = 'flex size-12 shrink-0 items-center justify-center overflow-hidden rounded border border-border bg-muted';
if (mediaType === 'image' && src && !failed) {
return (
<a href={src} target="_blank" rel="noreferrer" className={box} title="Open full size">
{/* eslint-disable-next-line @next/next/no-img-element -- served by the Studio API, not a static asset */}
<img src={src} alt={alt} loading="lazy" onError={() => setFailed(true)} className="size-full object-cover" />
</a>
);
}
const Icon = mediaType === 'video' ? FileVideo : mediaType === 'audio' ? FileAudio : ImageOff;
return (
<span className={box} aria-hidden>
<Icon className="size-5 text-muted-foreground" />
</span>
);
}
+5
View File
@@ -61,6 +61,11 @@ export function getJobVideoUrl(jobId: string, version?: number): string {
return version ? `${base}?v=${version}` : base;
}
/** One of a job's image references, served only when the job lists it. */
export function getJobReferenceUrl(jobId: string, source: string): string {
return `${getApiBaseUrl()}/jobs/${jobId}/reference?source=${encodeURIComponent(source)}`;
}
export interface CreateJobRequest {
model_id: string;
/** Optional label; the card and output filename fall back to the prompt. */
@@ -470,3 +470,59 @@ def test_snapshot_job_never_edited_has_an_empty_segment_list(client, snapshot_db
assert mock_server._load_snapshot_from_db(data_dir)
assert client.get("/api/jobs/fresh").json()["edit_segments"] == []
def test_reference_preview_serves_only_the_jobs_own_image_references(tmp_path):
from fastapi.testclient import TestClient
image = tmp_path / "dana.jpg"
image.write_bytes(b"\xff\xd8\xff\xe0 not really a jpeg")
audio = tmp_path / "voice.mp3"
audio.write_bytes(b"ID3")
other = tmp_path / "secret.png"
other.write_bytes(b"\x89PNG")
client = TestClient(mock_server.app)
job = client.post("/api/jobs", json={"model_id": "MiniMaxAI/MiniMax-H3", "name": "p-clip-01", "prompt": "p",
"references": [{"source": str(image), "media_type": "image"},
{"source": str(audio), "media_type": "audio"}]}).json()
def get(source, job_id=job["id"]):
return client.get(f"/api/jobs/{job_id}/reference", params={"source": source})
ok = get(str(image))
assert ok.status_code == 200 and ok.headers["content-type"] == "image/jpeg"
assert get(str(other)).status_code == 404 # an existing file the job does not list
assert get(str(audio)).status_code == 404 # listed, but not an image
assert get(str(image), job_id="missing").status_code == 404
def test_state_file_keeps_mock_job_changes_across_a_restart(client, tmp_path, monkeypatch):
state = tmp_path / "mock_state.json"
snapshot_kept = {"id": "kept", "status": "completed", "prompt": "old", mock_server._FROM_SNAPSHOT_KEY: True}
snapshot_gone = {"id": "gone", "status": "completed", "prompt": "old", mock_server._FROM_SNAPSHOT_KEY: True}
monkeypatch.setattr(mock_server, "_state_file", state)
monkeypatch.setattr(mock_server, "_snapshot_originals", {"kept": dict(snapshot_kept), "gone": dict(snapshot_gone)})
mock_server._jobs.update({"kept": dict(snapshot_kept), "gone": dict(snapshot_gone)})
job = _create(client, name="diner-envelope-clip-01")
client.patch(f"/api/jobs/{job['id']}", json={"prompt": "edited"})
assert client.delete("/api/jobs/gone").status_code == 200
saved = __import__("json").loads(state.read_text())
assert [j["id"] for j in saved["jobs"]] == [job["id"]] # the untouched snapshot job is not copied
assert saved["deleted_job_ids"] == ["gone"]
# "Restart": the snapshot loads again, then the saved state goes on top.
mock_server._jobs.clear()
mock_server._jobs.update({"kept": dict(snapshot_kept), "gone": dict(snapshot_gone)})
mock_server._load_state(state)
assert set(mock_server._jobs) == {"kept", job["id"]}
assert client.get(f"/api/jobs/{job['id']}").json()["prompt"] == "edited"
def test_without_a_state_file_nothing_is_written(client, tmp_path, monkeypatch):
monkeypatch.setattr(mock_server, "_state_file", None)
monkeypatch.chdir(tmp_path)
_create(client)
assert list(tmp_path.iterdir()) == []