From 648682285f4971ba6af8cb263ef6102d5e84774c Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Fri, 4 Sep 2026 08:07:06 +0000 Subject: [PATCH 1/2] feat: add Reactor.inc fast-h3 video generation API provider to MediaGen and FableLoom (#6214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds server/services/videoGen/reactor.js — a token-minting/submit/poll/download provider mirroring videoGen/fal.js's queue REST contract — as a first-class mediaJobQueue cloud-lane video backend with native clip-to-clip chaining via continue_from_clip_id. A new GET /api/video-gen/reactor/token route mints a short-lived, scoped session JWT from REACTOR_API_KEY server-side (never exposed to the client), re-minted automatically if a render's poll loop outlives the session. Wires 'reactor' through the VIDEO_GEN_MODE alphabet, the video pin ladder, prepareVideoGenParams' backend short-circuit, submitJob's dispatch, and the VideoGen page's backend switch (clip id / clip length / seed fields), plus a Settings > Image Gen field for the REACTOR_API_KEY / API key. --- .env.example | 9 + .../fableloom/LoomSettingsDrawer.jsx | 1 + .../src/components/settings/ImageGenTab.jsx | 25 +- .../components/settings/ImageGenTab.test.jsx | 2 +- client/src/hooks/useVideoGenFieldState.js | 6 + client/src/hooks/useVideoGenForm.js | 29 +- client/src/lib/imageGenModes.js | 3 +- client/src/lib/videoGenSubmission.js | 23 +- client/src/pages/VideoGen.jsx | 86 +++-- server/lib/generationModes.js | 10 +- server/lib/generationModes.test.js | 10 +- server/lib/validation.js | 5 + server/routes/videoGen.js | 40 ++- server/services/mediaJobQueue/index.js | 1 + server/services/videoGen/modes.js | 5 + server/services/videoGen/modes.test.js | 14 +- server/services/videoGen/prepareParams.js | 29 +- server/services/videoGen/reactor.js | 312 ++++++++++++++++++ server/services/videoGen/reactor.test.js | 280 ++++++++++++++++ server/services/videoGen/submitJob.js | 31 +- 20 files changed, 875 insertions(+), 46 deletions(-) create mode 100644 server/services/videoGen/reactor.js create mode 100644 server/services/videoGen/reactor.test.js diff --git a/.env.example b/.env.example index 9f2ba7e57d..421c41b9f9 100644 --- a/.env.example +++ b/.env.example @@ -115,6 +115,11 @@ PGPASSWORD=portos # jobs before it starts, so this bounds the whole submit→poll→download run. # FAL_VIDEO_TIMEOUT_MS=1200000 +# Maximum reactor.inc fast-h3 video-generation runtime in milliseconds +# (videoGen/reactor.js; default: 1200000 / 20 minutes) — same reasoning as +# FAL_VIDEO_TIMEOUT_MS above. +# REACTOR_VIDEO_TIMEOUT_MS=1200000 + # Maximum Antigravity image-generation runtime in milliseconds (default: 1200000 / 20 minutes) # AGY_IMAGEGEN_TIMEOUT_MS=1200000 @@ -159,6 +164,10 @@ PGPASSWORD=portos # Settings > Video Gen's stored key wins when both are set. # FAL_KEY=your_fal_api_key_here +# reactor.inc API key for the reactor.inc fast-h3 video backend (Video Gen +# page, FableLoom). Settings > Video Gen's stored key wins when both are set. +# REACTOR_API_KEY=your_reactor_api_key_here + # Absolute path to the bash binary used to run PortOS's bundled *.sh scripts # (e.g. scripts/db.sh). Auto-detected if unset; on Windows it prefers Git Bash, # because a bare `bash` often resolves to WSL, which can't see drive paths. diff --git a/client/src/components/fableloom/LoomSettingsDrawer.jsx b/client/src/components/fableloom/LoomSettingsDrawer.jsx index 69cdf3d544..196b951a2f 100644 --- a/client/src/components/fableloom/LoomSettingsDrawer.jsx +++ b/client/src/components/fableloom/LoomSettingsDrawer.jsx @@ -52,6 +52,7 @@ const VIDEO_RENDER_BACKENDS = [ { id: 'local', label: 'Local', icon: Cpu }, { id: 'grok', label: 'Grok', icon: Cloud }, { id: 'fal', label: 'fal.ai', icon: Cloud }, + { id: 'reactor', label: 'Reactor.inc', icon: Cloud }, ]; const modelOptions = (models, selected) => { diff --git a/client/src/components/settings/ImageGenTab.jsx b/client/src/components/settings/ImageGenTab.jsx index 7bdb525f55..96133e244f 100644 --- a/client/src/components/settings/ImageGenTab.jsx +++ b/client/src/components/settings/ImageGenTab.jsx @@ -116,6 +116,8 @@ export function ImageGenTab() { // (settings, or the FAL_KEY env var server-side). No enabled toggle: the // key's presence IS the opt-in, same shape as loras.js's Civitai key. const [falApiKey, setFalApiKey] = useState(''); + // reactor.inc fast-h3 API key (#6214) — same usability-gate shape as fal above. + const [reactorApiKey, setReactorApiKey] = useState(''); const videoGenSliceRef = useRef({}); const [sdapiUrl, setSdapiUrl] = useState(''); const [pythonPath, setPythonPath] = useState(''); @@ -182,6 +184,7 @@ export function ImageGenTab() { videoGenMode: '', videoGenDisplaySleep: true, falApiKey: '', + reactorApiKey: '', }); const [status, setStatus] = useState(null); @@ -255,6 +258,7 @@ export function ImageGenTab() { const vgMode = normalizeRenderPinValue(vg.mode) || ''; const vgDisplaySleep = vg.displaySleep !== false; const vgFalApiKey = vg.fal?.apiKey || ''; + const vgReactorApiKey = vg.reactor?.apiKey || ''; const m = ig.mode || IMAGE_GEN_MODE.EXTERNAL; const url = normalizeUrl(ig.external?.sdapiUrl || ig.sdapiUrl); const py = ig.local?.pythonPath || ''; @@ -291,6 +295,7 @@ export function ImageGenTab() { setVideoGenMode(vgMode); setVideoGenDisplaySleep(vgDisplaySleep); setFalApiKey(vgFalApiKey); + setReactorApiKey(vgReactorApiKey); videoGenSliceRef.current = vg; setSdapiUrl(url); setPythonPath(py); @@ -320,6 +325,7 @@ export function ImageGenTab() { videoGenMode: vgMode, videoGenDisplaySleep: vgDisplaySleep, falApiKey: vgFalApiKey, + reactorApiKey: vgReactorApiKey, }); setToolRegistered(tools.some((t) => t.id === SDAPI_TOOL_ID)); setCodexToolRegistered(tools.some((t) => t.id === CODEX_TOOL_ID)); @@ -412,7 +418,8 @@ export function ImageGenTab() { || JSON.stringify(renderDefaults) !== saved.renderDefaultsJson || videoGenMode !== saved.videoGenMode || videoGenDisplaySleep !== saved.videoGenDisplaySleep - || falApiKey !== saved.falApiKey; + || falApiKey !== saved.falApiKey + || reactorApiKey !== saved.reactorApiKey; const handleSave = async () => { setSaving(true); @@ -462,6 +469,7 @@ export function ImageGenTab() { mode: videoGenMode || null, displaySleep: videoGenDisplaySleep, fal: { ...videoGenSliceRef.current.fal, apiKey: falApiKey.trim() || undefined }, + reactor: { ...videoGenSliceRef.current.reactor, apiKey: reactorApiKey.trim() || undefined }, }, }; try { @@ -481,6 +489,7 @@ export function ImageGenTab() { videoGenMode, videoGenDisplaySleep, falApiKey: falApiKey.trim(), + reactorApiKey: reactorApiKey.trim(), }); // Reflect the pruned no-op entries back into the editor state so the // dirty check compares like against like after a save. @@ -799,6 +808,20 @@ export function ImageGenTab() { className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent" /> + reactor.inc API keyEnables the reactor.inc fast-h3 video backend on the Video Gen page and in FableLoom, or set the REACTOR_API_KEY environment variable instead.} + labelClassName="text-sm text-gray-300" + > + setReactorApiKey(e.target.value)} + placeholder="reactor-key-..." + className="w-full bg-port-bg border border-port-border rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:border-port-accent" + /> +
{RENDER_TARGET_OPTIONS.map(({ id, label, video }) => { const entry = renderDefaults[id] || {}; diff --git a/client/src/components/settings/ImageGenTab.test.jsx b/client/src/components/settings/ImageGenTab.test.jsx index 225ab8a52f..1df16390bb 100644 --- a/client/src/components/settings/ImageGenTab.test.jsx +++ b/client/src/components/settings/ImageGenTab.test.jsx @@ -245,7 +245,7 @@ describe('ImageGenTab grouped tabs', () => { await waitFor(() => expect(updateSettings).toHaveBeenCalled()); const patch = updateSettings.mock.calls[0][0]; expect(patch.videoGen).toEqual({ - mode: 'local', defaultModelId: 'ltx23_distilled_q4', displaySleep: true, fal: {}, + mode: 'local', defaultModelId: 'ltx23_distilled_q4', displaySleep: true, fal: {}, reactor: {}, }); }); }); diff --git a/client/src/hooks/useVideoGenFieldState.js b/client/src/hooks/useVideoGenFieldState.js index c8922517da..a22048c3c0 100644 --- a/client/src/hooks/useVideoGenFieldState.js +++ b/client/src/hooks/useVideoGenFieldState.js @@ -23,6 +23,9 @@ export function useVideoGenFieldState({ const [grokDuration, setGrokDuration] = useState(GROK_VIDEO_DEFAULT_DURATION); const [falDuration, setFalDuration] = useState(''); const [falModelId, setFalModelId] = useState(''); + const [reactorClipId, setReactorClipId] = useState(''); + const [reactorSeconds, setReactorSeconds] = useState(''); + const [reactorSeed, setReactorSeed] = useState(''); const [mode, setMode] = useState(incomingAudioFilename ? 'a2v' : (incomingSourceImage ? 'image' : 'text')); const [prompt, setPrompt] = useState(incomingPrompt || ''); const [negativePrompt, setNegativePrompt] = useState(incomingNegativePrompt || ''); @@ -86,6 +89,9 @@ export function useVideoGenFieldState({ grokDuration, setGrokDuration, falDuration, setFalDuration, falModelId, setFalModelId, + reactorClipId, setReactorClipId, + reactorSeconds, setReactorSeconds, + reactorSeed, setReactorSeed, guidanceScale, setGuidanceScale, height, setHeight, i2vReferenceMode, setI2vReferenceMode, diff --git a/client/src/hooks/useVideoGenForm.js b/client/src/hooks/useVideoGenForm.js index 3d13904e8f..8255ed553f 100644 --- a/client/src/hooks/useVideoGenForm.js +++ b/client/src/hooks/useVideoGenForm.js @@ -72,7 +72,8 @@ const editableRemixModel = (models, defaultModelId) => { * one builder for what `server/routes/videoGen.js` validates. */ export function useVideoGenForm({ - models, modelContext, availableLoras, grokEnabled, falEnabled = false, remoteSubmissionFields = null, + models, modelContext, availableLoras, grokEnabled, falEnabled = false, reactorEnabled = false, + remoteSubmissionFields = null, }) { const [searchParams, setSearchParams] = useSearchParams(); const incomingSourceImage = searchParams.get('sourceImageFile'); @@ -96,6 +97,9 @@ export function useVideoGenForm({ grokDuration, setGrokDuration, falDuration, setFalDuration, falModelId, setFalModelId, + reactorClipId, setReactorClipId, + reactorSeconds, setReactorSeconds, + reactorSeed, setReactorSeed, guidanceScale, setGuidanceScale, height, setHeight, i2vReferenceMode, setI2vReferenceMode, @@ -595,7 +599,11 @@ export function useVideoGenForm({ // toggle — same short-circuit shape as grok: only prompt/dims/source-image // and a duration reach the provider. const isFal = falEnabled && backend === 'fal'; - const referenceModeApplies = mode === 'image' && !isGrok && !isFal; + // reactor.inc fast-h3 is likewise usability-gated on a configured API key + // (`reactorEnabled`) — same short-circuit shape as fal, plus native + // clip-to-clip chaining via continue_from_clip_id. + const isReactor = reactorEnabled && backend === 'reactor'; + const referenceModeApplies = mode === 'image' && !isGrok && !isFal && !isReactor; // The strength the render will actually use, for the slider readout — an // untouched slider under Inspire still resolves to the contract's low default // rather than the pipeline's 1.0, and the panel must say so. @@ -879,7 +887,7 @@ export function useVideoGenForm({ // clip survives the switch and reappears if the user flips back. const handleBackendChange = (id) => { setBackend(id); - if ((id === 'grok' || id === 'fal') && mode !== 'text' && mode !== 'image') { + if ((id === 'grok' || id === 'fal' || id === 'reactor') && mode !== 'text' && mode !== 'image') { handleModeChange((sourceImageFile || sourceImageUpload) ? 'image' : 'text'); } }; @@ -1175,6 +1183,13 @@ export function useVideoGenForm({ setMode(p.videoMode === 'image' ? 'image' : 'text'); if (p.duration) setFalDuration(p.duration); if (p.modelId) setFalModelId(p.modelId); + } else if (p.mode === 'reactor') { + // reactor.inc job: same discriminator shape as grok/fal above. + setBackend('reactor'); + setMode(p.videoMode === 'image' ? 'image' : 'text'); + if (p.continueFromClipId) setReactorClipId(p.continueFromClipId); + if (p.seconds) setReactorSeconds(p.seconds); + if (p.seed !== undefined && p.seed !== null) setReactorSeed(p.seed); } else if (p.mode) setMode(p.mode); if (p.chunks && p.chunks > 1) setChunks(p.chunks); // 0 is a real restored value ("last frame only"), so this can't gate on @@ -1247,7 +1262,8 @@ export function useVideoGenForm({ // Snapshot the current validated state into a wire payload. The submit flow // stays pure so all three backend contracts can be tested independently. const submissionState = { - isGrok, grokDuration, isFal, falDuration, falModelId, remoteSubmissionFields, + isGrok, grokDuration, isFal, falDuration, falModelId, + isReactor, reactorClipId, reactorSeconds, reactorSeed, remoteSubmissionFields, prompt, negativePrompt, stylePreset, selectedUniverse, width, height, mode, sourceImageFile, sourceImageUpload, numFrames, fps, steps, guidanceScale, seed, @@ -1263,10 +1279,13 @@ export function useVideoGenForm({ return { // Backend + mode - backend, isGrok, isFal, handleBackendChange, + backend, isGrok, isFal, isReactor, handleBackendChange, grokDuration, setGrokDuration, falDuration, setFalDuration, falModelId, setFalModelId, + reactorClipId, setReactorClipId, + reactorSeconds, setReactorSeconds, + reactorSeed, setReactorSeed, mode, handleModeChange, // Prompt + style prompt, setPrompt, diff --git a/client/src/lib/imageGenModes.js b/client/src/lib/imageGenModes.js index 6a87d0bd4b..2f8811e51f 100644 --- a/client/src/lib/imageGenModes.js +++ b/client/src/lib/imageGenModes.js @@ -71,7 +71,7 @@ export const RENDER_TARGET_OPTIONS = Object.freeze([ // Client mirror of the server's VIDEO_GEN_MODES (services/videoGen/modes.js) — // the backend alphabet for the video pin controls above and the install-wide // `settings.videoGen.mode` pin. -export const VIDEO_RENDER_MODES = Object.freeze(['local', 'grok', 'fal']); +export const VIDEO_RENDER_MODES = Object.freeze(['local', 'grok', 'fal', 'reactor']); // Client mirror of the server's normalizeRenderPinValue // (server/lib/renderTargets.js) — THE one render-pin normalization rule: trim; @@ -95,6 +95,7 @@ export const MODE_LABELS = Object.freeze({ [IMAGE_GEN_MODE.AGY]: 'Agy', [IMAGE_GEN_MODE.EXTERNAL]: 'External', fal: 'fal.ai', + reactor: 'Reactor.inc', }); // Client mirror of the server's CLOUD_IMAGE_GEN_MODES (imageGen/modes.js) — diff --git a/client/src/lib/videoGenSubmission.js b/client/src/lib/videoGenSubmission.js index e454c778ab..60c03045e3 100644 --- a/client/src/lib/videoGenSubmission.js +++ b/client/src/lib/videoGenSubmission.js @@ -34,7 +34,8 @@ export function envelopVideoPrompt(text, { } export function buildVideoGenSubmission({ - isGrok, grokDuration, isFal, falDuration, falModelId, remoteSubmissionFields, + isGrok, grokDuration, isFal, falDuration, falModelId, + isReactor, reactorClipId, reactorSeconds, reactorSeed, remoteSubmissionFields, prompt, negativePrompt, stylePreset, selectedUniverse, width, height, mode, sourceImageFile, sourceImageUpload, numFrames, fps, steps, guidanceScale, seed, @@ -86,6 +87,26 @@ export function buildVideoGenSubmission({ }; } + if (isReactor) { + return { + backend: 'reactor', + prompt: composed.prompt, + negativePrompt: composed.negativePrompt, + reactorClipId: reactorClipId || undefined, + reactorSeconds: reactorSeconds || undefined, + // Unlike falDuration/falModelId, a seed of 0 is a real, meaningful + // value — `|| undefined` would silently drop it (the reactor route + // and reactor.js's own buildRequestBody already preserve 0 correctly, + // so only the client-side send needs the nullish check). + reactorSeed: reactorSeed === '' || reactorSeed === null || reactorSeed === undefined ? undefined : reactorSeed, + // No width/height: unlike grok/fal, reactor.js reads no dimension + // field — submitJob's reactor lane never forwards them. + mode: mode === 'image' ? 'image' : 'text', + sourceImageFile: mode === 'image' ? (sourceImageFile || '') : '', + sourceImage: mode === 'image' ? (sourceImageUpload || '') : '', + }; + } + if (remoteSubmissionFields) { return { backend: 'local', diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx index b3054d6b28..e53c59e69e 100644 --- a/client/src/pages/VideoGen.jsx +++ b/client/src/pages/VideoGen.jsx @@ -137,6 +137,9 @@ export default function VideoGen() { // fal.ai queue REST video backend (#6213) — surfaced only when an API key is // configured (Settings → Video Gen, or the FAL_KEY env var). const [falEnabled, setFalEnabled] = useState(false); + // reactor.inc fast-h3 video backend (#6214) — same usability gate shape as + // fal.ai above. + const [reactorEnabled, setReactorEnabled] = useState(false); // The jobId of the render this tab's Generate button currently owns — // threaded into cancelVideoGen so cancellation is job-scoped. const activeJobIdRef = useRef(null); @@ -146,6 +149,7 @@ export default function VideoGen() { .then((sv) => { setGrokEnabled(sv?.imageGen?.grok?.enabled === true); setFalEnabled(Boolean(sv?.videoGen?.fal?.apiKey)); + setReactorEnabled(Boolean(sv?.videoGen?.reactor?.apiKey)); }) .catch(() => {}); }, []); @@ -163,8 +167,9 @@ export default function VideoGen() { // Every field the form submits, plus the payload builder both submit paths // share. See client/src/hooks/useVideoGenForm.js. const { - backend, isGrok, isFal, handleBackendChange, grokDuration, setGrokDuration, + backend, isGrok, isFal, isReactor, handleBackendChange, grokDuration, setGrokDuration, falDuration, setFalDuration, falModelId, setFalModelId, + reactorClipId, setReactorClipId, reactorSeconds, setReactorSeconds, reactorSeed, setReactorSeed, mode, handleModeChange, prompt, setPrompt, envelopedPrompt, negativePrompt, setNegativePrompt, stylePreset, setStylePreset, selectedUniverse, setSelectedUniverse, remixModelFallback, @@ -199,7 +204,7 @@ export default function VideoGen() { icStrength, setIcStrength, icSkipStage2, setIcSkipStage2, applyRemix, applyFinish, applyResumedParams, buildGeneratePayload, } = useVideoGenForm({ - models, modelContext, availableLoras, grokEnabled, falEnabled, + models, modelContext, availableLoras, grokEnabled, falEnabled, reactorEnabled, remoteSubmissionFields: remoteTarget.isRemote ? remoteTarget.submissionFields : null, }); @@ -224,6 +229,7 @@ export default function VideoGen() { const present = [ ['the Grok backend', isGrok], ['the fal.ai backend', isFal], + ['the reactor.inc backend', isReactor], // Each remaining pipeline semantic has its own input listed below, but the // mode can be set before that input is filled — so gate the mode too // rather than letting an a2v render reach the peer as plain text-to-video. @@ -250,7 +256,7 @@ export default function VideoGen() { return `${model?.modelName || 'The selected peer model'} renders only from a source image — add a start frame, or pick a text-to-video model.`; } return null; - }, [remoteTarget.isRemote, remoteTarget.model, remoteTarget.acceptsInput, isGrok, isFal, mode, sourceImageFile, sourceImageUpload, + }, [remoteTarget.isRemote, remoteTarget.model, remoteTarget.acceptsInput, isGrok, isFal, isReactor, mode, sourceImageFile, sourceImageUpload, lastImageFile, lastImageUpload, keyframesActive, extendFromVideoId, audioFile, icReferenceFile, icReferenceVideoId, icReferenceImageFiles, selectedLoras, chunks]); // One reading for the Generate button, the enqueue guard and the caption. @@ -614,14 +620,14 @@ export default function VideoGen() { startEncoderWhenIdle(option && !option.builtIn ? textEncoderDownloadId(id) : null); }, [setTextEncoderId, textEncoderOptions, startEncoderWhenIdle]); const icWeightStatus = icSpec ? modelDownload.getStatus(icSpec.mode) : null; - const modelWeightsBlocked = !isGrok && !isFal + const modelWeightsBlocked = !isGrok && !isFal && !isReactor && (statusLoading || !modelId || !currentModel || modelDownload.loading || modelStatus === null || modelStatus?.cached === false); - const textEncoderWeightsBlocked = !isGrok && !isFal && usesSharedTextEncoder + const textEncoderWeightsBlocked = !isGrok && !isFal && !isReactor && usesSharedTextEncoder && (modelDownload.loading || textEncoderStatus === null || textEncoderStatus?.cached === false); - const icWeightsBlocked = !isGrok && !isFal && icModeActive + const icWeightsBlocked = !isGrok && !isFal && !isReactor && icModeActive && (modelDownload.loading || icWeightStatus === null || icWeightStatus?.cached === false); - const textEncoderOptionBlocked = !isGrok && !isFal && !!textEncoderOptionDownloadId + const textEncoderOptionBlocked = !isGrok && !isFal && !isReactor && !!textEncoderOptionDownloadId && (modelDownload.loading || textEncoderOptionStatus === null || textEncoderOptionStatus?.cached === false); const weightsGateBlocked = modelWeightsBlocked || textEncoderWeightsBlocked || textEncoderOptionBlocked || icWeightsBlocked; @@ -694,7 +700,7 @@ export default function VideoGen() { // actually apply it (macOS, and the user hasn't opted out). const rendersSleepDisplay = !!status?.displaySleepOnRender && !!currentModel?.sleepsDisplayDuringRender - && !remoteTarget.isRemote && !isGrok && !isFal; + && !remoteTarget.isRemote && !isGrok && !isFal && !isReactor; // Run a single payload through the SSE pipeline. Returns a promise that // resolves when the job completes (or rejects on error / cancel). The @@ -844,7 +850,7 @@ export default function VideoGen() { // will actually run on. const canEnqueue = prompt.trim() && (remoteTarget.isRemote ? remoteBlocked === null - : (isGrok || isFal || (!notConnected && !extendModeBlocked + : (isGrok || isFal || isReactor || (!notConnected && !extendModeBlocked && !a2vModeBlocked && !icLoraModeBlocked && !byovGateBlocked && !weightsGateBlocked && !keyframesBlocked))); @@ -919,15 +925,17 @@ export default function VideoGen() { })()} {/* Backend switch — shown only when the user enabled Grok in Settings → - Image Gen and/or configured a fal.ai API key. Both cloud backends' - image-to-video only supports text (image-first) and image modes, so - switching to either snaps an unsupported mode back to the nearest one. */} - {(grokEnabled || falEnabled) && ( + Image Gen and/or configured a fal.ai or reactor.inc API key. Every + cloud backend's image-to-video only supports text (image-first) and + image modes, so switching to one snaps an unsupported mode back to + the nearest one. */} + {(grokEnabled || falEnabled || reactorEnabled) && (
{[ { id: 'local', label: 'Local' }, ...(grokEnabled ? [{ id: 'grok', label: 'Grok' }] : []), ...(falEnabled ? [{ id: 'fal', label: 'fal.ai' }] : []), + ...(reactorEnabled ? [{ id: 'reactor', label: 'Reactor.inc' }] : []), ].map(({ id, label }) => ( @@ -955,7 +965,7 @@ export default function VideoGen() { WAI-ARIA Tabs, since the mode-specific inputs aren't structured as tabpanels and we don't implement roving-tabindex/arrow-key focus. */}
- {((isGrok || isFal) ? MODES.filter((m) => m.id === 'text' || m.id === 'image') : MODES).map(({ id, label, icon: Icon, desc }) => { + {((isGrok || isFal || isReactor) ? MODES.filter((m) => m.id === 'text' || m.id === 'image') : MODES).map(({ id, label, icon: Icon, desc }) => { const active = mode === id; return (