diff --git a/.changelog/next/fixed-agent-9e1d76b8.md b/.changelog/next/fixed-agent-9e1d76b8.md new file mode 100644 index 0000000000..c8690ca2b9 --- /dev/null +++ b/.changelog/next/fixed-agent-9e1d76b8.md @@ -0,0 +1 @@ +- Keep video remix controls editable diff --git a/client/src/hooks/useVideoGenForm.js b/client/src/hooks/useVideoGenForm.js index beff040a19..0027c140e9 100644 --- a/client/src/hooks/useVideoGenForm.js +++ b/client/src/hooks/useVideoGenForm.js @@ -21,6 +21,23 @@ import { textEncoderIdFromRecord, } from '../lib/videoGenParams.js'; +// A Remix is an editing starting point. A fixed sampler profile (for example, +// MiniMax H3) cannot honor a negative prompt, steps, or CFG override, so +// restoring it as the active model leaves the very values Remix just loaded +// trapped behind disabled inputs. Prefer an editable text-to-video model in +// that case; the source model stays available in the picker for a faithful +// re-render. +const hasEditableRemixControls = (model) => ( + model?.samplerLocked !== true && model?.supportsNegativePrompt !== false +); + +const editableRemixModel = (models, defaultModelId) => { + const candidates = models.filter((model) => ( + isModelAllowedForMode(model, 'text') && hasEditableRemixControls(model) + )); + return candidates.find((model) => model.id === defaultModelId) || candidates[0] || null; +}; + /** * VideoGen form state + request shaping (issue #3291). * @@ -58,6 +75,14 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) const [negativePrompt, setNegativePrompt] = useState(incomingNegativePrompt || ''); const [stylePreset, setStylePreset] = useState(null); const [modelId, setModelId] = useState(''); + // Source model awaiting the catalog load. Both cross-page and in-page Remix + // arrive before/after models independently, so this state gives them one + // reconciliation path once the model capabilities are known. Recorded + // model-specific conditioning (a substitute text encoder or LoRAs) cannot + // survive a model swap, so it explicitly keeps that source model selected + // for a faithful remix. + const [remixSourceModel, setRemixSourceModel] = useState(null); + const [remixModelFallback, setRemixModelFallback] = useState(null); const [width, setWidth] = useState(768); const [height, setHeight] = useState(512); // Set once the size has been chosen deliberately — the user picking a preset, @@ -200,7 +225,10 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) const present = remixGateKeys.filter((k) => searchParams.get(k) != null); if (present.length === 0) return; const get = (k) => searchParams.get(k); - if (get('modelId')) setModelId(get('modelId')); + if (get('modelId')) { + setModelId(get('modelId')); + setRemixSourceModel({ id: get('modelId'), preserveConditioning: false }); + } const nf = Number(get('numFrames')); if (Number.isFinite(nf) && nf > 0) setNumFrames(nf); const f = Number(get('fps')); @@ -396,6 +424,32 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) const currentModel = models.find((m) => m.id === modelId); + // 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 + // 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. + useEffect(() => { + 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); + if (target) { + setModelId(target.id); + setRemixModelFallback({ + sourceName: source.name || source.id, + targetName: target.name || target.id, + samplerLocked: source.samplerLocked === true, + negativePromptUnsupported: source.supportsNegativePrompt === false, + }); + } + } else { + setRemixModelFallback(null); + } + setRemixSourceModel(null); + }, [remixSourceModel, models, status?.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 // is an off-distribution wiring-test size, while its trained 16:9 canvas is @@ -559,7 +613,11 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) // Switching model drops the sampler overrides — steps/guidanceScale are // per-model defaults, and carrying one model's numbers onto another is // usually wrong. - const handleModelChange = applyModelSelection; + const handleModelChange = (nextId) => { + setRemixSourceModel(null); + setRemixModelFallback(null); + applyModelSelection(nextId); + }; const dropSourceImageParam = () => { if (!incomingSourceImage) return; @@ -843,12 +901,16 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) }; // Remix a prior render: hand all its params back into the form so the user - // can iterate (tweak the prompt, swap seeds, etc.) without re-typing. + // can iterate (tweak the prompt, sampler, seed, etc.) without re-typing. + // Fixed-profile sources reconcile to an editable compatible model above; + // otherwise the original model remains selected for a faithful re-render. // Mirrors ImageGen.handleRemix — in-page state set so the form jumps to // the new values without a navigation. The `item` is the raw video sidecar // (not the normalized MediaPreview shape). - const applyRemix = (item) => { + const applyRemix = (item, { preferEditableModel = true } = {}) => { if (!item) return; + setRemixSourceModel(null); + setRemixModelFallback(null); setStylePreset(null); // prompt: always set explicitly. Legacy entries can be missing `prompt` // (normalizeVideo surfaces them as '(no prompt)') — clear the form instead @@ -866,7 +928,17 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) // (race on initial mount), this avoids dropping the value silently — the // post-load validation effect (`Validate modelId once models are loaded`) // will fall back to defaultModel if the id doesn't end up in the catalog. - if (item.modelId) setModelId(item.modelId); + if (item.modelId) { + setModelId(item.modelId); + setRemixSourceModel(preferEditableModel + ? { + id: item.modelId, + preserveConditioning: !!item.textEncoderId + || (Array.isArray(item.loraFilenames) && item.loraFilenames.length > 0), + } + : null); + if (!preferEditableModel) setRemixModelFallback(null); + } if (item.width) { setWidth(item.width); sizeManuallySetRef.current = true; } if (item.height) { setHeight(item.height); sizeManuallySetRef.current = true; } if (item.numFrames) setNumFrames(item.numFrames); @@ -962,12 +1034,14 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) // press Generate, so no provider call fires off a gallery click. const applyFinish = (item, deliveryModelId) => { if (!item || !deliveryModelId) return; - applyRemix(item); + applyRemix(item, { preferEditableModel: false }); // Force the local backend: the delivery model is a local registry entry, so // finishing while the form happens to be on the Grok backend would leave // `isGrok` true and submit a Grok payload that ignores the model entirely. setBackend('local'); setModelId(deliveryModelId); + setRemixSourceModel(null); + setRemixModelFallback(null); setSteps(''); setGuidanceScale(''); }; @@ -1257,6 +1331,7 @@ export function useVideoGenForm({ models, status, availableLoras, grokEnabled }) prompt, setPrompt, negativePrompt, setNegativePrompt, stylePreset, setStylePreset, + remixModelFallback, // Model modelId, handleModelChange, currentModel, visibleModels, loraFamily, videoLoras, loraUnavailableHint, diff --git a/client/src/hooks/useVideoGenForm.test.jsx b/client/src/hooks/useVideoGenForm.test.jsx index b2b4c79457..f3ef8d7c63 100644 --- a/client/src/hooks/useVideoGenForm.test.jsx +++ b/client/src/hooks/useVideoGenForm.test.jsx @@ -34,6 +34,7 @@ const H3 = { { label: '768x1344', w: 768, h: 1344 }, ], supportsNegativePrompt: false, supportsTiling: false, supportsDisableAudio: false, + samplerLocked: true, // Server-decorated per model (videoGen/local.js#decorateVideoModel) — the // client never derives this from a runtime name, so the fixture carries it // exactly as the /models payload does. @@ -352,15 +353,16 @@ describe('useVideoGenForm', () => { // of a stock render must CLEAR a leftover selection rather than carry it // into a render the user asked to reproduce faithfully. it.each([ - ['restores a recorded substitute', { textEncoderId: 'heretic-bf16' }, 'heretic-bf16'], - ['clears the selection when the record has none', {}, 'stock'], - ])('%s on remix', async (_label, extra, expected) => { + ['restores a recorded substitute', { textEncoderId: 'heretic-bf16' }, 'heretic-bf16', H3.id], + ['clears the selection when the record has none', {}, 'stock', MLX.id], + ])('%s on remix', async (_label, extra, expected, expectedModelId) => { const { result } = await renderWithH3(); act(() => result.current.setTextEncoderId('heretic-bf16')); await waitFor(() => expect(result.current.textEncoderId).toBe('heretic-bf16')); act(() => result.current.applyRemix({ modelId: H3.id, prompt: 'a fox', ...extra })); await waitFor(() => expect(result.current.textEncoderId).toBe(expected)); + expect(result.current.modelId).toBe(expectedModelId); }); it('restores a resumed in-flight render’s conditioner', async () => { @@ -685,6 +687,51 @@ 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 }, + }); + await waitFor(() => expect(result.current.modelId).toBe(MLX.id)); + + act(() => result.current.applyRemix({ + modelId: H3.id, + prompt: 'a fox in rain', + negativePrompt: 'blurry', + steps: 9, + guidanceScale: 0, + })); + + await waitFor(() => expect(result.current.remixModelFallback).toEqual({ + sourceName: H3.name, + targetName: MLX.name, + samplerLocked: true, + negativePromptUnsupported: true, + })); + expect(result.current.modelId).toBe(MLX.id); + expect(result.current.negativePrompt).toBe('blurry'); + expect(result.current.steps).toBe('9'); + expect(result.current.guidanceScale).toBe('0'); + }); + + 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 }, + url: `/media/video?modelId=${H3.id}&numFrames=124&steps=9&guidanceScale=0`, + }); + + await waitFor(() => expect(result.current.remixModelFallback).toEqual({ + sourceName: H3.name, + targetName: MLX.name, + samplerLocked: true, + negativePromptUnsupported: true, + })); + expect(result.current.modelId).toBe(MLX.id); + expect(result.current.steps).toBe('9'); + expect(result.current.guidanceScale).toBe('0'); + }); + it('applyRemix clears fields the record does not carry rather than leaving stale ones', async () => { const { result } = render(); act(() => { result.current.setSteps('40'); result.current.setGuidanceScale('7'); result.current.setNegativePrompt('old neg'); }); diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx index e9f6527251..82cba2172d 100644 --- a/client/src/pages/VideoGen.jsx +++ b/client/src/pages/VideoGen.jsx @@ -142,7 +142,7 @@ export default function VideoGen() { const { backend, isGrok, handleBackendChange, grokDuration, setGrokDuration, mode, handleModeChange, - prompt, setPrompt, negativePrompt, setNegativePrompt, stylePreset, setStylePreset, + prompt, setPrompt, negativePrompt, setNegativePrompt, stylePreset, setStylePreset, remixModelFallback, modelId, handleModelChange, currentModel, visibleModels, loraFamily, videoLoras, loraUnavailableHint, selectedLoras, setSelectedLoras, @@ -1036,6 +1036,15 @@ export default function VideoGen() { value={modelId} onChange={(e) => handleModelChange(e.target.value)} /> + {remixModelFallback && ( +

+ {remixModelFallback.sourceName} {remixModelFallback.samplerLocked && remixModelFallback.negativePromptUnsupported + ? 'has fixed sampler controls and no negative prompt' + : remixModelFallback.samplerLocked + ? 'has fixed sampler controls' + : 'does not support a negative prompt'}. This remix is using {remixModelFallback.targetName} so its negative prompt, Steps, and CFG Scale remain editable. +

+ )} {modelStatus && (