diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md
index 4e1b0cc107..284fe9240d 100644
--- a/client/src/hooks/README.md
+++ b/client/src/hooks/README.md
@@ -206,7 +206,7 @@ grep -i "what you want to do" client/src/hooks/README.md
| `useYoutubeIngest` | One YouTube brain-ingest job slot: start/cancel + SSE progress + terminal-frame handling via `POST /api/brain/youtube/ingest`. Returns `{ active, percent, stage, start(body), cancel }` — `start` takes the whole payload (`{ url, captureTranscript, downloadVideo, ingestAudio, note, agentPrompt, tags }`), not a bare URL. `onComplete(ingest)` fires with the stored ingest record; non-fatal `warnings[]` on the terminal frame are toasted automatically. | Quick Capture's YouTube path, and any other surface that ingests a video into the brain. |
| `useYoutubeTrackImport` | One YouTube-audio-import job slot (#1945): start/cancel + SSE progress + terminal-frame handling via `POST /api/tracks/import/youtube`. Returns `{ active, percent, start(url, context), cancel }`; `onComplete(track, context)` fires with the finished Track. Call once per UI surface that can independently kick off an import — a shared slot would let one surface's kickoff orphan another's in-flight job. | Any picker that lets a user paste a YouTube URL to create a Track (Music Video's create form and track-change row). |
| `useVideoGenFieldState` | Mutable VideoGen field values, setters, and lifecycle refs initialized from URL handoff values. | Internal state slice composed by `useVideoGenForm`; keep model reconciliation and submission behavior in their focused units. |
-| `useVideoGenForm` | `useVideoGenForm({ models, status, availableLoras, grokEnabled })` → every VideoGen form field + setter, the URL-param prefill paths (ImageGen handoff, Continue, Remix, `?lora=`), the mode/backend transitions that clear stale inputs, the derived model/keyframe/IC gates (`extendModeBlocked`, `a2vModeBlocked`, `icLoraModeBlocked`, `keyframesError`), `applyRemix` / `applyResumedParams`, and `buildGeneratePayload()`. | The /media/video form — the single client-side source of truth for the payload `server/routes/videoGen.js` validates. Reuse it for any second entry point that generates video. |
+| `useVideoGenForm` | `useVideoGenForm({ models, modelContext, availableLoras, grokEnabled })` → every VideoGen form field + setter, the URL-param prefill paths (ImageGen handoff, Continue, Remix, `?lora=`), the mode/backend transitions that clear stale inputs, the derived model/keyframe/IC gates (`extendModeBlocked`, `a2vModeBlocked`, `icLoraModeBlocked`, `keyframesError`), `applyRemix` / `applyResumedParams`, and `buildGeneratePayload()`. | The /media/video form — the single client-side source of truth for the payload `server/routes/videoGen.js` validates. Reuse it for any second entry point that generates video. |
| `useVideoGenSubmitFlow` | Builds the current VideoGen wire payload and enveloped prompt from a form-state snapshot. | Internal submission slice composed by `useVideoGenForm`; all local, Grok, and federated request shaping remains in `videoGenSubmission`. |
| `useVideoGenValidation` | Derives keyframe errors and all VideoGen submit-blocking predicates from the current fields and model capabilities. | Internal validation slice composed by `useVideoGenForm`; also exports `validateVideoKeyframes` for focused tests. |
| `useVideoDownload` | One full-video-download job slot (#1946): start/cancel + SSE progress + terminal-frame handling via `POST /api/devtools/video-download`. Returns `{ active, percent, stage, context, start(url, context), cancel }`; `onComplete(video)` fires with the finished video-history entry. | The Dev Tools Video Downloader page. |
diff --git a/client/src/hooks/useVideoGenForm.js b/client/src/hooks/useVideoGenForm.js
index e8ff7988fd..79be036303 100644
--- a/client/src/hooks/useVideoGenForm.js
+++ b/client/src/hooks/useVideoGenForm.js
@@ -53,12 +53,14 @@ const editableRemixModel = (models, defaultModelId) => {
* that clear now-irrelevant inputs, the derived model/keyframe/IC gates, and
* `buildGeneratePayload()` — the single client-side source of truth for the
* shape `server/routes/videoGen.js` validates. `VideoGen.jsx` keeps the
- * fetching (status/models/history/gallery), the SSE run pipeline, the batch
- * queue, and the rendering.
+ * fetching (status/model-context/history/gallery), the SSE run pipeline, the
+ * batch queue, and the rendering.
*
* The caller supplies the fetched context the form has to react to:
- * - `models` / `status` — from `getVideoGenStatus()`; drive the model
- * dropdown, the default-model seed, and the mode-compatibility fallback.
+ * - `models` / `modelContext` — from `getVideoGenModelContext()`; drive the
+ * model dropdown, the default-model seed, and the mode-compatibility
+ * fallback. Deliberately NOT `getVideoGenStatus()`: that route shells out
+ * to python, and the picker must not wait on the interpreter probe.
* - `availableLoras` — the installed LoRA library, for name resolution.
* - `grokEnabled` — the Settings → Image Gen toggle that reveals the
* Local/Grok backend switch.
@@ -69,7 +71,7 @@ const editableRemixModel = (models, defaultModelId) => {
* wire accepts — kept here rather than in the page so there stays exactly
* one builder for what `server/routes/videoGen.js` validates.
*/
-export function useVideoGenForm({ models, status, availableLoras, grokEnabled, remoteSubmissionFields = null }) {
+export function useVideoGenForm({ models, modelContext, availableLoras, grokEnabled, remoteSubmissionFields = null }) {
const [searchParams, setSearchParams] = useSearchParams();
const incomingSourceImage = searchParams.get('sourceImageFile');
const incomingAudioFilename = searchParams.get('audioFilename');
@@ -163,11 +165,11 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
});
return () => { cancelled = true; };
}, [incomingAudioFilename, setSearchParams]);
- // Seed the model dropdown from the server's default once /status lands,
- // without clobbering a Remix/deep-link/user pick that already set it.
+ // Seed the model dropdown from the server's default once the model context
+ // lands, without clobbering a Remix/deep-link/user pick that already set it.
useEffect(() => {
- if (status?.defaultModel) setModelId((prev) => prev || status.defaultModel);
- }, [status?.defaultModel]);
+ if (modelContext?.defaultModel) setModelId((prev) => prev || modelContext.defaultModel);
+ }, [modelContext?.defaultModel]);
// Re-sync when ImageGen pipes a new image via ?sourceImageFile=...
useEffect(() => {
@@ -370,7 +372,8 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
// server would 400 on submit; we proactively swap to a compatible model.
// a2v fallback preference: highest-memory model that fits this machine
// (leaving headroom for the OS + text encoder) > the largest if none fit.
- // Other modes: status.defaultModel (if compatible) > first compatible model.
+ // Other modes: the context's defaultModel (if compatible) > first
+ // compatible model.
useEffect(() => {
if (!modelId || models.length === 0) return;
const current = models.find((m) => m.id === modelId);
@@ -385,13 +388,13 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
// so the user can at least try, and the install banner / OOM surfaces
// the real constraint instead of a silent dropdown change.
const reserveGb = 16;
- // typeof === 'number' (not `status?.systemMemoryGb ? ...`) so a server
- // legitimately reporting a tiny number (0 GB after rounding on a
- // sub-GB box) flows through the `fits` check and lands on the
- // smallest model. The truthiness shortcut would collapse 0 with
- // "absent" and pick the LARGEST model on a tiny machine.
- const budget = typeof status?.systemMemoryGb === 'number'
- ? Math.max(0, status.systemMemoryGb - reserveGb)
+ // typeof === 'number' (not `modelContext?.systemMemoryGb ? ...`) so a
+ // server legitimately reporting a tiny number (0 GB after rounding on a
+ // sub-GB box) flows through the `fits` check and lands on the smallest
+ // model. The truthiness shortcut would collapse 0 with "absent" and pick
+ // the LARGEST model on a tiny machine.
+ const budget = typeof modelContext?.systemMemoryGb === 'number'
+ ? Math.max(0, modelContext.systemMemoryGb - reserveGb)
: Number.POSITIVE_INFINITY;
const sortedDesc = [...visibleModels].sort(
(a, b) => videoModelMemoryGb(b) - videoModelMemoryGb(a),
@@ -399,18 +402,18 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
const fits = sortedDesc.find((m) => videoModelMemoryGb(m) <= budget);
fallback = (fits || sortedDesc[sortedDesc.length - 1])?.id || '';
} else {
- const defaultModel = models.find((m) => m.id === status?.defaultModel);
+ const defaultModel = models.find((m) => m.id === modelContext?.defaultModel);
if (defaultModel && isModelAllowedForMode(defaultModel, mode)) {
fallback = defaultModel.id;
} else {
- fallback = visibleModels[0]?.id || status?.defaultModel || models[0]?.id || '';
+ fallback = visibleModels[0]?.id || modelContext?.defaultModel || models[0]?.id || '';
}
}
if (!fallback || fallback === modelId) return;
// Toast only for the stale-id case (model removed from catalog). The
// mode-incompatibility swap is expected behavior after a mode change —
// no need to surface it. Name the destination model so users on a2v
- // don't think they landed on `status.defaultModel` (they may not have —
+ // don't think they landed on `modelContext.defaultModel` (they may not have —
// a2v picks the largest-fits model, which is often a dgrauet entry).
if (!current && staleModelToastRef.current !== modelId) {
staleModelToastRef.current = modelId;
@@ -418,7 +421,7 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
toast(`Original model "${modelId}" is no longer available — switched to "${fallbackName}"`);
}
applyModelSelection(fallback);
- }, [modelId, models, status?.defaultModel, status?.systemMemoryGb, mode, visibleModels, applyModelSelection]);
+ }, [modelId, models, modelContext?.defaultModel, modelContext?.systemMemoryGb, mode, visibleModels, applyModelSelection]);
const currentModel = models.find((m) => m.id === modelId);
@@ -435,9 +438,9 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
setNumFrames(frames);
}, [audioDurationSec, currentModel, fps, mode]);
- // A source model can reach this hook either through a URL handoff before
- // /status has populated `models`, or from the in-page gallery after it has.
- // Resolve both cases here. The fallback is deliberately limited to models
+ // A source model can reach this hook either through a URL handoff before the
+ // model context has populated `models`, or from the in-page gallery after it
+ // has. Resolve both cases here. The fallback is deliberately limited to models
// that can run a text remix and expose all restored prompt/sampler controls;
// if no such model is installed we leave the source selected rather than
// silently changing a faithful re-render.
@@ -445,7 +448,7 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
if (!remixSourceModel || models.length === 0) return;
const source = models.find((model) => model.id === remixSourceModel.id);
if (source && !remixSourceModel.preserveConditioning && !hasEditableRemixControls(source)) {
- const target = editableRemixModel(models, status?.defaultModel);
+ const target = editableRemixModel(models, modelContext?.defaultModel);
if (target) {
setModelId(target.id);
setRemixModelFallback({
@@ -459,7 +462,7 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
setRemixModelFallback(null);
}
setRemixSourceModel(null);
- }, [remixSourceModel, models, status?.defaultModel]);
+ }, [remixSourceModel, models, modelContext?.defaultModel]);
// Until the user deliberately chooses a size, model changes carry their own
// native default canvas. This is material for H3: the shared 768x512 default
@@ -712,7 +715,7 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled, r
keyframesActive,
mode,
numFrames,
- pixelBudget: status?.fflfLtx2PixelBudget,
+ pixelBudget: modelContext?.fflfLtx2PixelBudget,
sourceImageFile,
sourceImageUpload,
width,
diff --git a/client/src/hooks/useVideoGenForm.test.jsx b/client/src/hooks/useVideoGenForm.test.jsx
index 6723f7b3bc..6f4ae7b66f 100644
--- a/client/src/hooks/useVideoGenForm.test.jsx
+++ b/client/src/hooks/useVideoGenForm.test.jsx
@@ -67,13 +67,13 @@ const H3_REF2VA = {
defaultFrames: 124,
};
const MODELS = [MLX, LTX2];
-const STATUS = { connected: true, defaultModel: MLX.id };
+const MODEL_CONTEXT = { defaultModel: MLX.id };
-const render = ({ models = MODELS, status = STATUS, availableLoras = [], grokEnabled = false, url = '/media/video' } = {}) => {
+const render = ({ models = MODELS, modelContext = MODEL_CONTEXT, availableLoras = [], grokEnabled = false, url = '/media/video' } = {}) => {
const wrapper = ({ children }) => {children};
return renderHook(
(props) => useVideoGenForm(props),
- { wrapper, initialProps: { models, status, availableLoras, grokEnabled } },
+ { wrapper, initialProps: { models, modelContext, availableLoras, grokEnabled } },
);
};
@@ -94,7 +94,7 @@ describe('useVideoGenForm', () => {
vi.unstubAllGlobals();
});
- it('seeds the model from status.defaultModel without clobbering a URL pick', async () => {
+ it('seeds the model from modelContext.defaultModel without clobbering a URL pick', async () => {
const { result } = render();
await waitFor(() => expect(result.current.modelId).toBe(MLX.id));
@@ -324,7 +324,7 @@ describe('useVideoGenForm', () => {
it('clears sampler overrides on an automatic mode-compatible model fallback', async () => {
const { result } = render({
models: [WAN_T2V, WAN_TI2V],
- status: { connected: true, defaultModel: WAN_T2V.id },
+ modelContext: { defaultModel: WAN_T2V.id },
});
await waitFor(() => expect(result.current.modelId).toBe(WAN_T2V.id));
act(() => {
@@ -340,7 +340,7 @@ describe('useVideoGenForm', () => {
it('does not submit chunks for a T2V-only Wan profile', async () => {
const { result } = render({
models: [WAN_T2V],
- status: { connected: true, defaultModel: WAN_T2V.id },
+ modelContext: { defaultModel: WAN_T2V.id },
});
await waitFor(() => expect(result.current.modelId).toBe(WAN_T2V.id));
act(() => {
@@ -353,7 +353,7 @@ describe('useVideoGenForm', () => {
it('normalizes MiniMax H3 to its fixed temporal and sampler contract', async () => {
const { result } = render({
models: [MLX, H3],
- status: { connected: true, defaultModel: MLX.id },
+ modelContext: { defaultModel: MLX.id },
});
await waitFor(() => expect(result.current.modelId).toBe(MLX.id));
act(() => {
@@ -388,7 +388,7 @@ describe('useVideoGenForm', () => {
// Substitutable prompt conditioner (#4081).
describe('text encoder selection', () => {
const renderWithH3 = async () => {
- const rendered = render({ models: [MLX, H3], status: { connected: true, defaultModel: MLX.id } });
+ const rendered = render({ models: [MLX, H3], modelContext: { defaultModel: MLX.id } });
await waitFor(() => expect(rendered.result.current.modelId).toBe(MLX.id));
act(() => rendered.result.current.handleModelChange(H3.id));
await waitFor(() => expect(rendered.result.current.modelId).toBe(H3.id));
@@ -398,7 +398,7 @@ describe('useVideoGenForm', () => {
// An empty list is what hides the picker; a model with substitutions
// exposes them straight off the server-decorated entry.
it('exposes only the selected model’s options', async () => {
- const { result } = render({ models: [MLX, H3], status: { connected: true, defaultModel: MLX.id } });
+ const { result } = render({ models: [MLX, H3], modelContext: { defaultModel: MLX.id } });
await waitFor(() => expect(result.current.modelId).toBe(MLX.id));
expect(result.current.textEncoderOptions).toEqual([]);
act(() => result.current.handleModelChange(H3.id));
@@ -465,7 +465,7 @@ describe('useVideoGenForm', () => {
it('preserves H3 native 32px-grid geometry in the submitted payload', async () => {
const { result } = render({
models: [MLX, H3],
- status: { connected: true, defaultModel: MLX.id },
+ modelContext: { defaultModel: MLX.id },
});
await waitFor(() => expect(result.current.modelId).toBe(MLX.id));
act(() => result.current.handleModelChange(H3.id));
@@ -482,7 +482,7 @@ describe('useVideoGenForm', () => {
it('offers MiniMax H3 image mode, chaining and a non-advisory last frame', async () => {
const { result } = render({
models: [MLX, H3],
- status: { connected: true, defaultModel: MLX.id },
+ modelContext: { defaultModel: MLX.id },
});
await waitFor(() => expect(result.current.modelId).toBe(MLX.id));
act(() => {
@@ -791,7 +791,7 @@ describe('useVideoGenForm', () => {
it('moves a fixed-profile remix to an editable model while preserving its restored controls', async () => {
const { result } = render({
models: [MLX, H3],
- status: { connected: true, defaultModel: MLX.id },
+ modelContext: { defaultModel: MLX.id },
});
await waitFor(() => expect(result.current.modelId).toBe(MLX.id));
@@ -818,7 +818,7 @@ describe('useVideoGenForm', () => {
it('uses the same editable-model fallback for a cross-page Remix handoff', async () => {
const { result } = render({
models: [MLX, H3],
- status: { connected: true, defaultModel: MLX.id },
+ modelContext: { defaultModel: MLX.id },
url: `/media/video?modelId=${H3.id}&numFrames=124&steps=9&guidanceScale=0`,
});
@@ -938,10 +938,10 @@ describe('useVideoGenForm — i2v reference mode (#4874)', () => {
lastFrameAnchored: true, supportedModes: RUNTIME_MODES,
};
const LTX25_MODELS = [LTX25, LTX2];
- const LTX25_STATUS = { connected: true, defaultModel: LTX25.id };
+ const LTX25_MODEL_CONTEXT = { defaultModel: LTX25.id };
const inImageMode = async (opts = {}) => {
- const { result } = render({ models: LTX25_MODELS, status: LTX25_STATUS, ...opts });
+ const { result } = render({ models: LTX25_MODELS, modelContext: LTX25_MODEL_CONTEXT, ...opts });
await act(async () => { result.current.handleModeChange('image'); });
return result;
};
@@ -982,7 +982,7 @@ describe('useVideoGenForm — i2v reference mode (#4874)', () => {
(props) => useVideoGenForm(props),
{
wrapper: ({ children }) => {children},
- initialProps: { models: [], status: LTX25_STATUS, availableLoras: [], grokEnabled: false },
+ initialProps: { models: [], modelContext: LTX25_MODEL_CONTEXT, availableLoras: [], grokEnabled: false },
},
);
await act(async () => {
@@ -990,7 +990,7 @@ describe('useVideoGenForm — i2v reference mode (#4874)', () => {
});
expect(result.current.i2vReferenceMode).toBe('inspire');
- rerender({ models: LTX25_MODELS, status: LTX25_STATUS, availableLoras: [], grokEnabled: false });
+ rerender({ models: LTX25_MODELS, modelContext: LTX25_MODEL_CONTEXT, availableLoras: [], grokEnabled: false });
await waitFor(() => expect(result.current.currentModel?.id).toBe(LTX25.id));
expect(result.current.i2vReferenceMode).toBe('inspire');
});
diff --git a/client/src/lib/README.md b/client/src/lib/README.md
index 2455ae4ef8..bbea8b4bce 100644
--- a/client/src/lib/README.md
+++ b/client/src/lib/README.md
@@ -80,7 +80,6 @@ grep -i "what you want to do" client/src/lib/README.md
| `videoFinish.js` | Finish-a-draft gate (#3696). `isReproducibleTextToVideo(record)` is true only for a single text-to-video render whose history record already carries everything a re-render needs — `renderInputsVersion` (the positive marker that its conditioning inventory is trustworthy, so legacy records degrade to not-finishable rather than reading as unconditioned), an empty `conditioning` array, a resolved seed, a real prompt, and no stitched/chained/upscaled provenance. `finishTargetForRecord(record, models)` additionally resolves the draft model's server-declared `finishModelId` against the models this install can actually run, returning the delivery model entry or `null`. `isDeliveryVideoModel(model, models)` is the other end of the same graph — true when some entry names `model` as its `finishModelId`, which is what makes a delivery render always decode on the full decoder (#5423). Mirrors `finishTargetForModel` / `isDeliveryVideoModel` in `server/lib/videoFinishProfiles.js`; the pair table itself stays server-side. |
| `videoGenParams.js` | Pure VideoGen param helpers: `FRAME_OPTIONS`/`FPS_OPTIONS`/`VIDEO_EDGE_BOUNDS`/`MAX_CHUNKS`/`CHUNK_OPTIONS`/`DEFAULT_CONTEXT_FRAMES`/`CONTEXT_FRAME_OPTIONS` constants, model-aware frame/fps/resolution-grid normalization, separate mute vs prompt-audio capability checks, `supportsContextWindow(model)` (does this runtime have an extend pipeline to feed a continuation window to — display mirror of `server/lib/videoContinuity.js`, pinned by `server/lib/videoContinuity.parity.test.js`), `videoModelMemoryGb()` (model memory footprint), `selectVideoMemoryProfile(model, systemMemoryGb)` + `VIDEO_MEMORY_RESERVE_GB` (#5420 — which declared weight-placement profile this machine can actually hold, out of the `memoryProfiles` the server decorates onto the entry; the floors ride on the model so only the reserve is mirrored, and an unmeasured `systemMemoryGb` returns a `null` usable figure rather than reading as a box that is too small), `computeFflfSafeFrames()` (FFLF/ltx2 pixel-budget back-solve, mirrors `server/services/videoGen/local.js`), and `isModelAllowedForMode()` (a2v uses the shared audio-runtime capability; IC remix remains LTX-only). Speed profiles (#4875) mirror the conditioner shape: `DEFAULT_SPEED_PROFILE_ID` (must equal `SPEED_PROFILE_DEFAULT_ID` in `server/lib/videoSpeedProfiles.js` — absence and `'quality'` are the same request), `isDefaultSpeedProfileId()` mirrors the server's absence-is-the-default rule, `speedProfilesForModel()` reads the server-decorated `speedProfiles` off the entry, `speedProfilesForMode(model, mode)` applies the SAME mode gate the server's `speedProfileDeclineReason` does (so a profile the server would decline is never offered, nor allowed to lock the dials), `normalizeSpeedProfileForModel()` snaps a selection onto what a just-switched model declares (mode-independent, so switching to fflf hides the picker without rewriting the choice), `speedProfileIdFromRecord()` reads one back out of a history entry / resumed job, `selectedSpeedProfile(id, model, mode)` resolves the profile actually driving the render — what the picker shows and what disables Steps+CFG — and `videoChainChunkModes({ model, mode, chaining, contextFrames, hasSourceImage })` derives the modes a CHAINED request’s chunks will run in (chunk 0 the request’s, chunks 1+ `extend` on a window-continuity chain or `image` on a frame hop), mirroring `generateChainedVideo`’s dispatch and `resolveContinuityStrategy`; `resolveContextFramesForDisplay()` is the absent/invalid→`DEFAULT_CONTEXT_FRAMES` half of `resolveContextFrames` that gate depends on. All pinned by `server/lib/videoSpeedProfiles.parity.test.js`. Also mirrors the IC-LoRA remix registry (`IC_LORA_MODES`/`IC_LORA_MODE_VALUES`/`isIcLoraMode()`/`icLoraSpecForMode()`/`icResolutionIssue()`) from `server/lib/icLoraWeights.js` so the form validates a reference render pre-submit — pinned by `server/lib/icLoraWeights.parity.test.js`. |
| `videoGenResolutions.js` | Shared resolution presets/default for video generation, model-specific preset/default resolvers (for native canvases such as MiniMax H3), and `snapAspectToImage()` to pick the closest-aspect preset for an I2V source. |
-| `videoGenStatusCache.js` | Session-scoped cache of the model-shaping half of `GET /api/video-gen/status` (`readCachedVideoGenStatus` / `writeCachedVideoGenStatus` / `VIDEO_GEN_STATUS_CACHE_KEY`), so the Video Gen Model picker paints from the previous answer instead of waiting on the python probe behind that route. Stores only the model list plus `defaultModel` / `systemMemoryGb` and hands them back marked `stale: true`; every python-health field is dropped rather than guarded, so a stored answer can never report connectivity. |
| `videoGenSubmission.js` | Builds the local, Grok, and federated video-generation request bodies from validated form state, including prompt envelopes and empty-value wire sentinels. |
| `videoReferenceModes.js` | Mirror of `server/lib/videoReferenceModes.js` (parity enforced by `server/lib/videoReferenceModes.mirror.test.js`) — the i2v reference-mode contract: `I2V_REFERENCE_MODES`, `I2V_REFERENCE_MODE_OPTIONS` (label + the promise sentence `AdvancedParamsPanel` and the source-frame note print), `runtimeSupportsI2vReferenceMode` (gates which options the picker offers), `resolveI2vReferenceStrength` (the effective strength the panel displays), and `i2vReferenceModeViolation` for pre-submit feedback. |
| `videoRenderPhase.js` | Video render phase → named progress step (#5872). `resolveVideoRenderSteps({ generating, phase, progressPct })` → `{ activeId, steps: [{ id, label, state: 'done'|'active'|'pending' }] }`, collapsing the runners' fine-grained `STAGE:` vocabulary (`load-transformer`, `encode-prompt`, `sampling`, `mux`, …) onto six steps a person can read — the queue's own `queued` is one of them, so a caller needs no separate flag. Family prefixes (`download-*`, `load-*`, `wan-*`, …) absorb markers a future runner adds; `videoRenderStepFor(phase)` returns `null` for a genuinely unknown one, never step 0. Consumed by `components/videoGen/RenderStatusCard.jsx`. |
diff --git a/client/src/lib/index.js b/client/src/lib/index.js
index 59de4bc9e0..d08cdcc4c1 100644
--- a/client/src/lib/index.js
+++ b/client/src/lib/index.js
@@ -61,7 +61,6 @@ export * from './slashdoCatalog.js';
export * from './videoFinish.js';
export * from './videoGenParams.js';
export * from './videoGenResolutions.js';
-export * from './videoGenStatusCache.js';
export * from './videoGenSubmission.js';
export * from './videoReferenceModes.js';
export * from './videoRenderPhase.js';
diff --git a/client/src/lib/videoGenStatusCache.js b/client/src/lib/videoGenStatusCache.js
deleted file mode 100644
index d8fd1c6211..0000000000
--- a/client/src/lib/videoGenStatusCache.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/**
- * Session-scoped cache of the model-shaping half of `GET /api/video-gen/status`.
- *
- * That probe shells out to python and rebuilds the hardware-aware model list on
- * every call, so a cold Video Gen page load leaves the Model picker with nothing
- * to render for a second or two. Caching lets the picker paint from the previous
- * answer while the live probe revalidates behind it.
- *
- * Only `CACHED_FIELDS` is stored, and the read hands it back marked
- * `stale: true`. Everything the payload says about python health — `connected`,
- * `reason`, `missingPackages`, `pythonPath`, `byovRuntimes`, `runtime` — is
- * deliberately dropped rather than guarded, because an interpreter the user just
- * fixed (or just broke) must never be reported from a stored answer, and a field
- * that isn't there can't be read by mistake.
- *
- * Session, not local: the model registry and the python environment both move
- * with an upgrade or an install, and a payload kept for weeks would outlive
- * both.
- */
-import { safeReadJsonSession, safeWriteJsonSession } from './safeStorage.js';
-
-// Bump the suffix when `CACHED_FIELDS` changes, so an older tab's entry is
-// ignored rather than half-read.
-export const VIDEO_GEN_STATUS_CACHE_KEY = 'portos.videoGenStatus.v1';
-
-// The model list plus the numbers that decide which model is selected for it.
-const CACHED_FIELDS = ['models', 'defaultModel', 'systemMemoryGb'];
-
-// Returns the cached fields with `stale: true`, or null when nothing usable is
-// stored. An entry with no `models` array is worthless here — painting the
-// picker is the whole point — so it reads as absent.
-export const readCachedVideoGenStatus = () => {
- const cached = safeReadJsonSession(VIDEO_GEN_STATUS_CACHE_KEY);
- if (!cached || typeof cached !== 'object' || !Array.isArray(cached.models)) return null;
- return { ...cached, stale: true };
-};
-
-// Store the cacheable slice of a freshly fetched payload.
-export const writeCachedVideoGenStatus = (status) => {
- if (!status || typeof status !== 'object' || !Array.isArray(status.models)) return;
- const slice = Object.fromEntries(CACHED_FIELDS.map((field) => [field, status[field]]));
- safeWriteJsonSession(VIDEO_GEN_STATUS_CACHE_KEY, slice);
-};
diff --git a/client/src/pages/VideoGen.composeWhileBusy.test.jsx b/client/src/pages/VideoGen.composeWhileBusy.test.jsx
index 415c4aecfb..c5d9f854db 100644
--- a/client/src/pages/VideoGen.composeWhileBusy.test.jsx
+++ b/client/src/pages/VideoGen.composeWhileBusy.test.jsx
@@ -7,6 +7,7 @@ import {
resetVideoGenMockState,
state,
videoGenModel,
+ videoGenModelContext,
videoGenStatus,
videoGenTermsGate,
} from '../test/videoGenPageMocks.jsx';
@@ -20,6 +21,7 @@ describe('VideoGen compose-while-busy', () => {
beforeEach(() => {
resetVideoGenMockState();
state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MODEL]));
+ state.getVideoGenModelContext.mockResolvedValue(videoGenModelContext([MODEL]));
state.modelStatuses = { [MODEL.id]: { id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 } };
state.generateVideo.mockReturnValue(new Promise(() => {}));
state.attach.mockReturnValue(new Promise(() => {}));
diff --git a/client/src/pages/VideoGen.federatedTarget.test.jsx b/client/src/pages/VideoGen.federatedTarget.test.jsx
index 69954f679f..110e32501f 100644
--- a/client/src/pages/VideoGen.federatedTarget.test.jsx
+++ b/client/src/pages/VideoGen.federatedTarget.test.jsx
@@ -7,6 +7,7 @@ import {
resetVideoGenMockState,
state,
videoGenModel,
+ videoGenModelContext,
videoGenStatus,
videoGenTermsGate,
} from '../test/videoGenPageMocks.jsx';
@@ -49,6 +50,7 @@ describe('VideoGen federated render target', () => {
resetVideoGenMockState();
state.peers = [PEER];
state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MODEL]));
+ state.getVideoGenModelContext.mockResolvedValue(videoGenModelContext([MODEL]));
state.modelStatuses = { [MODEL.id]: { id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 } };
state.generateVideo.mockReturnValue(new Promise(() => {}));
state.attach.mockReturnValue(new Promise(() => {}));
diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx
index 73fb67267b..d2cd41680a 100644
--- a/client/src/pages/VideoGen.jsx
+++ b/client/src/pages/VideoGen.jsx
@@ -25,9 +25,9 @@
*
* Form state, the URL-param prefill paths, the mode/backend transitions, and
* `buildGeneratePayload()` live in `useVideoGenForm` (issue #3291) — this page
- * owns the fetching (status/models/history/gallery), the SSE run pipeline, the
- * the rendering. The durable server queue owns queued work; each render target
- * drains through its own lane.
+ * owns the fetching (status/model-context/history/gallery), the SSE run
+ * pipeline, and the rendering. The durable server queue owns queued work;
+ * each render target drains through its own lane.
*
* "Add to queue" submits immediately to the durable server queue. That is
* important for mixed-target work: a Grok submission can start in its cloud
@@ -82,7 +82,7 @@ import { useVideoGenForm } from '../hooks/useVideoGenForm.js';
import { useFederatedMediaTarget } from '../hooks/useFederatedMediaTarget';
import RemoteMediaTargetPicker from '../components/federatedMedia/RemoteMediaTargetPicker';
import {
- getVideoGenStatus, generateVideo, cancelVideoGen,
+ getVideoGenStatus, getVideoGenModelContext, generateVideo, cancelVideoGen,
listVideoHistory, deleteVideoHistoryItem, setVideoHidden,
upscaleVideo,
patchSettingsSlice,
@@ -98,7 +98,6 @@ import ResolutionField from '../components/media/ResolutionField';
import { VIDEO_EDGE_BOUNDS, videoEdgeBoundsForModel, IC_LORA_MODES } from '../lib/videoGenParams.js';
import { finishTargetForRecord, isDeliveryVideoModel } from '../lib/videoFinish.js';
import { peerModelRequiresInput } from '../lib/federatedMediaReadiness.js';
-import { readCachedVideoGenStatus, writeCachedVideoGenStatus } from '../lib/videoGenStatusCache.js';
const MODES = [
{ id: 'text', label: 'Text', icon: Type, desc: 'Text-to-video' },
{ id: 'image', label: 'Image', icon: ImageIcon, desc: 'Image-to-video (start frame)' },
@@ -121,12 +120,16 @@ export default function VideoGen() {
refreshGrokEnabled();
};
- // Paint the model picker from the previous /status answer while the live
- // probe runs. The cached entry carries `stale: true` and holds nothing but
- // the model-shaping fields (see lib/videoGenStatusCache.js); connectivity UI
- // below gates on `statusFresh`.
- const [status, setStatus] = useState(readCachedVideoGenStatus);
+ // `/status` owns connectivity ONLY. It shells out to python on every call
+ // (~1-2s), so nothing the form needs to render may wait on it.
+ const [status, setStatus] = useState(null);
+ // The model list plus the numbers its auto-select reads, off the probe-free
+ // `/model-context`. Fetched alongside /status on mount, it lands first — so
+ // the Model picker paints on a cold load instead of holding a placeholder
+ // through the interpreter probe.
+ const [modelContext, setModelContext] = useState(null);
const [statusLoading, setStatusLoading] = useState(true);
+ const [modelContextLoading, setModelContextLoading] = useState(true);
// Grok Build CLI video backend (#2859 phase 2) — surfaced only when the
// user enabled Grok in Settings → Image Gen (one toggle covers image +
// video). 'local' keeps every existing flow untouched.
@@ -134,7 +137,7 @@ export default function VideoGen() {
// The jobId of the render this tab's Generate button currently owns —
// threaded into cancelVideoGen so cancellation is job-scoped.
const activeJobIdRef = useRef(null);
- const [models, setModels] = useState(() => status?.models || []);
+ const models = useMemo(() => modelContext?.models || [], [modelContext]);
const refreshGrokEnabled = useCallback(() => {
getSettings({ silent: true })
.then((sv) => setGrokEnabled(sv?.imageGen?.grok?.enabled === true))
@@ -189,7 +192,7 @@ export default function VideoGen() {
icStrength, setIcStrength, icSkipStage2, setIcSkipStage2,
applyRemix, applyFinish, applyResumedParams, buildGeneratePayload,
} = useVideoGenForm({
- models, status, availableLoras, grokEnabled,
+ models, modelContext, availableLoras, grokEnabled,
remoteSubmissionFields: remoteTarget.isRemote ? remoteTarget.submissionFields : null,
});
@@ -403,19 +406,28 @@ export default function VideoGen() {
const refreshStatus = useCallback(() => {
setStatusLoading(true);
getVideoGenStatus()
- .then((s) => {
- setStatus(s);
- setModels(s.models || []);
- writeCachedVideoGenStatus(s);
- })
+ .then(setStatus)
.catch(() => setStatus({ connected: false, reason: 'Status check failed' }))
.finally(() => setStatusLoading(false));
}, []);
+ // Kept separate from refreshStatus so the picker never inherits the python
+ // probe's latency. A failure leaves `modelContext` null, which reads exactly
+ // like "no model to offer" — the Model field takes itself away rather than
+ // holding a placeholder forever.
+ const refreshModelContext = useCallback(() => {
+ setModelContextLoading(true);
+ getVideoGenModelContext({ silent: true })
+ .then(setModelContext)
+ .catch(() => {})
+ .finally(() => setModelContextLoading(false));
+ }, []);
+
useEffect(() => {
refreshStatus();
+ refreshModelContext();
return () => eventSourceRef.current?.close();
- }, [refreshStatus, eventSourceRef]);
+ }, [refreshStatus, refreshModelContext, eventSourceRef]);
// SSE subscriber shared by the in-flight POST path and the mount-time
// resume path. `withToast: false` on resume suppresses the success/error
@@ -811,16 +823,13 @@ export default function VideoGen() {
// `byovRuntimeMissing` for those models. Without this, a user who installed
// ONLY a BYOV runtime via the modal would stay stuck behind a "not
// configured" error from the unrelated legacy probe.
- // A cached entry says nothing about python health, so the connectivity UI
- // waits for the live probe rather than reporting the interpreter state of
- // whenever the last visit happened.
- const statusFresh = !!status && !status.stale;
// The Model field renders as soon as there is anything to say — the list, or
- // the fact that it is still being probed. Only a finished probe that named no
- // model at all takes the field away.
+ // the fact that it is still being fetched. Only a finished fetch that named
+ // no model at all takes the field away. That fetch no longer waits on the
+ // python probe, so on a cold load the list itself is normally what lands.
const modelsLoading = models.length === 0;
- const modelFieldVisible = !modelsLoading || statusLoading;
- const notConnected = statusFresh && status.connected === false && !needsByovProbe;
+ const modelFieldVisible = !modelsLoading || modelContextLoading;
+ const notConnected = !!status && status.connected === false && !needsByovProbe;
// A federated render answers to the PEER’s readiness, not to this machine’s
// runtime gates — none of the local probes below describe the hardware it
@@ -834,7 +843,7 @@ export default function VideoGen() {
return (
- {statusFresh ? (
+ {status ? (
- {statusFresh && status.connected === false && (() => {
+ {status && status.connected === false && (() => {
const missingCount = status.missingPackages?.length || 0;
const hasPath = !!status.pythonPath;
return (
@@ -1392,7 +1401,7 @@ export default function VideoGen() {
backend={backend}
backendDisclosures={status?.backendDisclosures}
model={isGrok ? null : currentModel}
- systemMemoryGb={status?.systemMemoryGb}
+ systemMemoryGb={modelContext?.systemMemoryGb}
/>
{!isGrok && (
@@ -1563,10 +1572,11 @@ export default function VideoGen() {
onClose={() => setInstallModalOpen(false)}
onComplete={() => {
refreshByovStatus();
- // The capability probe is part of /video-gen/status's model
- // decoration. Refresh it after install/repair so H3's LoRA picker
- // and warning react without a manual page reload.
+ // The capability probe decorates the model list, and the install
+ // also moves python health. Refresh both after install/repair so
+ // H3's LoRA picker and warning react without a manual page reload.
refreshStatus();
+ refreshModelContext();
}}
/>
diff --git a/client/src/pages/VideoGen.modelLoading.test.jsx b/client/src/pages/VideoGen.modelLoading.test.jsx
index 9840dece63..a6636bbc46 100644
--- a/client/src/pages/VideoGen.modelLoading.test.jsx
+++ b/client/src/pages/VideoGen.modelLoading.test.jsx
@@ -7,90 +7,82 @@ import {
resetVideoGenMockState,
state,
videoGenModel,
+ videoGenModelContext,
videoGenStatus,
} from '../test/videoGenPageMocks.jsx';
/**
- * The Model picker paints before /status lands.
+ * The Model picker does not wait on /status.
*
- * /status shells out to python and rebuilds the hardware-aware model list on
- * every call, so the field used to be absent for a second or two and then pop
- * into the middle of the form. It now holds its place with a loading
- * placeholder, and a session-cached payload paints the real list immediately —
- * while every connectivity claim keeps waiting for the live probe.
+ * /status shells out to python on every call, so a cold load used to leave the
+ * field absent for a second or two and then pop it into the middle of the form.
+ * The list now comes from the probe-free /video-gen/model-context, which lands
+ * on its own — while every connectivity claim keeps waiting for the live probe.
+ * The loading placeholder remains for the window before either answers.
*/
const MODEL_ONE = videoGenModel('example-one');
const MODEL_TWO = videoGenModel('example-two');
const statusPayload = (overrides = {}) => videoGenStatus([MODEL_ONE, MODEL_TWO], overrides);
+const modelContextPayload = (overrides = {}) => videoGenModelContext([MODEL_ONE, MODEL_TWO], overrides);
await loadVideoGenPage();
-const { VIDEO_GEN_STATUS_CACHE_KEY } = await import('../lib/videoGenStatusCache.js');
-// A /status call the test settles by hand, so the page can be asserted mid-probe.
-const deferredStatus = () => {
+// A call the test settles by hand, so the page can be asserted mid-flight.
+const deferred = (mock) => {
let settle;
- state.getVideoGenStatus.mockReturnValue(new Promise((resolve) => { settle = resolve; }));
+ mock.mockReturnValue(new Promise((resolve) => { settle = resolve; }));
return async (payload) => { await act(async () => { settle(payload); }); };
};
+const deferredStatus = () => deferred(state.getVideoGenStatus);
+const deferredModelContext = () => deferred(state.getVideoGenModelContext);
-describe('VideoGen model picker while /status is in flight', () => {
+describe('VideoGen model picker vs the /status python probe', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
resetVideoGenMockState();
state.getVideoGenStatus.mockResolvedValue(statusPayload());
+ state.getVideoGenModelContext.mockResolvedValue(modelContextPayload());
state.attach.mockResolvedValue({ filename: 'example.mp4' });
});
it('keeps the Model field with a loading placeholder until the model list lands', async () => {
- const resolveStatus = deferredStatus();
+ const resolveModelContext = deferredModelContext();
await renderVideoGenPage();
const field = screen.getByLabelText('Model');
expect(field).toBeDisabled();
expect(field).toHaveTextContent('Loading models…');
- await resolveStatus(statusPayload());
+ await resolveModelContext(modelContextPayload());
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(MODEL_ONE.id));
expect(screen.getByLabelText('Model')).toBeEnabled();
});
- it('paints the cached model list on the next load instead of waiting for the probe', async () => {
- const first = await renderVideoGenPage();
- await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(MODEL_ONE.id));
- // Only the model-shaping slice is persisted — python health never is.
- expect(Object.keys(JSON.parse(sessionStorage.getItem(VIDEO_GEN_STATUS_CACHE_KEY))).sort())
- .toEqual(['defaultModel', 'models', 'systemMemoryGb']);
- first.unmount();
-
+ it('paints the model list on a cold load while every python claim waits', async () => {
+ // No prior visit and no priming — the regression this pins is the Model
+ // field sitting on its placeholder for the whole /status round trip, and
+ // the converse: nothing may report the interpreter before it answers.
const resolveStatus = deferredStatus();
await renderVideoGenPage();
const field = screen.getByLabelText('Model');
expect(field).toBeEnabled();
expect(field).toHaveValue(MODEL_ONE.id);
- await resolveStatus(statusPayload());
- });
-
- it('never reports python health from a cached entry', async () => {
- // A hand-written entry carrying a FAILED probe — the belt to the
- // projection's braces. The model list may come from storage; the diagnosis
- // may not, because the interpreter can have been fixed since.
- sessionStorage.setItem(VIDEO_GEN_STATUS_CACHE_KEY, JSON.stringify(statusPayload({
- connected: false,
- reason: 'Python probe failed',
- missingPackages: ['torch'],
- })));
- const resolveStatus = deferredStatus();
- await renderVideoGenPage();
-
- expect(screen.getByLabelText('Model')).toHaveValue(MODEL_ONE.id);
expect(screen.getByText('Checking…')).toBeInTheDocument();
expect(screen.queryByText(/Install missing Python packages/)).toBeNull();
- expect(screen.queryByText(/Python probe failed/)).toBeNull();
await resolveStatus(statusPayload({ connected: true, pythonVersion: '3.12.1' }));
await waitFor(() => expect(screen.getByText('Python 3.12.1')).toBeInTheDocument());
});
+
+ it('takes the Model field away when the context fetch names no model at all', async () => {
+ // A failed /model-context leaves nothing to offer. The field must not hold
+ // its placeholder forever — the rest of the form closes over the gap.
+ state.getVideoGenModelContext.mockRejectedValue(new Error('offline'));
+ await renderVideoGenPage();
+
+ await waitFor(() => expect(screen.queryByLabelText('Model')).toBeNull());
+ });
});
diff --git a/client/src/pages/VideoGen.terms.test.jsx b/client/src/pages/VideoGen.terms.test.jsx
index 76c5d4ece8..e3fc1f78d6 100644
--- a/client/src/pages/VideoGen.terms.test.jsx
+++ b/client/src/pages/VideoGen.terms.test.jsx
@@ -7,6 +7,7 @@ import {
resetVideoGenMockState,
state,
videoGenModel,
+ videoGenModelContext,
videoGenStatus,
videoGenTermsGate,
} from '../test/videoGenPageMocks.jsx';
@@ -33,6 +34,7 @@ describe('VideoGen MiniMax H3 orchestration', () => {
state.generateVideo.mockResolvedValue({ jobId: 'job-1' });
state.repair.mockResolvedValue({ ok: true });
state.getVideoGenStatus.mockResolvedValue(videoGenStatus([H3_ONE, H3_TWO]));
+ state.getVideoGenModelContext.mockResolvedValue(videoGenModelContext([H3_ONE, H3_TWO]));
state.attach.mockImplementation(async (_jobId, handlers) => {
handlers.onComplete({ result: { filename: 'example.mp4' } });
return { filename: 'example.mp4' };
@@ -98,10 +100,14 @@ describe('VideoGen MiniMax H3 orchestration', () => {
it('refreshes the model capability payload after runtime setup completes', async () => {
await renderVideoGenPage();
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(H3_ONE.id));
- const before = state.getVideoGenStatus.mock.calls.length;
+ const beforeStatus = state.getVideoGenStatus.mock.calls.length;
+ const beforeContext = state.getVideoGenModelContext.mock.calls.length;
await act(async () => { await state.runtimeInstallComplete(); });
- await waitFor(() => expect(state.getVideoGenStatus).toHaveBeenCalledTimes(before + 1));
+ // The install moves BOTH halves: the hardware decoration on the model list
+ // and the python health the connectivity banner reads.
+ await waitFor(() => expect(state.getVideoGenModelContext).toHaveBeenCalledTimes(beforeContext + 1));
+ expect(state.getVideoGenStatus).toHaveBeenCalledTimes(beforeStatus + 1);
});
});
diff --git a/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx b/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx
index b22a213f35..78ab72171d 100644
--- a/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx
+++ b/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx
@@ -17,6 +17,7 @@ import {
resetVideoGenMockState,
state,
videoGenModel,
+ videoGenModelContext,
videoGenStatus,
videoGenTermsGate,
} from '../test/videoGenPageMocks.jsx';
@@ -44,6 +45,7 @@ describe('VideoGen substitute text-encoder auto-download', () => {
beforeEach(() => {
resetVideoGenMockState();
state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MODEL]));
+ state.getVideoGenModelContext.mockResolvedValue(videoGenModelContext([MODEL]));
// The substitute is never resident: what these cases pin down is the
// request, and a cached encoder would short-circuit it.
state.getModelStatus = (id) => (String(id).startsWith('__text_encoder_option__:')
diff --git a/client/src/services/apiImageVideo.js b/client/src/services/apiImageVideo.js
index 6dd2bc59ba..6690b48d45 100644
--- a/client/src/services/apiImageVideo.js
+++ b/client/src/services/apiImageVideo.js
@@ -165,6 +165,13 @@ export const getVideoGenStatus = (options = {}) => request('/video-gen/status',
export const listVideoModels = ({ includeUnavailable = false, ...options } = {}) =>
request('/video-gen/models', options)
.then((models) => filterHardwareCompatibleModels(models, { includeUnavailable }));
+// `{ models, defaultModel, systemMemoryGb, fflfLtx2PixelBudget }` — the model
+// list plus the numbers its auto-select reads, with no python probe behind it.
+// getVideoGenStatus() returns the same fields, but only after shelling out to
+// the interpreter; fetch this alongside it so the Model picker paints first.
+export const getVideoGenModelContext = (options = {}) =>
+ request('/video-gen/model-context', options)
+ .then((ctx) => ({ ...ctx, models: filterHardwareCompatibleModels(ctx?.models) }));
// `{ models: [...], textEncoder: { repo, cached, sizeBytes } }`. Same shape
// contract as the image variant + a text-encoder block since the active
// encoder is a separate multi-GB pull.
diff --git a/client/src/test/videoGenPageMocks.jsx b/client/src/test/videoGenPageMocks.jsx
index 937fe98776..cf09048e3d 100644
--- a/client/src/test/videoGenPageMocks.jsx
+++ b/client/src/test/videoGenPageMocks.jsx
@@ -42,6 +42,11 @@ export const state = {
peers: [],
/** `getVideoGenStatus`; a spy so a suite can defer it, count calls or vary the payload. */
getVideoGenStatus: vi.fn(),
+ /**
+ * `getVideoGenModelContext`; the probe-free half the Model picker reads (#5835).
+ * A spy for the same reasons — a suite defers it to assert the page mid-flight.
+ */
+ getVideoGenModelContext: vi.fn(),
generateVideo: vi.fn(),
attach: vi.fn(),
eventSourceRef: { current: null },
@@ -61,7 +66,7 @@ export const state = {
universeStyle: DEFAULT_UNIVERSE_STYLE,
};
-const SPIES = ['getVideoGenStatus', 'generateVideo', 'attach', 'start', 'startWhenIdle', 'repair', 'cancel', 'refresh'];
+const SPIES = ['getVideoGenStatus', 'getVideoGenModelContext', 'generateVideo', 'attach', 'start', 'startWhenIdle', 'repair', 'cancel', 'refresh'];
/** Restore every documented default, including fresh spies. Call it first in `beforeEach`. */
export function resetVideoGenMockState() {
@@ -105,6 +110,19 @@ export const videoGenTermsGate = (termsId) => ({
licenseUrl: 'https://example.com/license',
});
+/**
+ * A `/model-context` payload over `models` — the model list plus the three
+ * shaping numbers the picker's auto-select reads, with no python probe behind
+ * them. The first model is the default unless overridden.
+ */
+export const videoGenModelContext = (models, overrides = {}) => ({
+ models,
+ defaultModel: models[0]?.id ?? null,
+ systemMemoryGb: 128,
+ fflfLtx2PixelBudget: 8_000_000,
+ ...overrides,
+});
+
/** A `/status` payload over `models`; the first model is the default unless overridden. */
export const videoGenStatus = (models, overrides = {}) => ({
connected: true,
@@ -122,6 +140,7 @@ vi.mock('../services/api', () => ({
// a media provider the picker renders nothing and every local path is unchanged.
getInstances: vi.fn(async () => ({ peers: state.peers })),
getVideoGenStatus: (...args) => state.getVideoGenStatus(...args),
+ getVideoGenModelContext: (...args) => state.getVideoGenModelContext(...args),
generateVideo: (...args) => state.generateVideo(...args),
cancelVideoGen: vi.fn(async () => ({})),
listVideoHistory: vi.fn(async () => []),
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index 9227c19c57..02ca6e6af8 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -16454,6 +16454,14 @@
"server/routes/videoGen.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/video-gen/model-context",
+ "mountPath": "/api/video-gen",
+ "sources": [
+ "server/routes/videoGen.js"
+ ]
+ },
{
"method": "GET",
"path": "/api/video-gen/model-terms",
@@ -17497,8 +17505,8 @@
],
"stats": {
"mounts": 147,
- "operations": 2167,
- "declarations": 2175,
+ "operations": 2168,
+ "declarations": 2176,
"sourceFiles": 230
}
}
diff --git a/server/routes/videoGen.js b/server/routes/videoGen.js
index 7e4e5cfc88..659dea1dac 100644
--- a/server/routes/videoGen.js
+++ b/server/routes/videoGen.js
@@ -88,6 +88,33 @@ const hardwareAwareVideoModels = async () => {
};
};
+// The model list plus the three numbers that decide which entry the picker
+// auto-selects. Deliberately free of any python probe: /status shells out to
+// the interpreter on every call (~1-2s) and the Model field used to wait on it,
+// so `/model-context` serves the same fields off the registry and the cached
+// hardware probe alone. /status keeps returning them for its other readers —
+// this is the single builder both routes share, so the two can't drift.
+const videoModelContext = async () => {
+ const { capabilities, models } = await hardwareAwareVideoModels();
+ return {
+ // Each entry carries its optional `disclosure` block (provenance, weights/
+ // runtime licenses, pinned-snapshot download size) straight off the
+ // registry — absent for custom models, which the UI renders as Unknown.
+ models,
+ defaultModel: defaultVideoModelId(capabilities),
+ // Total system memory in GB — the client uses this to auto-select the
+ // highest-memory mode-compatible model that fits on this machine.
+ // Rounded to nearest GB; sub-GB precision isn't useful for the
+ // model-size comparison and reads more cleanly in the UI.
+ systemMemoryGb: Math.round(os.totalmem() / 1024 ** 3),
+ // Effective FFLF/ltx2 stage-2 pixel-frame budget (honors
+ // FFLF_LTX2_PIXEL_BUDGET). The multi-keyframe picker mirrors the
+ // back-solve so it can reject out-of-budget keyframe indices before
+ // submit instead of letting the worker 400 mid-render.
+ fflfLtx2PixelBudget: resolveFflfLtx2PixelBudget(),
+ };
+};
+
// M4A files are stored in an MP4 container. Browsers and OS file pickers
// label them inconsistently: Safari uses `video/mp4`, Chrome/Firefox use
// `audio/mp4`, and some platforms emit `audio/x-m4a` or `audio/aac`.
@@ -400,18 +427,17 @@ router.get('/status', asyncHandler(async (_req, res) => {
const s = await getSettings();
const py = s.imageGen?.local?.pythonPath || null;
const { connected, reason, missing, pythonVersion } = await resolveLocalPythonHealth(py);
- const { capabilities, models } = await hardwareAwareVideoModels();
res.json({
connected,
pythonPath: py,
pythonVersion: pythonVersion || null,
reason,
missingPackages: missing,
- // Each entry carries its optional `disclosure` block (provenance, weights/
- // runtime licenses, pinned-snapshot download size) straight off the
- // registry — absent for custom models, which the UI renders as Unknown.
- models,
- defaultModel: defaultVideoModelId(capabilities),
+ // `models` / `defaultModel` / `systemMemoryGb` / `fflfLtx2PixelBudget` —
+ // kept here for the callers that already read them off /status. The Video
+ // Gen page takes them from GET /model-context instead, so its Model picker
+ // never waits on the python probe above.
+ ...(await videoModelContext()),
// Server-owned execution + policy scope per render backend (#3674). The
// client renders these strings verbatim so the wording can't drift between
// the two surfaces.
@@ -419,16 +445,6 @@ router.get('/status', asyncHandler(async (_req, res) => {
// Authoritative list of bring-your-own-venv runtimes — lets the client
// gate the install-banner probe without hardcoding the same Set.
byovRuntimes: Object.keys(BYOV_RUNTIME_INFO),
- // Total system memory in GB — the client uses this to auto-select the
- // highest-memory mode-compatible model that fits on this machine.
- // Rounded to nearest GB; sub-GB precision isn't useful for the
- // model-size comparison and reads more cleanly in the UI.
- systemMemoryGb: Math.round(os.totalmem() / 1024 ** 3),
- // Effective FFLF/ltx2 stage-2 pixel-frame budget (honors
- // FFLF_LTX2_PIXEL_BUDGET). The multi-keyframe picker mirrors the
- // back-solve so it can reject out-of-budget keyframe indices before
- // submit instead of letting the worker 400 mid-render.
- fflfLtx2PixelBudget: resolveFflfLtx2PixelBudget(),
// Runtime fingerprint — host chip/os + resolved ltx/mlx/torch versions per
// installed BYOV runtime — so the UI can show the exact numerical stack and
// bug reports for garbled/"mosaic" output carry the version info that makes
@@ -530,6 +546,14 @@ router.get('/models', asyncHandler(async (_req, res) => {
res.json(models);
}));
+// Everything the Model picker needs to render AND auto-select, with no python
+// probe in the way. A sibling route rather than a wrapper around /models so the
+// bare-array shape that route has always returned stays intact for its existing
+// callers (and for an older client talking to a newer server).
+router.get('/model-context', asyncHandler(async (_req, res) => {
+ res.json(await videoModelContext());
+}));
+
router.get('/models/status', asyncHandler(async (_req, res) => {
// Text encoder is shared across all video renders. A registry entry with
// `localPath` (e.g. an LM Studio install) trumps the HF cache check, so
diff --git a/server/routes/videoGen.test.js b/server/routes/videoGen.test.js
index 457c94ceb9..d4dba66796 100644
--- a/server/routes/videoGen.test.js
+++ b/server/routes/videoGen.test.js
@@ -567,6 +567,35 @@ describe('videoGen routes', () => {
});
});
+ describe('GET /model-context', () => {
+ it('serves the model list and its auto-select numbers without probing python', async () => {
+ const { checkPackages } = await import('../lib/pythonSetup.js');
+ checkPackages.mockClear();
+
+ const r = await request(app).get('/api/video-gen/model-context');
+ expect(r.status).toBe(200);
+ // The picker's whole input set, in one probe-free answer.
+ expect(r.body.models.map((m) => m.id)).toEqual(['ltx2_unified']);
+ expect(r.body.defaultModel).toBe('ltx2_unified');
+ expect(typeof r.body.systemMemoryGb).toBe('number');
+ expect(r.body.systemMemoryGb).toBeGreaterThan(0);
+ expect(typeof r.body.fflfLtx2PixelBudget).toBe('number');
+ expect(r.body.fflfLtx2PixelBudget).toBeGreaterThan(0);
+ // The point of the route: no interpreter subprocess in the request path.
+ expect(checkPackages).not.toHaveBeenCalled();
+ });
+
+ it('agrees with /status on every field the two share', async () => {
+ const [context, status] = await Promise.all([
+ request(app).get('/api/video-gen/model-context'),
+ request(app).get('/api/video-gen/status'),
+ ]);
+ for (const field of ['models', 'defaultModel', 'systemMemoryGb', 'fflfLtx2PixelBudget']) {
+ expect(status.body[field]).toEqual(context.body[field]);
+ }
+ });
+ });
+
describe('GET /models/:modelId/download — restricted terms', () => {
const h3CheckpointFiles = ['LICENSE', 'FL2VA/model_index.json', 'FL2VA/video_vae/source/model.safetensors'];
const h3 = {