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 = {