From e0aa702eb3723bbffb7c0ba02b4bcb553d683b73 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Mon, 31 Aug 2026 01:42:38 +0000 Subject: [PATCH 1/2] feat: add fal H3 Max free video handoff --- .../src/components/fableloom/LoomCanvas.jsx | 2 + .../components/fableloom/LoomCanvas.test.jsx | 4 ++ .../components/fableloom/LoomNodeEditor.jsx | 40 +++++++++++- .../fableloom/LoomNodeEditor.test.jsx | 43 ++++++++++++- .../components/fableloom/LoomSceneMedia.jsx | 44 ++++++++++++- client/src/lib/README.md | 1 + client/src/lib/falVideoHandoff.js | 28 ++++++++ client/src/lib/falVideoHandoff.test.js | 33 ++++++++++ client/src/lib/index.js | 1 + client/src/pages/FableLoomStory.jsx | 23 +++++++ .../pages/VideoGen.composeWhileBusy.test.jsx | 25 ++++++++ client/src/pages/VideoGen.jsx | 64 ++++++++++++++++++- 12 files changed, 303 insertions(+), 5 deletions(-) create mode 100644 client/src/lib/falVideoHandoff.js create mode 100644 client/src/lib/falVideoHandoff.test.js diff --git a/client/src/components/fableloom/LoomCanvas.jsx b/client/src/components/fableloom/LoomCanvas.jsx index e9276bc896..7fffba6005 100644 --- a/client/src/components/fableloom/LoomCanvas.jsx +++ b/client/src/components/fableloom/LoomCanvas.jsx @@ -40,6 +40,7 @@ export default function LoomCanvas({ episode, selectedNodeId, onSelectNode, onMoveNode, viewportWidth: viewportWidthProp, orientation: orientationProp, mediaJobs = {}, onGenerateImage, onGenerateVideo, + onOpenFalVideo, generationDisabled = false, generationDisabledReason = '', }) { // An in-flight drag lives entirely outside React state: the dragged 's @@ -328,6 +329,7 @@ export default function LoomCanvas({ jobs={mediaJobs[node.id]} onGenerateImage={onGenerateImage} onGenerateVideo={onGenerateVideo} + onOpenFalVideo={onOpenFalVideo} compact generationDisabled={generationDisabled} generationDisabledReason={generationDisabledReason} diff --git a/client/src/components/fableloom/LoomCanvas.test.jsx b/client/src/components/fableloom/LoomCanvas.test.jsx index d7b88fc0fc..e6d2b0d504 100644 --- a/client/src/components/fableloom/LoomCanvas.test.jsx +++ b/client/src/components/fableloom/LoomCanvas.test.jsx @@ -72,6 +72,7 @@ describe('LoomCanvas', () => { it('keeps media controls in each visual node and gives a finished video preview precedence', () => { const onGenerateImage = vi.fn(); const onGenerateVideo = vi.fn(); + const onOpenFalVideo = vi.fn(); const withMedia = episode(); withMedia.nodes[0] = { ...withMedia.nodes[0], image: 'scene.png', videoHistoryId: 'video-1', @@ -83,6 +84,7 @@ describe('LoomCanvas', () => { onSelectNode={() => {}} onGenerateImage={onGenerateImage} onGenerateVideo={onGenerateVideo} + onOpenFalVideo={onOpenFalVideo} />, ); @@ -90,8 +92,10 @@ describe('LoomCanvas', () => { expect(screen.queryByAltText('The Gate image preview')).toBeNull(); fireEvent.click(screen.getByRole('button', { name: 'Regenerate image' })); fireEvent.click(screen.getByRole('button', { name: 'Regenerate video' })); + fireEvent.click(screen.getAllByRole('button', { name: 'fal.ai free' })[0]); expect(onGenerateImage).toHaveBeenCalledWith(withMedia.nodes[0]); expect(onGenerateVideo).toHaveBeenCalledWith(withMedia.nodes[0]); + expect(onOpenFalVideo).toHaveBeenCalledWith(withMedia.nodes[0]); }); it('shows live image progress and retains an actionable failed indicator', () => { diff --git a/client/src/components/fableloom/LoomNodeEditor.jsx b/client/src/components/fableloom/LoomNodeEditor.jsx index c45cba3d01..a81171f37e 100644 --- a/client/src/components/fableloom/LoomNodeEditor.jsx +++ b/client/src/components/fableloom/LoomNodeEditor.jsx @@ -69,7 +69,7 @@ const characterReferenceInfo = (character) => { export default function LoomNodeEditor({ loom, episode, node, universe, onLoomUpdate, onClearSelection, onMakeStart, - mediaJobs = {}, onGenerateImage, onGenerateVideo, + mediaJobs = {}, onGenerateImage, onGenerateVideo, onOpenFalVideo, generationDisabled = false, generationDisabledReason = '', }) { const [form, setForm] = useState(null); @@ -248,6 +248,40 @@ export default function LoomNodeEditor({ }); }; + const runOpenFalVideo = () => { + const authoredPrompt = form.videoPrompt.trim() || form.prose.trim(); + if (!authoredPrompt) { + toast.error('Write the scene first'); + return; + } + // Keep window.open under the originating click. The free-tool handoff does + // not read server state, so persisting an unsaved prompt can finish after + // the new tab opens without risking a popup blocker. + onOpenFalVideo?.({ + ...node, + prose: form.prose, + videoPrompt: form.videoPrompt, + cameraMovement: form.cameraMovement, + }); + void saveField('videoPrompt', form.videoPrompt); + }; + + const attachGalleryVideo = async (_targetNode, item) => { + if (!item?.id || !item?.filename) { + toast.error('The selected video is missing its gallery record'); + return; + } + // Scene playback has historically resolved history ids as `${id}.mp4`. + // fal H3 Max downloads MP4, so refuse a different gallery container rather + // than attaching a record whose preview URL this schema cannot represent. + if (item.filename !== `${item.id}.mp4`) { + toast.error('Choose an MP4 uploaded here; this gallery video uses a different filename'); + return; + } + const updated = await patchNode({ videoHistoryId: item.id }); + if (updated) toast.success('Scene video attached'); + }; + const handleDelete = async () => { const updated = await deleteLoomNode(loom.id, episode.id, node.id).catch(() => null); if (updated) { @@ -669,8 +703,12 @@ export default function LoomNodeEditor({ jobs={mediaJobs} onGenerateImage={runGenerateImage} onGenerateVideo={runGenerateVideo} + onOpenFalVideo={runOpenFalVideo} + onAttachVideo={attachGalleryVideo} generationDisabled={aiBlocked || generationDisabled} generationDisabledReason={aiBlocked ? 'Wait for scene changes to save' : generationDisabledReason} + falDisabled={generationDisabled} + falDisabledReason={generationDisabledReason} /> diff --git a/client/src/components/fableloom/LoomNodeEditor.test.jsx b/client/src/components/fableloom/LoomNodeEditor.test.jsx index 3f8b45cc48..fe557142a5 100644 --- a/client/src/components/fableloom/LoomNodeEditor.test.jsx +++ b/client/src/components/fableloom/LoomNodeEditor.test.jsx @@ -12,6 +12,17 @@ vi.mock('../../services/api', () => ({ updateLoomTransition: vi.fn(), })); +vi.mock('../videoGen/GalleryVideoPicker', () => ({ + default: ({ open, onSelect }) => open ? ( + + ) : null, +})); + import { addLoomTransition, branchLoomNode, deleteLoomNode, deleteLoomTransition, updateLoomNode, updateLoomTransition, } from '../../services/api'; @@ -39,6 +50,7 @@ const renderEditor = (transitions = [existingPath]) => { const onLoomUpdate = vi.fn(); const onGenerateImage = vi.fn().mockResolvedValue({ jobId: 'image-1' }); const onGenerateVideo = vi.fn().mockResolvedValue({ jobId: 'video-1' }); + const onOpenFalVideo = vi.fn(); render( { onClearSelection={() => {}} onGenerateImage={onGenerateImage} onGenerateVideo={onGenerateVideo} + onOpenFalVideo={onOpenFalVideo} /> , ); - return { onLoomUpdate, onGenerateImage, onGenerateVideo }; + return { onLoomUpdate, onGenerateImage, onGenerateVideo, onOpenFalVideo }; }; const renderHelperEditor = () => { @@ -304,6 +317,34 @@ describe('LoomNodeEditor scene media', () => { })); }); + it('hands the current scene direction to the fal free tool without waiting on a save', async () => { + const user = userEvent.setup(); + const { onOpenFalVideo } = renderEditor(); + + await user.clear(screen.getByLabelText('Video prompt')); + await user.type(screen.getByLabelText('Video prompt'), 'A fast practical-effects reveal.'); + await user.click(screen.getByRole('button', { name: 'fal.ai free' })); + + expect(onOpenFalVideo).toHaveBeenCalledWith(expect.objectContaining({ + id: 'n1', + videoPrompt: 'A fast practical-effects reveal.', + cameraMovement: 'slow-dolly-in', + })); + }); + + it('attaches an uploaded fal MP4 through the durable gallery history id', async () => { + const user = userEvent.setup(); + updateLoomNode.mockResolvedValue({ id: 'loom-1' }); + renderEditor(); + + await user.click(screen.getByRole('button', { name: 'Attach video' })); + await user.click(screen.getByRole('button', { name: 'Pick fal MP4' })); + + await waitFor(() => expect(updateLoomNode).toHaveBeenCalledWith( + 'loom-1', 'ep-1', 'n1', { videoHistoryId: 'upload-example' }, { silent: true }, + )); + }); + it('uses the scene for text-to-video when no rendered still exists', async () => { const user = userEvent.setup(); const onGenerateVideo = vi.fn().mockResolvedValue({ jobId: 'video-2' }); diff --git a/client/src/components/fableloom/LoomSceneMedia.jsx b/client/src/components/fableloom/LoomSceneMedia.jsx index dd7d497e1d..561930014d 100644 --- a/client/src/components/fableloom/LoomSceneMedia.jsx +++ b/client/src/components/fableloom/LoomSceneMedia.jsx @@ -7,8 +7,10 @@ * and queued/running/failed/canceled states stay visible after the POST returns. */ -import { AlertCircle, ImagePlus, Loader2, Video } from 'lucide-react'; +import { useState } from 'react'; +import { AlertCircle, ExternalLink, ImagePlus, Loader2, Upload, Video } from 'lucide-react'; import MediaImage from '../MediaImage'; +import GalleryVideoPicker from '../videoGen/GalleryVideoPicker'; const ACTIVE_STATUSES = new Set(['submitting', 'queued', 'running', 'unknown']); @@ -39,11 +41,18 @@ export default function LoomSceneMedia({ jobs = {}, onGenerateImage, onGenerateVideo, + onOpenFalVideo, + onAttachVideo, compact = false, generationDisabled = false, generationDisabledReason = '', + falDisabled, + falDisabledReason, }) { + const [videoPickerOpen, setVideoPickerOpen] = useState(false); const imageJob = jobs.image || null; + const freeToolDisabled = falDisabled ?? generationDisabled; + const freeToolDisabledReason = falDisabledReason ?? generationDisabledReason; const videoJob = jobs.video || null; const imageActive = isActive(imageJob); const videoActive = isActive(videoJob); @@ -144,7 +153,7 @@ export default function LoomSceneMedia({
:
{noticeLabel && !compact && ( @@ -180,6 +211,15 @@ export default function LoomSceneMedia({ {generationDisabledReason && !noticeLabel && !compact && (

{generationDisabledReason}

)} + {!compact && ( + setVideoPickerOpen(false)} + onSelect={(item) => onAttachVideo?.(node, item)} + allowUpload + uploadToGallery + /> + )} ); } diff --git a/client/src/lib/README.md b/client/src/lib/README.md index 943793ab63..88f7f3da34 100644 --- a/client/src/lib/README.md +++ b/client/src/lib/README.md @@ -112,6 +112,7 @@ grep -i "what you want to do" client/src/lib/README.md | `clinicianReport.js` | Pure builders for the MeatSpace clinician-export view (`/meatspace/export`). `buildClinicianReport({ tests, config })` → structured report model (blood panels grouped by category with reference ranges + out-of-range flags, plus a lifestyle summary); `reportToMarkdown(report)` → copy-paste markdown. Reuses the Blood tab's `REFERENCE_RANGES` / `getBloodValueStatus` so printed flags match the UI. Also exports `buildBloodTestModel`, `buildLifestyleModel`, `getCategoryForKey`, `formatRange`. | | `quotaBurnPatch.js` | `mergeQuotaBurnPatch(base, patch)` — mirrors the server's quota-burn config merge (top-level + per-family keys merge, a family's `jobs` array replaces) so the Quota Burn page can apply an edit optimistically and accumulate debounced edits into one PUT body. `applyQuotaBurnPreset(job, preset)` / `jobFromPreset(preset, { id, appId })` — copy a catalog prompt preset into a job, preserving the user's own step name and app choice. `quotaBurnJobIsSpent(job, ranAt)` — whether a `run once` step has had its one dispatch, gated on the optimistic config's own `runOnce` so a just-ticked checkbox reads as spent before the save round-trips (the client mirror of the server's `jobIsSpent`). `UNLIMITED_DISPATCHES` / `isUnlimitedDispatchCap(cap)` / `dispatchCapInput(value)` — the -1 "no dispatch cap" sentinel (the default), mirrored from the server. | | `clipboard.js` | `copyToClipboard`, `writeClipboardSilently`, `readClipboard` — safe across insecure-origin contexts. Use these instead of `navigator.clipboard.writeText` inline. | +| `falVideoHandoff.js` | `openFalH3MaxFreeTool({prompt, negativePrompt})` opens fal's free MiniMax H3 Max browser tool under the originating click and copies a prepared shot prompt; `buildFalH3MaxPrompt` and `FAL_H3_MAX_FREE_URL` expose the pure prompt/URL contract. The free web allowance is deliberately a handoff, not the separately metered fal API. | | `compareHelpers.js` | `equalByKeys(a, b, keys)` / `equalListByKeys(a, b, keys)` — typed key-based equality for `useAutoRefetch`'s `compare`. Keys are property names, dotted paths (`'context.running'`), or `(item) => value` accessors. The typed alternative to `sameJsonShape` when a monotonic timestamp or unrendered field would break stringify-equality dedup. | | `consoleFilters.js` | `installConsoleFilters()` — drops a small allow-list of known-noise console strings (THREE.js `Clock` deprecation, expected WebGL `Context Lost.`) from `console.{warn,log,debug}`. Idempotent; auto-installed on import. Imported for its side effect from `main.jsx`. | | `cosTaskType.js` | CoS task-learning bucket resolver for ETA surfaces: `extractCosTaskType(task)` mirrors the server's metadata-first `extractTaskType` handling for live tasks and archived-agent projections, so `self-improve:*`, `user-task`, and fallback estimates use the same history bucket that recorded the run. | diff --git a/client/src/lib/falVideoHandoff.js b/client/src/lib/falVideoHandoff.js new file mode 100644 index 0000000000..46ada5b608 --- /dev/null +++ b/client/src/lib/falVideoHandoff.js @@ -0,0 +1,28 @@ +import { copyToClipboard } from './clipboard.js'; + +export const FAL_H3_MAX_FREE_URL = 'https://fal.ai/tools/minimax-h3-max'; + +/** + * fal's free H3 Max allowance lives in its browser tool rather than its + * metered API. Keep the handoff prompt provider-neutral: the authored shot is + * preserved verbatim and PortOS's avoid list becomes an explicit final block. + */ +export function buildFalH3MaxPrompt(prompt, negativePrompt = '') { + const shot = typeof prompt === 'string' ? prompt.trim() : ''; + const avoid = typeof negativePrompt === 'string' ? negativePrompt.trim() : ''; + if (!shot) return ''; + return avoid ? `${shot}\n\nAvoid: ${avoid}` : shot; +} + +/** + * Open the free browser tool while the click still owns user activation, then + * copy the prepared prompt. Opening first avoids popup blockers caused by + * awaiting the Clipboard API before window.open(). + */ +export function openFalH3MaxFreeTool({ prompt, negativePrompt = '' } = {}) { + const prepared = buildFalH3MaxPrompt(prompt, negativePrompt); + if (!prepared) return false; + globalThis.open?.(FAL_H3_MAX_FREE_URL, '_blank', 'noopener,noreferrer'); + void copyToClipboard(prepared, 'fal H3 Max prompt copied — paste it into the free tool'); + return true; +} diff --git a/client/src/lib/falVideoHandoff.test.js b/client/src/lib/falVideoHandoff.test.js new file mode 100644 index 0000000000..d217743383 --- /dev/null +++ b/client/src/lib/falVideoHandoff.test.js @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + buildFalH3MaxPrompt, FAL_H3_MAX_FREE_URL, openFalH3MaxFreeTool, +} from './falVideoHandoff.js'; + +const toastMocks = vi.hoisted(() => ({ error: vi.fn(), success: vi.fn() })); +vi.mock('../components/ui/Toast', () => ({ default: toastMocks })); + +describe('fal H3 Max free-tool handoff', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('open', vi.fn()); + vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(async () => {}) } }); + }); + + it('preserves the shot and appends a non-empty avoid list', () => { + expect(buildFalH3MaxPrompt(' One continuous tracking shot. ', ' cuts, logos ')) + .toBe('One continuous tracking shot.\n\nAvoid: cuts, logos'); + expect(buildFalH3MaxPrompt('A quiet room.', ' ')).toBe('A quiet room.'); + }); + + it('opens the free tool and copies the prepared prompt', async () => { + expect(openFalH3MaxFreeTool({ prompt: 'The door opens.', negativePrompt: 'cuts' })).toBe(true); + expect(globalThis.open).toHaveBeenCalledWith(FAL_H3_MAX_FREE_URL, '_blank', 'noopener,noreferrer'); + await vi.waitFor(() => expect(globalThis.navigator.clipboard.writeText) + .toHaveBeenCalledWith('The door opens.\n\nAvoid: cuts')); + }); + + it('does nothing when no prompt is ready', () => { + expect(openFalH3MaxFreeTool({ prompt: ' ' })).toBe(false); + expect(globalThis.open).not.toHaveBeenCalled(); + }); +}); diff --git a/client/src/lib/index.js b/client/src/lib/index.js index 96ada92f8a..1239797905 100644 --- a/client/src/lib/index.js +++ b/client/src/lib/index.js @@ -29,6 +29,7 @@ export * from './creativeDirectorPlan.js'; export * from './creativeDirectorPreview.js'; export * from './editorialRoadmap.js'; export * from './federatedMediaReadiness.js'; +export * from './falVideoHandoff.js'; export * from './fableLoomReadiness.js'; export * from './glbFailure.js'; export * from './grokVideoClip.js'; diff --git a/client/src/pages/FableLoomStory.jsx b/client/src/pages/FableLoomStory.jsx index d234d50bb3..ffc3a9a622 100644 --- a/client/src/pages/FableLoomStory.jsx +++ b/client/src/pages/FableLoomStory.jsx @@ -43,6 +43,7 @@ import { } from '../components/fableloom/sceneMediaRequests'; import { universeStylePreset } from '../lib/universeStylePreset'; import { fableLoomMediaReadiness } from '../lib/fableLoomReadiness'; +import { openFalH3MaxFreeTool } from '../lib/falVideoHandoff'; import { LOOM_ORIENTATION, LOOM_STACK_WIDTH } from '../lib/loomLayout'; import { addLoomEpisode, addLoomNode, deleteLoomEpisode, generateImage, generateVideo, @@ -330,6 +331,26 @@ export default function FableLoomStory({ view = 'graph' }) { return queued; }, [episodeId, generationDisabledReason, loom, mediaReadiness.reason, mediaWorkflowBlocked, sceneStylePreset, setSceneMediaJob, styleContextLoading, styleContextUnavailable]); + const openFalSceneVideo = useCallback((targetNode) => { + const prompt = (targetNode?.videoPrompt || '').trim() || (targetNode?.prose || '').trim(); + if (!prompt) { + toast.error('Write the scene first'); + return false; + } + if (mediaWorkflowBlocked) { + toast.error(mediaReadiness.reason); + return false; + } + if (styleContextLoading || styleContextUnavailable) { + toast.error(generationDisabledReason || 'Scene style is not ready'); + return false; + } + const request = buildFableLoomVideoRequest({ + loom, episodeId, node: targetNode, stylePreset: sceneStylePreset, + }); + return openFalH3MaxFreeTool(request); + }, [episodeId, generationDisabledReason, loom, mediaReadiness.reason, mediaWorkflowBlocked, sceneStylePreset, styleContextLoading, styleContextUnavailable]); + const basePath = `/fableloom/${loomId}`; const episodePath = useCallback( (epId, nId) => `${basePath}/${epId}${nId ? `/${nId}` : ''}`, @@ -569,6 +590,7 @@ export default function FableLoomStory({ view = 'graph' }) { mediaJobs={mediaJobs} onGenerateImage={queueSceneImage} onGenerateVideo={queueSceneVideo} + onOpenFalVideo={openFalSceneVideo} generationDisabled={styleContextLoading || styleContextUnavailable || mediaWorkflowBlocked} generationDisabledReason={mediaGenerationDisabledReason} /> @@ -642,6 +664,7 @@ export default function FableLoomStory({ view = 'graph' }) { mediaJobs={mediaJobs[node.id]} onGenerateImage={queueSceneImage} onGenerateVideo={queueSceneVideo} + onOpenFalVideo={openFalSceneVideo} generationDisabled={styleContextLoading || styleContextUnavailable || mediaWorkflowBlocked} generationDisabledReason={mediaGenerationDisabledReason} onMakeStart={node.id !== episode.startNodeId ? async () => { diff --git a/client/src/pages/VideoGen.composeWhileBusy.test.jsx b/client/src/pages/VideoGen.composeWhileBusy.test.jsx index 3176b1b780..0967fdac96 100644 --- a/client/src/pages/VideoGen.composeWhileBusy.test.jsx +++ b/client/src/pages/VideoGen.composeWhileBusy.test.jsx @@ -151,6 +151,11 @@ describe('VideoGen compose-while-busy', () => { state.attach.mockReset().mockReturnValue(new Promise(() => {})); state.enqueue.mockReset(); state.eventSourceRef.current = null; + vi.stubGlobal('open', vi.fn()); + Object.defineProperty(globalThis.navigator, 'clipboard', { + configurable: true, + value: { writeText: vi.fn(async () => {}) }, + }); }); it('starts runtime installation through the non-idempotent POST stream', async () => { @@ -187,4 +192,24 @@ describe('VideoGen compose-while-busy', () => { expect(screen.getByTestId('prompt-from-media')).toHaveAttribute('data-disabled', '0'); expect(screen.getByRole('button', { name: /Add to queue/ })).toBeEnabled(); }); + + it('copies the composed prompt and opens fal H3 Max without queueing a paid API job', async () => { + await act(async () => { + render( + + + , + ); + }); + + fireEvent.change(await screen.findByLabelText('Prompt'), { target: { value: 'A clockwork bird takes flight.' } }); + fireEvent.click(screen.getByRole('button', { name: 'Copy prompt & open fal.ai' })); + + expect(globalThis.open).toHaveBeenCalledWith( + 'https://fal.ai/tools/minimax-h3-max', '_blank', 'noopener,noreferrer', + ); + await waitFor(() => expect(globalThis.navigator.clipboard.writeText) + .toHaveBeenCalledWith('A clockwork bird takes flight.')); + expect(state.generateVideo).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx index 151f9e7b9c..ce166635df 100644 --- a/client/src/pages/VideoGen.jsx +++ b/client/src/pages/VideoGen.jsx @@ -56,6 +56,7 @@ import LiveVideoStage from '../components/videoGen/LiveVideoStage'; import { resolveVideoStagePreview, VIDEO_STAGE_KIND } from '../lib/videoStagePreview'; import VideoGenGallery from '../components/videoGen/VideoGenGallery'; import GalleryImagePicker from '../components/imageGen/GalleryImagePicker'; +import GalleryVideoPicker from '../components/videoGen/GalleryVideoPicker'; import MediaPreview from '../components/media/MediaPreview'; import StylePresetPicker from '../components/media/StylePresetPicker'; import PromptEnhancer from '../components/media/PromptEnhancer'; @@ -63,7 +64,7 @@ import PromptFromMedia from '../components/media/PromptFromMedia'; import { normalizeVideo } from '../components/media/normalize'; import { Film, Sparkles, Settings as SettingsIcon, RefreshCw, AlertTriangle, - X, Type, Image as ImageIcon, GitBranch, ListPlus, Music, SlidersHorizontal, + X, Type, Image as ImageIcon, GitBranch, ListPlus, Music, SlidersHorizontal, ExternalLink, } from 'lucide-react'; import toast from '../components/ui/Toast'; import BatchQueuePanel from '../components/media/BatchQueuePanel'; @@ -99,6 +100,7 @@ import ResolutionField from '../components/media/ResolutionField'; import { VIDEO_EDGE_BOUNDS, videoEdgeBoundsForModel, IC_LORA_MODES } from '../lib/videoGenParams.js'; import { finishTargetForRecord, isDeliveryVideoModel } from '../lib/videoFinish.js'; import { peerModelRequiresInput } from '../lib/federatedMediaReadiness.js'; +import { openFalH3MaxFreeTool } from '../lib/falVideoHandoff.js'; const MODES = [ { id: 'text', label: 'Text', icon: Type, desc: 'Text-to-video' }, { id: 'image', label: 'Image', icon: ImageIcon, desc: 'Image-to-video (start frame)' }, @@ -187,6 +189,16 @@ export default function VideoGen() { models, status, availableLoras, grokEnabled, remoteSubmissionFields: remoteTarget.isRemote ? remoteTarget.submissionFields : null, }); + + // fal's daily H3 Max allowance is browser-only; its API is a separately + // metered product. Keep this as an explicit handoff rather than pretending a + // provider POST can spend the free quota. The exact composed prompt (style + + // no-music envelope) is what gets copied, matching a normal submission. + const falFreeSupported = mode === 'text' || mode === 'image'; + const openFalFree = () => openFalH3MaxFreeTool({ + prompt: envelopedPrompt, + negativePrompt, + }); // Conditioning the selected peer model cannot take. The server refuses a job // holding any of it (MEDIA_PROVIDER_INPUT_UNSUPPORTED) rather than silently // rendering something else, so the form says so before the user commits. @@ -249,6 +261,7 @@ export default function VideoGen() { // `null` = closed; otherwise `{ kind, index? }` records which slot the pick // lands in, since one modal serves every slot. const [galleryPicker, setGalleryPicker] = useState(null); + const [falImportOpen, setFalImportOpen] = useState(false); const handleGalleryPick = (item) => { const filename = item?.filename; if (!filename || !galleryPicker) return; @@ -872,6 +885,44 @@ export default function VideoGen() { +
+
+

MiniMax H3 Max on fal.ai

+

+ fal offers up to 15 free browser-tool renders per day with an account. PortOS copies this form’s composed prompt and opens that tool; download the MP4, then upload it to Media History. fal’s app API is separately metered. +

+ {!falFreeSupported && ( +

+ Switch to Text or Image mode for the H3 Max free-tool handoff. +

+ )} +
+
+ + +
+
+ {status && status.connected === false && (() => { const missingCount = status.missingPackages?.length || 0; const hasPath = !!status.pythonPath; @@ -1524,6 +1575,17 @@ export default function VideoGen() { onSelect={handleGalleryPick} /> + setFalImportOpen(false)} + onSelect={() => { + refreshHistory(); + toast.success('Video imported to Media History'); + }} + allowUpload + uploadToGallery + /> + From 932de97b9c00bfbf7782ed5380b0f7886a7c0928 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Mon, 31 Aug 2026 01:50:58 +0000 Subject: [PATCH 2/2] fix: address fal video handoff review findings --- .../fableloom/LoomNodeEditor.test.jsx | 41 ++++++++++++++----- .../components/fableloom/LoomSceneMedia.jsx | 1 + .../videoGen/FalH3MaxPromptFallback.jsx | 40 ++++++++++++++++++ .../videoGen/GalleryVideoPicker.jsx | 13 ++++-- .../videoGen/GalleryVideoPicker.test.jsx | 13 +++--- client/src/lib/falVideoHandoff.js | 11 ++++- client/src/lib/falVideoHandoff.test.js | 12 ++++++ client/src/pages/FableLoomStory.jsx | 12 +++++- client/src/pages/VideoGen.jsx | 18 ++++++-- 9 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 client/src/components/videoGen/FalH3MaxPromptFallback.jsx diff --git a/client/src/components/fableloom/LoomNodeEditor.test.jsx b/client/src/components/fableloom/LoomNodeEditor.test.jsx index fe557142a5..a60f3b7c6a 100644 --- a/client/src/components/fableloom/LoomNodeEditor.test.jsx +++ b/client/src/components/fableloom/LoomNodeEditor.test.jsx @@ -12,15 +12,19 @@ vi.mock('../../services/api', () => ({ updateLoomTransition: vi.fn(), })); +const pickerMocks = vi.hoisted(() => ({ + item: { id: 'upload-example', filename: 'upload-example.mp4' }, + props: vi.fn(), +})); vi.mock('../videoGen/GalleryVideoPicker', () => ({ - default: ({ open, onSelect }) => open ? ( - - ) : null, + default: (props) => { + pickerMocks.props(props); + return props.open ? ( + + ) : null; + }, })); import { @@ -132,7 +136,10 @@ const renderCanonicalEditor = (presence = 'onscreen') => { ); }; -beforeEach(() => vi.clearAllMocks()); +beforeEach(() => { + vi.clearAllMocks(); + pickerMocks.item = { id: 'upload-example', filename: 'upload-example.mp4' }; +}); describe('LoomNodeEditor paths', () => { it('requires confirmation before deleting a scene', async () => { @@ -338,13 +345,27 @@ describe('LoomNodeEditor scene media', () => { renderEditor(); await user.click(screen.getByRole('button', { name: 'Attach video' })); - await user.click(screen.getByRole('button', { name: 'Pick fal MP4' })); + expect(pickerMocks.props).toHaveBeenLastCalledWith(expect.objectContaining({ + accept: 'video/mp4,.mp4', + })); + await user.click(screen.getByRole('button', { name: 'Pick gallery video' })); await waitFor(() => expect(updateLoomNode).toHaveBeenCalledWith( 'loom-1', 'ep-1', 'n1', { videoHistoryId: 'upload-example' }, { silent: true }, )); }); + it('refuses a non-MP4 history record that scene playback cannot address', async () => { + const user = userEvent.setup(); + pickerMocks.item = { id: 'upload-example', filename: 'upload-example.mov' }; + renderEditor(); + + await user.click(screen.getByRole('button', { name: 'Attach video' })); + await user.click(screen.getByRole('button', { name: 'Pick gallery video' })); + + expect(updateLoomNode).not.toHaveBeenCalled(); + }); + it('uses the scene for text-to-video when no rendered still exists', async () => { const user = userEvent.setup(); const onGenerateVideo = vi.fn().mockResolvedValue({ jobId: 'video-2' }); diff --git a/client/src/components/fableloom/LoomSceneMedia.jsx b/client/src/components/fableloom/LoomSceneMedia.jsx index 561930014d..b15e0c0eee 100644 --- a/client/src/components/fableloom/LoomSceneMedia.jsx +++ b/client/src/components/fableloom/LoomSceneMedia.jsx @@ -218,6 +218,7 @@ export default function LoomSceneMedia({ onSelect={(item) => onAttachVideo?.(node, item)} allowUpload uploadToGallery + accept="video/mp4,.mp4" /> )} diff --git a/client/src/components/videoGen/FalH3MaxPromptFallback.jsx b/client/src/components/videoGen/FalH3MaxPromptFallback.jsx new file mode 100644 index 0000000000..216f3a82ab --- /dev/null +++ b/client/src/components/videoGen/FalH3MaxPromptFallback.jsx @@ -0,0 +1,40 @@ +import Modal from '../ui/Modal'; + +export default function FalH3MaxPromptFallback({ prompt, onClose }) { + return ( + +
+
+

Copy the fal H3 Max prompt

+

+ Automatic clipboard access is unavailable here. Select the prepared prompt below, + copy it manually, and paste it into the fal.ai tab. +

+
+