diff --git a/client/src/components/fableloom/LoomCanvas.jsx b/client/src/components/fableloom/LoomCanvas.jsx index e9276bc89..7fffba600 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 d7b88fc0f..e6d2b0d50 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 c45cba3d0..a81171f37 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 3f8b45cc4..a60f3b7c6 100644 --- a/client/src/components/fableloom/LoomNodeEditor.test.jsx +++ b/client/src/components/fableloom/LoomNodeEditor.test.jsx @@ -12,6 +12,21 @@ 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: (props) => { + pickerMocks.props(props); + return props.open ? ( + + ) : null; + }, +})); + import { addLoomTransition, branchLoomNode, deleteLoomNode, deleteLoomTransition, updateLoomNode, updateLoomTransition, } from '../../services/api'; @@ -39,6 +54,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 = () => { @@ -119,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 () => { @@ -304,6 +324,48 @@ 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' })); + 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 dd7d497e1..b15e0c0ee 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,16 @@ export default function LoomSceneMedia({ {generationDisabledReason && !noticeLabel && !compact && (

{generationDisabledReason}

)} + {!compact && ( + setVideoPickerOpen(false)} + 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 000000000..216f3a82a --- /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. +

+
+