Add reference image previews
This commit is contained in:
@@ -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")
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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()) == []
|
||||
|
||||
Reference in New Issue
Block a user