Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/next/fixed-agent-9e1d76b8.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Keep video remix controls editable
87 changes: 81 additions & 6 deletions client/src/hooks/useVideoGenForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
*
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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'));
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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('');
};
Expand Down Expand Up @@ -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,
Expand Down
53 changes: 50 additions & 3 deletions client/src/hooks/useVideoGenForm.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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'); });
Expand Down
11 changes: 10 additions & 1 deletion client/src/pages/VideoGen.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1036,6 +1036,15 @@ export default function VideoGen() {
value={modelId}
onChange={(e) => handleModelChange(e.target.value)}
/>
{remixModelFallback && (
<p className="mt-1 text-[11px] text-port-accent leading-snug" role="status">
{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.
</p>
)}
{modelStatus && (
<ModelDownloadBadge
status={modelStatus}
Expand Down