diff --git a/apps/fastvideo_studio/mock_server.py b/apps/fastvideo_studio/mock_server.py index 56aaa7a3d..007b03594 100644 --- a/apps/fastvideo_studio/mock_server.py +++ b/apps/fastvideo_studio/mock_server.py @@ -399,6 +399,56 @@ def list_models(workload_type: str | None = None) -> list[dict[str, Any]]: return _models_for(workload_type) +# Per-model sampling presets, mirroring the real /api/models/presets shape +# (keys the model has no recommendation for are simply absent). +_MODEL_PRESETS: dict[str, dict[str, Any]] = { + "Wan-AI/Wan2.1-T2V-1.3B-Diffusers": { + "height": 480, + "width": 832, + "num_frames": 81, + "fps": 16, + "num_inference_steps": 50, + "guidance_scale": 3.0, + "guidance_rescale": 0.0, + "negative_prompt": "Bright tones, overexposed, static, blurred details", + "seed": 1024, + }, + "Wan-AI/Wan2.1-I2V-14B-480P-Diffusers": { + "height": 480, + "width": 832, + "num_frames": 81, + "fps": 16, + "num_inference_steps": 40, + "guidance_scale": 5.0, + "seed": 1024, + }, + "black-forest-labs/FLUX.1-schnell": { + "height": 1024, + "width": 1024, + "num_frames": 1, + "num_inference_steps": 4, + "guidance_scale": 0.0, + "seed": 42, + }, +} +_GENERIC_PRESETS: dict[str, Any] = { + "height": 720, + "width": 1280, + "num_frames": 81, + "fps": 24, + "num_inference_steps": 50, + "guidance_scale": 5.0, + "guidance_rescale": 0.0, + "seed": 1024, +} + + +@app.get("/api/models/presets") +def model_presets(model_id: str) -> dict[str, Any]: + """Recommended sampling settings; unknown models get generic defaults.""" + return _MODEL_PRESETS.get(model_id, _GENERIC_PRESETS) + + # --- GPUs ------------------------------------------------------------------- diff --git a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx index a2ae5517d..1f50833d7 100644 --- a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx +++ b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.test.tsx @@ -4,14 +4,24 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import CreateJobModal from './CreateJobModal'; -import { createJob, getDatasets, getModels, uploadImage } from '@/lib/api'; +import { + createJob, + getDatasets, + getModelPresets, + getModels, + listGenerators, + uploadImage, + type GeneratorInfo, +} from '@/lib/api'; import { defaultOptionsStore } from '@/stores/defaultOptions'; import { DEFAULT_OPTIONS } from '@/lib/defaultOptions'; vi.mock('@/lib/api', () => ({ createJob: vi.fn(), getModels: vi.fn(), + getModelPresets: vi.fn(), getDatasets: vi.fn(), + listGenerators: vi.fn(), uploadImage: vi.fn(), getSettings: vi.fn(), updateSettings: vi.fn(), @@ -22,11 +32,32 @@ const MODELS = [ { id: 'wan/t2v-14b', label: 'Wan T2V Large', type: 't2v' }, ]; +// A resident engine slot whose engine config differs from the persisted +// defaults on every field the modal adopts. +const WARM_SLOT: GeneratorInfo = { + state: 'ready', + model_id: 'wan/t2v-14b', + workload_type: 't2v', + num_gpus: 8, + dit_cpu_offload: true, + text_encoder_cpu_offload: true, + vae_cpu_offload: true, + image_encoder_cpu_offload: false, + use_fsdp_inference: true, + enable_torch_compile: true, + vsa_sparsity: 0.5, + tp_size: 1, + sp_size: 8, + error: null, +}; + beforeEach(() => { // Reset the shared options store to a known baseline for test isolation. defaultOptionsStore.set({ options: DEFAULT_OPTIONS }); vi.mocked(getModels).mockResolvedValue(MODELS); + vi.mocked(getModelPresets).mockResolvedValue({}); vi.mocked(getDatasets).mockResolvedValue([]); + vi.mocked(listGenerators).mockResolvedValue([]); vi.mocked(uploadImage).mockResolvedValue({ path: '/uploads/x.png' }); vi.mocked(createJob).mockResolvedValue({ id: 'job-1' } as never); }); @@ -206,4 +237,123 @@ describe('CreateJobModal', () => { await waitFor(() => expect(onSuccess).toHaveBeenCalledTimes(1)); }); + + it('defaults to the warm resident model and adopts its engine config', async () => { + vi.mocked(listGenerators).mockResolvedValue([WARM_SLOT]); + + const user = userEvent.setup(); + renderModal(); + + // Without a warm model the default logic picks the first model + // (wan/t2v-1.3b, covered by the seeding test above); the warm slot wins. + await waitFor(() => + expect(screen.getByLabelText('Model')).toHaveValue('wan/t2v-14b'), + ); + // The warm selection also triggers its presets fetch. + await waitFor(() => + expect(getModelPresets).toHaveBeenCalledWith('wan/t2v-14b'), + ); + + await user.type(screen.getByLabelText('Prompt'), 'warm run'); + await user.click(screen.getByRole('button', { name: 'Create Job' })); + + await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1)); + // The engine fields mirror the resident slot (not the persisted defaults: + // num_gpus 1, all offloads/fsdp/compile false) so the job reuses the warm + // instance instead of replacing it. + expect(vi.mocked(createJob).mock.calls[0][0]).toMatchObject({ + model_id: 'wan/t2v-14b', + num_gpus: 8, + tp_size: 1, + sp_size: 8, + dit_cpu_offload: true, + text_encoder_cpu_offload: true, + vae_cpu_offload: true, + image_encoder_cpu_offload: false, + use_fsdp_inference: true, + enable_torch_compile: true, + vsa_sparsity: 0.5, + }); + }); + + it('restores persisted-default engine fields when switching away from the warm model', async () => { + defaultOptionsStore.set({ + options: { ...DEFAULT_OPTIONS, numGpus: 2, tpSize: 2 }, + }); + vi.mocked(listGenerators).mockResolvedValue([WARM_SLOT]); + + const user = userEvent.setup(); + renderModal(); + + await waitFor(() => + expect(screen.getByLabelText('Model')).toHaveValue('wan/t2v-14b'), + ); + await user.selectOptions(screen.getByLabelText('Model'), 'wan/t2v-1.3b'); + await user.type(screen.getByLabelText('Prompt'), 'cold run'); + await user.click(screen.getByRole('button', { name: 'Create Job' })); + + await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1)); + expect(vi.mocked(createJob).mock.calls[0][0]).toMatchObject({ + model_id: 'wan/t2v-1.3b', + num_gpus: 2, + tp_size: 2, + use_fsdp_inference: false, + enable_torch_compile: false, + }); + }); + + it('populates sampling fields from the selected model presets; engine fields stay from defaults', async () => { + defaultOptionsStore.set({ + options: { ...DEFAULT_OPTIONS, numGpus: 4, tpSize: 2, seed: 999 }, + }); + vi.mocked(getModelPresets).mockImplementation(async (id) => + id === 'wan/t2v-14b' + ? { + height: 720, + width: 1280, + num_frames: 121, + fps: 30, + num_inference_steps: 40, + guidance_scale: 6, + guidance_rescale: 0.5, + negative_prompt: 'blurry, low quality', + seed: 7, + } + : {}, + ); + + const user = userEvent.setup(); + renderModal(); + + await screen.findByRole('option', { name: 'Wan T2V Large (wan/t2v-14b)' }); + await user.selectOptions(screen.getByLabelText('Model'), 'wan/t2v-14b'); + // The negative prompt is the easiest preset-populated field to observe. + await waitFor(() => + expect(screen.getByLabelText('Negative Prompt')).toHaveValue( + 'blurry, low quality', + ), + ); + + await user.type(screen.getByLabelText('Prompt'), 'preset test'); + await user.click(screen.getByRole('button', { name: 'Create Job' })); + + await waitFor(() => expect(createJob).toHaveBeenCalledTimes(1)); + const payload = vi.mocked(createJob).mock.calls[0][0]; + expect(payload).toMatchObject({ + model_id: 'wan/t2v-14b', + // Sampling fields come from the model presets… + height: 720, + width: 1280, + num_frames: 121, + fps: 30, + num_inference_steps: 40, + guidance_scale: 6, + guidance_rescale: 0.5, + negative_prompt: 'blurry, low quality', + seed: 7, + // …while engine fields still come from the persisted defaults. + num_gpus: 4, + tp_size: 2, + }); + }); }); diff --git a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx index 1abccf4a6..7152729ae 100644 --- a/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx +++ b/apps/fastvideo_studio/src/components/jobs/CreateJobModal.tsx @@ -23,9 +23,12 @@ import { defaultOptionsStore } from '@/stores/defaultOptions'; import { createJob, getDatasets, + getModelPresets, getModels, + listGenerators, uploadImage, type CreateJobRequest, + type GeneratorInfo, type Model, } from '@/lib/api'; import { getDefaultModelForWorkload } from '@/lib/defaultOptions'; @@ -55,6 +58,9 @@ export default function CreateJobModal({ const [models, setModels] = React.useState([]); const [modelId, setModelId] = React.useState(''); + // The engine's resident slot (null when empty/failed), fetched on open so + // jobs against the warm model can adopt its engine config. + const [warmSlot, setWarmSlot] = React.useState(null); const [prompt, setPrompt] = React.useState(''); const [imagePath, setImagePath] = React.useState(''); const [imageFileName, setImageFileName] = React.useState(''); @@ -178,17 +184,34 @@ export default function CreateJobModal({ let stale = false; setIsLoadingModels(true); setModelLoadError(null); - getModels(inferenceWorkload) - .then((list) => { + // For inference jobs, prefer the warm resident model (if any) over the + // persisted default so new jobs hit the already-loaded engine. The warm + // slot is a nice-to-have: if the lookup fails, fall back silently. + const warmSlotPromise = isInference + ? listGenerators() + .then((gens) => + gens[0] && gens[0].state !== 'failed' ? gens[0] : null, + ) + .catch(() => null) + : Promise.resolve(null); + Promise.all([getModels(inferenceWorkload), warmSlotPromise]) + .then(([list, slot]) => { if (stale) return; setModels(list); + setWarmSlot(slot); const ids = list.map((m) => m.id); const opts = defaultOptionsStore.get().options; const defaultId = getDefaultModelForWorkload( opts, inferenceWorkload as 't2v' | 'i2v' | 't2i', ); - const chosen = ids.includes(defaultId) ? defaultId : (list[0]?.id ?? ''); + const warmId = slot?.model_id ?? ''; + const chosen = + warmId && ids.includes(warmId) + ? warmId + : ids.includes(defaultId) + ? defaultId + : (list[0]?.id ?? ''); setModelId(chosen); if (workloadType === 'dmd_t2v') { setRealScoreModelPath(chosen); @@ -210,7 +233,78 @@ export default function CreateJobModal({ return () => { stale = true; }; - }, [isOpen, inferenceWorkload, workloadType]); + }, [isOpen, isInference, inferenceWorkload, workloadType]); + + // Whenever the selected model changes (including the initial selection on + // open), populate the sampling fields from that model's presets. Missing + // keys leave the field as-is; engine fields (GPUs, parallelism, offloads) + // come from the warm slot or persisted defaults (effect below). Latest + // selection wins: the cleanup marks superseded fetches stale, same as the + // model-list effect. + React.useEffect(() => { + if (!isOpen || !isInference || !modelId) return; + let stale = false; + getModelPresets(modelId) + .then((p) => { + if (stale) return; + if (p.height !== undefined) setHeight(p.height); + if (p.width !== undefined) setWidth(p.width); + if (p.num_frames !== undefined) + setNumFrames(workloadType === 't2i' ? 1 : p.num_frames); + if (p.fps !== undefined) setFps(p.fps); + if (p.num_inference_steps !== undefined) + setNumInferenceSteps(p.num_inference_steps); + if (p.guidance_scale !== undefined) setGuidanceScale(p.guidance_scale); + if (p.guidance_rescale !== undefined) + setGuidanceRescale(p.guidance_rescale); + if (p.negative_prompt !== undefined) + setNegativePrompt(p.negative_prompt); + if (p.seed !== undefined) setSeed(p.seed); + }) + .catch((e) => { + // Presets are a convenience; on failure keep the current values. + console.error('Failed to load model presets:', e); + }); + return () => { + stale = true; + }; + }, [isOpen, isInference, modelId, workloadType]); + + // When the selected model IS the warm resident one, adopt the slot's engine + // config so the job reuses the loaded instance instead of silently replacing + // it (e.g. persisted num_gpus=1 vs a warm 8-GPU slot). Switching away from + // the warm model restores the persisted-default engine fields; non-warm to + // non-warm switches leave the user's engine edits alone (today's behavior). + const wasWarmRef = React.useRef(false); + React.useEffect(() => { + if (!isOpen || !isInference || !modelId) return; + const isWarm = warmSlot?.model_id === modelId; + if (isWarm && warmSlot) { + setNumGpus(warmSlot.num_gpus); + setTpSize(warmSlot.tp_size); + setSpSize(warmSlot.sp_size); + setDitCpuOffload(warmSlot.dit_cpu_offload); + setTextEncoderCpuOffload(warmSlot.text_encoder_cpu_offload); + setVaeCpuOffload(warmSlot.vae_cpu_offload); + setImageEncoderCpuOffload(warmSlot.image_encoder_cpu_offload); + setUseFsdpInference(warmSlot.use_fsdp_inference); + setEnableTorchCompile(warmSlot.enable_torch_compile); + setVsaSparsity(warmSlot.vsa_sparsity); + } else if (wasWarmRef.current) { + const opts = defaultOptionsStore.get().options; + setNumGpus(opts.numGpus); + setTpSize(opts.tpSize); + setSpSize(opts.spSize); + setDitCpuOffload(opts.ditCpuOffload); + setTextEncoderCpuOffload(opts.textEncoderCpuOffload); + setVaeCpuOffload(opts.vaeCpuOffload); + setImageEncoderCpuOffload(opts.imageEncoderCpuOffload); + setUseFsdpInference(opts.useFsdpInference); + setEnableTorchCompile(opts.enableTorchCompile); + setVsaSparsity(opts.vsaSparsity); + } + wasWarmRef.current = isWarm; + }, [isOpen, isInference, modelId, warmSlot]); // Training jobs need a dataset; load the ready datasets when relevant. React.useEffect(() => { diff --git a/apps/fastvideo_studio/src/lib/api.ts b/apps/fastvideo_studio/src/lib/api.ts index 02c80c2a2..0fb50040f 100644 --- a/apps/fastvideo_studio/src/lib/api.ts +++ b/apps/fastvideo_studio/src/lib/api.ts @@ -202,6 +202,33 @@ export async function getModels(workloadType?: string): Promise { return response.json(); } +/** + * A model's recommended sampling settings. Keys the backend has no value + * for are absent; the UI leaves those form fields untouched. + */ +export interface ModelPresets { + height?: number; + width?: number; + num_frames?: number; + fps?: number; + num_inference_steps?: number; + guidance_scale?: number; + guidance_rescale?: number; + negative_prompt?: string; + seed?: number; +} + +export async function getModelPresets(modelId: string): Promise { + const baseApiUrl = getApiBaseUrl(); + const response = await fetch( + `${baseApiUrl}/models/presets?model_id=${encodeURIComponent(modelId)}`, + ); + if (!response.ok) { + throw new Error("Failed to fetch model presets"); + } + return response.json(); +} + export async function getGpus(): Promise { const baseApiUrl = getApiBaseUrl(); const response = await fetch(`${baseApiUrl}/gpus`);