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
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions client/src/components/fableloom/LoomSettingsDrawer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
25 changes: 24 additions & 1 deletion client/src/components/settings/ImageGenTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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('');
Expand Down Expand Up @@ -182,6 +184,7 @@ export function ImageGenTab() {
videoGenMode: '',
videoGenDisplaySleep: true,
falApiKey: '',
reactorApiKey: '',
});

const [status, setStatus] = useState(null);
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -291,6 +295,7 @@ export function ImageGenTab() {
setVideoGenMode(vgMode);
setVideoGenDisplaySleep(vgDisplaySleep);
setFalApiKey(vgFalApiKey);
setReactorApiKey(vgReactorApiKey);
videoGenSliceRef.current = vg;
setSdapiUrl(url);
setPythonPath(py);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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"
/>
</FormField>
<FormField
label={<>reactor.inc API key<span className="block text-xs text-gray-500 mt-0.5">Enables the reactor.inc fast-h3 video backend on the Video Gen page and in FableLoom, or set the REACTOR_API_KEY environment variable instead.</span></>}
labelClassName="text-sm text-gray-300"
>
<input
id="reactor-api-key"
type="password"
autoComplete="off"
value={reactorApiKey}
onChange={(e) => 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"
/>
</FormField>
<div className="space-y-3">
{RENDER_TARGET_OPTIONS.map(({ id, label, video }) => {
const entry = renderDefaults[id] || {};
Expand Down
2 changes: 1 addition & 1 deletion client/src/components/settings/ImageGenTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {},
});
});
});
Expand Down
6 changes: 6 additions & 0 deletions client/src/hooks/useVideoGenFieldState.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 || '');
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 24 additions & 5 deletions client/src/hooks/useVideoGenForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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');
}
};
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion client/src/lib/imageGenModes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) —
Expand Down
23 changes: 22 additions & 1 deletion client/src/lib/videoGenSubmission.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Loading