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 @@ -110,6 +110,11 @@ PGPASSWORD=portos
# GROK_TIMEOUT_MS — keep it above the 20-minute cloud-lane idle watchdog.
# GROK_VIDEO_TIMEOUT_MS=1800000

# Maximum fal.ai queue video-generation runtime in milliseconds (videoGen/fal.js;
# default: 1200000 / 20 minutes) — a queue render can sit behind other tenants'
# jobs before it starts, so this bounds the whole submit→poll→download run.
# FAL_VIDEO_TIMEOUT_MS=1200000

# Maximum Antigravity image-generation runtime in milliseconds (default: 1200000 / 20 minutes)
# AGY_IMAGEGEN_TIMEOUT_MS=1200000

Expand Down Expand Up @@ -150,6 +155,10 @@ PGPASSWORD=portos
# CivitAI API key for LoRA / model downloads from civitai.com
# CIVITAI_API_KEY=your_civitai_api_key_here

# fal.ai API key for the fal.ai queue video backend (Video Gen page, FableLoom).
# Settings > Video Gen's stored key wins when both are set.
# FAL_KEY=your_fal_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 @@ -51,6 +51,7 @@ const VIDEO_RENDER_BACKENDS = [
{ id: 'auto', label: 'Auto', icon: Layers },
{ id: 'local', label: 'Local', icon: Cpu },
{ id: 'grok', label: 'Grok', icon: Cloud },
{ id: 'fal', label: 'fal.ai', icon: Cloud },
];

const modelOptions = (models, selected) => {
Expand Down
33 changes: 31 additions & 2 deletions client/src/components/settings/ImageGenTab.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export function ImageGenTab() {
// (defaultModelId) survive the settings PUT's wholesale slice replace.
const [videoGenMode, setVideoGenMode] = useState('');
const [videoGenDisplaySleep, setVideoGenDisplaySleep] = useState(true);
// fal.ai queue REST API key (#6213) — usability-gated on this being set
// (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('');
const videoGenSliceRef = useRef({});
const [sdapiUrl, setSdapiUrl] = useState('');
const [pythonPath, setPythonPath] = useState('');
Expand Down Expand Up @@ -177,6 +181,7 @@ export function ImageGenTab() {
renderDefaultsJson: '{}',
videoGenMode: '',
videoGenDisplaySleep: true,
falApiKey: '',
});

const [status, setStatus] = useState(null);
Expand Down Expand Up @@ -249,6 +254,7 @@ export function ImageGenTab() {
const vg = (s?.videoGen && typeof s.videoGen === 'object') ? s.videoGen : {};
const vgMode = normalizeRenderPinValue(vg.mode) || '';
const vgDisplaySleep = vg.displaySleep !== false;
const vgFalApiKey = vg.fal?.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 @@ -284,6 +290,7 @@ export function ImageGenTab() {
setRenderDefaults(rd);
setVideoGenMode(vgMode);
setVideoGenDisplaySleep(vgDisplaySleep);
setFalApiKey(vgFalApiKey);
videoGenSliceRef.current = vg;
setSdapiUrl(url);
setPythonPath(py);
Expand Down Expand Up @@ -312,6 +319,7 @@ export function ImageGenTab() {
renderDefaultsJson: JSON.stringify(rd),
videoGenMode: vgMode,
videoGenDisplaySleep: vgDisplaySleep,
falApiKey: vgFalApiKey,
});
setToolRegistered(tools.some((t) => t.id === SDAPI_TOOL_ID));
setCodexToolRegistered(tools.some((t) => t.id === CODEX_TOOL_ID));
Expand Down Expand Up @@ -403,7 +411,8 @@ export function ImageGenTab() {
|| denoiseByMode.external !== saved.denoiseByMode.external
|| JSON.stringify(renderDefaults) !== saved.renderDefaultsJson
|| videoGenMode !== saved.videoGenMode
|| videoGenDisplaySleep !== saved.videoGenDisplaySleep;
|| videoGenDisplaySleep !== saved.videoGenDisplaySleep
|| falApiKey !== saved.falApiKey;

const handleSave = async () => {
setSaving(true);
Expand Down Expand Up @@ -448,7 +457,12 @@ export function ImageGenTab() {
),
// Install-wide video pin (#3231 Phase 4). Spread over the loaded slice so
// sibling keys (defaultModelId) survive the wholesale slice replace.
videoGen: { ...videoGenSliceRef.current, mode: videoGenMode || null, displaySleep: videoGenDisplaySleep },
videoGen: {
...videoGenSliceRef.current,
mode: videoGenMode || null,
displaySleep: videoGenDisplaySleep,
fal: { ...videoGenSliceRef.current.fal, apiKey: falApiKey.trim() || undefined },
},
};
try {
await updateSettings(patch, { silent: true });
Expand All @@ -466,6 +480,7 @@ export function ImageGenTab() {
renderDefaultsJson: JSON.stringify(patch.renderDefaults),
videoGenMode,
videoGenDisplaySleep,
falApiKey: falApiKey.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 @@ -770,6 +785,20 @@ export function ImageGenTab() {
<span className="block text-xs text-gray-500 mt-0.5">Keeps the system awake while reducing WindowServer GPU contention on affected Apple silicon. Turn off only when another headless workflow manages display power.</span>
</span>
</label>
<FormField
label={<>fal.ai API key<span className="block text-xs text-gray-500 mt-0.5">Enables the fal.ai queue video backend on the Video Gen page and in FableLoom. Get a key at fal.ai/dashboard/keys, or set the FAL_KEY environment variable instead.</span></>}
labelClassName="text-sm text-gray-300"
>
<input
id="fal-api-key"
type="password"
autoComplete="off"
value={falApiKey}
onChange={(e) => setFalApiKey(e.target.value)}
placeholder="fal-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
4 changes: 3 additions & 1 deletion client/src/components/settings/ImageGenTab.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -244,7 +244,9 @@ describe('ImageGenTab grouped tabs', () => {
fireEvent.click(screen.getByRole('button', { name: /^Save$/ }));
await waitFor(() => expect(updateSettings).toHaveBeenCalled());
const patch = updateSettings.mock.calls[0][0];
expect(patch.videoGen).toEqual({ mode: 'local', defaultModelId: 'ltx23_distilled_q4', displaySleep: true });
expect(patch.videoGen).toEqual({
mode: 'local', defaultModelId: 'ltx23_distilled_q4', displaySleep: true, fal: {},
});
});
});

Expand Down
4 changes: 4 additions & 0 deletions client/src/hooks/useVideoGenFieldState.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ export function useVideoGenFieldState({
}) {
const [backend, setBackend] = useState('local');
const [grokDuration, setGrokDuration] = useState(GROK_VIDEO_DEFAULT_DURATION);
const [falDuration, setFalDuration] = useState('');
const [falModelId, setFalModelId] = useState('');
const [mode, setMode] = useState(incomingAudioFilename ? 'a2v' : (incomingSourceImage ? 'image' : 'text'));
const [prompt, setPrompt] = useState(incomingPrompt || '');
const [negativePrompt, setNegativePrompt] = useState(incomingNegativePrompt || '');
Expand Down Expand Up @@ -82,6 +84,8 @@ export function useVideoGenFieldState({
extendFromVideoId, setExtendFromVideoId,
fps, setFps,
grokDuration, setGrokDuration,
falDuration, setFalDuration,
falModelId, setFalModelId,
guidanceScale, setGuidanceScale,
height, setHeight,
i2vReferenceMode, setI2vReferenceMode,
Expand Down
27 changes: 22 additions & 5 deletions client/src/hooks/useVideoGenForm.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ const editableRemixModel = (models, defaultModelId) => {
* wire accepts — kept here rather than in the page so there stays exactly
* one builder for what `server/routes/videoGen.js` validates.
*/
export function useVideoGenForm({ models, modelContext, availableLoras, grokEnabled, remoteSubmissionFields = null }) {
export function useVideoGenForm({
models, modelContext, availableLoras, grokEnabled, falEnabled = false, remoteSubmissionFields = null,
}) {
const [searchParams, setSearchParams] = useSearchParams();
const incomingSourceImage = searchParams.get('sourceImageFile');
const incomingAudioFilename = searchParams.get('audioFilename');
Expand All @@ -92,6 +94,8 @@ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnab
extendFromVideoId, setExtendFromVideoId,
fps, setFps,
grokDuration, setGrokDuration,
falDuration, setFalDuration,
falModelId, setFalModelId,
guidanceScale, setGuidanceScale,
height, setHeight,
i2vReferenceMode, setI2vReferenceMode,
Expand Down Expand Up @@ -586,7 +590,12 @@ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnab
// lane reads only prompt/dims/source-image/duration, so its image_to_video
// always anchors and the promise has to collapse to the default there.
const isGrok = grokEnabled && backend === 'grok';
const referenceModeApplies = mode === 'image' && !isGrok;
// fal.ai's queue REST API is usability-gated on a configured API key
// (`falEnabled`, mirrored from the server's isVideoModeUsable check), not a
// 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;
// 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 @@ -870,7 +879,7 @@ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnab
// clip survives the switch and reappears if the user flips back.
const handleBackendChange = (id) => {
setBackend(id);
if (id === 'grok' && mode !== 'text' && mode !== 'image') {
if ((id === 'grok' || id === 'fal') && mode !== 'text' && mode !== 'image') {
handleModeChange((sourceImageFile || sourceImageUpload) ? 'image' : 'text');
}
};
Expand Down Expand Up @@ -1160,6 +1169,12 @@ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnab
setBackend('grok');
setMode(p.videoMode === 'image' ? 'image' : 'text');
if (p.duration) setGrokDuration(p.duration);
} else if (p.mode === 'fal') {
// fal.ai job: same discriminator shape as grok above.
setBackend('fal');
setMode(p.videoMode === 'image' ? 'image' : 'text');
if (p.duration) setFalDuration(p.duration);
if (p.modelId) setFalModelId(p.modelId);
} 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 @@ -1232,7 +1247,7 @@ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnab
// 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, remoteSubmissionFields,
isGrok, grokDuration, isFal, falDuration, falModelId, remoteSubmissionFields,
prompt, negativePrompt, stylePreset, selectedUniverse,
width, height, mode, sourceImageFile, sourceImageUpload,
numFrames, fps, steps, guidanceScale, seed,
Expand All @@ -1248,8 +1263,10 @@ export function useVideoGenForm({ models, modelContext, availableLoras, grokEnab

return {
// Backend + mode
backend, isGrok, handleBackendChange,
backend, isGrok, isFal, handleBackendChange,
grokDuration, setGrokDuration,
falDuration, setFalDuration,
falModelId, setFalModelId,
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']);
export const VIDEO_RENDER_MODES = Object.freeze(['local', 'grok', 'fal']);

// Client mirror of the server's normalizeRenderPinValue
// (server/lib/renderTargets.js) — THE one render-pin normalization rule: trim;
Expand All @@ -94,6 +94,7 @@ export const MODE_LABELS = Object.freeze({
[IMAGE_GEN_MODE.GROK]: 'Grok',
[IMAGE_GEN_MODE.AGY]: 'Agy',
[IMAGE_GEN_MODE.EXTERNAL]: 'External',
fal: 'fal.ai',
});

// Client mirror of the server's CLOUD_IMAGE_GEN_MODES (imageGen/modes.js) —
Expand Down
17 changes: 16 additions & 1 deletion client/src/lib/videoGenSubmission.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function envelopVideoPrompt(text, {
}

export function buildVideoGenSubmission({
isGrok, grokDuration, remoteSubmissionFields,
isGrok, grokDuration, isFal, falDuration, falModelId, remoteSubmissionFields,
prompt, negativePrompt, stylePreset, selectedUniverse,
width, height, mode, sourceImageFile, sourceImageUpload,
numFrames, fps, steps, guidanceScale, seed,
Expand Down Expand Up @@ -71,6 +71,21 @@ export function buildVideoGenSubmission({
};
}

if (isFal) {
return {
backend: 'fal',
prompt: composed.prompt,
negativePrompt: composed.negativePrompt,
falDuration,
falModelId: falModelId || undefined,
width: clampImageEdge(width, VIDEO_EDGE_BOUNDS),
height: clampImageEdge(height, VIDEO_EDGE_BOUNDS),
mode: mode === 'image' ? 'image' : 'text',
sourceImageFile: mode === 'image' ? (sourceImageFile || '') : '',
sourceImage: mode === 'image' ? (sourceImageUpload || '') : '',
};
}

if (remoteSubmissionFields) {
return {
backend: 'local',
Expand Down
Loading