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
2 changes: 2 additions & 0 deletions client/src/components/fableloom/LoomCanvas.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <g>'s
Expand Down Expand Up @@ -328,6 +329,7 @@ export default function LoomCanvas({
jobs={mediaJobs[node.id]}
onGenerateImage={onGenerateImage}
onGenerateVideo={onGenerateVideo}
onOpenFalVideo={onOpenFalVideo}
compact
generationDisabled={generationDisabled}
generationDisabledReason={generationDisabledReason}
Expand Down
4 changes: 4 additions & 0 deletions client/src/components/fableloom/LoomCanvas.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -83,15 +84,18 @@ describe('LoomCanvas', () => {
onSelectNode={() => {}}
onGenerateImage={onGenerateImage}
onGenerateVideo={onGenerateVideo}
onOpenFalVideo={onOpenFalVideo}
/>,
);

expect(screen.getByLabelText('The Gate video preview')).toBeInTheDocument();
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', () => {
Expand Down
40 changes: 39 additions & 1 deletion client/src/components/fableloom/LoomNodeEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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}
/>
</div>

Expand Down
66 changes: 64 additions & 2 deletions client/src/components/fableloom/LoomNodeEditor.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 ? (
<button type="button" onClick={() => props.onSelect(pickerMocks.item)}>
Pick gallery video
</button>
) : null;
},
}));

import {
addLoomTransition, branchLoomNode, deleteLoomNode, deleteLoomTransition, updateLoomNode, updateLoomTransition,
} from '../../services/api';
Expand Down Expand Up @@ -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(
<MemoryRouter>
<LoomNodeEditor
Expand All @@ -55,10 +71,11 @@ const renderEditor = (transitions = [existingPath]) => {
onClearSelection={() => {}}
onGenerateImage={onGenerateImage}
onGenerateVideo={onGenerateVideo}
onOpenFalVideo={onOpenFalVideo}
/>
</MemoryRouter>,
);
return { onLoomUpdate, onGenerateImage, onGenerateVideo };
return { onLoomUpdate, onGenerateImage, onGenerateVideo, onOpenFalVideo };
};

const renderHelperEditor = () => {
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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' });
Expand Down
45 changes: 43 additions & 2 deletions client/src/components/fableloom/LoomSceneMedia.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -144,7 +153,7 @@ export default function LoomSceneMedia({
</div>

<div
className="grid shrink-0 grid-cols-2 gap-1"
className={`grid shrink-0 gap-1 ${compact ? 'grid-cols-3' : 'grid-cols-2'}`}
onPointerDown={stopNodeActivation}
onClick={stopNodeActivation}
onKeyDown={stopNodeActivation}
Expand All @@ -169,6 +178,28 @@ export default function LoomSceneMedia({
{videoActive ? <Loader2 size={compact ? 10 : 12} className="animate-spin" /> : <Video size={compact ? 10 : 12} />}
<span className="truncate">{videoActive ? 'Generating video' : node.videoHistoryId ? 'Regenerate video' : 'Generate video'}</span>
</button>
<button
type="button"
onClick={() => onOpenFalVideo?.(node)}
disabled={freeToolDisabled || !onOpenFalVideo}
title={freeToolDisabledReason || 'Copy this scene prompt and open fal H3 Max (up to 15 free browser renders per day with an account)'}
className={buttonClass}
>
<ExternalLink size={compact ? 10 : 12} aria-hidden="true" />
<span className="truncate">fal.ai free</span>
</button>
{!compact && (
<button
type="button"
onClick={() => setVideoPickerOpen(true)}
disabled={!onAttachVideo}
title="Attach a downloaded fal MP4 or another video from Media History"
className={buttonClass}
>
<Upload size={12} aria-hidden="true" />
<span className="truncate">Attach video</span>
</button>
)}
</div>

{noticeLabel && !compact && (
Expand All @@ -180,6 +211,16 @@ export default function LoomSceneMedia({
{generationDisabledReason && !noticeLabel && !compact && (
<p className="text-xs text-port-text-muted" role="status">{generationDisabledReason}</p>
)}
{!compact && (
<GalleryVideoPicker
open={videoPickerOpen}
onClose={() => setVideoPickerOpen(false)}
onSelect={(item) => onAttachVideo?.(node, item)}
allowUpload
uploadToGallery
accept="video/mp4,.mp4"
/>
)}
</div>
);
}
40 changes: 40 additions & 0 deletions client/src/components/videoGen/FalH3MaxPromptFallback.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import Modal from '../ui/Modal';

export default function FalH3MaxPromptFallback({ prompt, onClose }) {
return (
<Modal
open={Boolean(prompt)}
onClose={onClose}
size="lg"
usePortal
ariaLabel="Copy fal H3 Max prompt manually"
>
<div className="space-y-3 rounded-xl border border-port-border bg-port-card p-4">
<div>
<h2 className="text-base font-semibold text-port-text">Copy the fal H3 Max prompt</h2>
<p className="mt-1 text-sm text-port-text-muted">
Automatic clipboard access is unavailable here. Select the prepared prompt below,
copy it manually, and paste it into the fal.ai tab.
</p>
</div>
<textarea
aria-label="Prepared fal H3 Max prompt"
readOnly
value={prompt || ''}
onFocus={(event) => event.currentTarget.select()}
rows={12}
className="w-full resize-y rounded-lg border border-port-border bg-port-bg p-3 text-sm text-port-text"
/>
<div className="flex justify-end">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-port-border px-3 py-2 text-sm text-port-text hover:border-port-accent hover:text-port-accent"
>
Done
</button>
</div>
</div>
</Modal>
);
}
13 changes: 10 additions & 3 deletions client/src/components/videoGen/GalleryVideoPicker.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,14 @@ import toast from '../ui/Toast';

const VIDEO_ACCEPT = 'video/mp4,video/webm,video/quicktime,video/x-m4v,.mp4,.webm,.mov,.m4v';

export default function GalleryVideoPicker({ open, onClose, onSelect, allowUpload = false, uploadToGallery = false }) {
export default function GalleryVideoPicker({
open,
onClose,
onSelect,
allowUpload = false,
uploadToGallery = false,
accept = VIDEO_ACCEPT,
}) {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [uploading, setUploading] = useState(false);
Expand Down Expand Up @@ -87,7 +94,7 @@ export default function GalleryVideoPicker({ open, onClose, onSelect, allowUploa
if (sessionRef.current !== session) return;
setUploading(false);
if (!entry?.filename) return;
onSelect?.(normalizeVideo(entry));
onSelect?.(normalizeVideo(entry), { origin: 'upload' });
onClose?.();
return;
}
Expand Down Expand Up @@ -122,7 +129,7 @@ export default function GalleryVideoPicker({ open, onClose, onSelect, allowUploa
<div className="flex items-center gap-2 shrink-0">
{allowUpload && (
<FilePickerButton
accept={VIDEO_ACCEPT}
accept={accept}
onChange={handleUpload}
disabled={uploading}
title="Upload a video from your device"
Expand Down
13 changes: 8 additions & 5 deletions client/src/components/videoGen/GalleryVideoPicker.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,14 @@ describe('GalleryVideoPicker', () => {
const fileInput = document.querySelector('input[type="file"]');
fireEvent.change(fileInput, { target: { files: [new File(['x'], 'clip.mp4', { type: 'video/mp4' })] } });
await waitFor(() => expect(uploadGalleryVideo).toHaveBeenCalledWith('ZmFrZQ==', 'clip.mp4', { silent: true }));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({
kind: 'video',
filename: 'upload-ab12cd34.mp4',
previewUrl: '/data/video-thumbnails/upload-ab12cd34.jpg',
})));
await waitFor(() => expect(onSelect).toHaveBeenCalledWith(
expect.objectContaining({
kind: 'video',
filename: 'upload-ab12cd34.mp4',
previewUrl: '/data/video-thumbnails/upload-ab12cd34.jpg',
}),
{ origin: 'upload' },
));
expect(onClose).toHaveBeenCalled();
});

Expand Down
1 change: 1 addition & 0 deletions client/src/lib/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
Loading