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 (