diff --git a/src/app/api/generate/providers/fal.ts b/src/app/api/generate/providers/fal.ts index 3aa1463e..aff62f91 100644 --- a/src/app/api/generate/providers/fal.ts +++ b/src/app/api/generate/providers/fal.ts @@ -494,7 +494,8 @@ export async function generateWithFalQueue( }; } - const contentType = mediaResponse.headers.get("content-type") || "video/mp4"; + const isVideoModel = input.model.capabilities.some(c => c.includes("video")); + const contentType = mediaResponse.headers.get("content-type") || (isVideoModel ? "video/mp4" : "image/png"); const isVideo = contentType.startsWith("video/"); const mediaArrayBuffer = await mediaResponse.arrayBuffer(); diff --git a/src/app/api/generate/providers/replicate.ts b/src/app/api/generate/providers/replicate.ts index 1c983565..78113c26 100644 --- a/src/app/api/generate/providers/replicate.ts +++ b/src/app/api/generate/providers/replicate.ts @@ -156,8 +156,9 @@ export async function generateWithReplicate( const prediction = await createResponse.json(); console.log(`[API:${requestId}] Prediction created: ${prediction.id}`); - // Poll for completion - const maxWaitTime = 5 * 60 * 1000; // 5 minutes + // Poll for completion — video models get a longer timeout + const isVideoModel = input.model.capabilities.some(c => c.includes("video")); + const maxWaitTime = isVideoModel ? 10 * 60 * 1000 : 5 * 60 * 1000; const pollInterval = 1000; // 1 second const startTime = Date.now(); @@ -172,7 +173,7 @@ export async function generateWithReplicate( if (Date.now() - startTime > maxWaitTime) { return { success: false, - error: `${input.model.name}: Generation timed out after 5 minutes. Video models may take longer - try again.`, + error: `${input.model.name}: Generation timed out after ${maxWaitTime / 60000} minutes.`, }; } @@ -225,8 +226,11 @@ export async function generateWithReplicate( }; } - // Output can be a single URL string or an array of URLs - const outputUrls: string[] = Array.isArray(output) ? output : [output]; + // Output can be a single URL string or an array — filter to valid strings only + const rawOutputs = Array.isArray(output) ? output : [output]; + const outputUrls: string[] = rawOutputs.filter( + (v): v is string => typeof v === "string" && v.length > 0 + ); if (outputUrls.length === 0) { return { diff --git a/src/store/execution/__tests__/generateVideoExecutor.test.ts b/src/store/execution/__tests__/generateVideoExecutor.test.ts index 4032066c..e7e54f7c 100644 --- a/src/store/execution/__tests__/generateVideoExecutor.test.ts +++ b/src/store/execution/__tests__/generateVideoExecutor.test.ts @@ -61,6 +61,7 @@ function makeCtx( generationsPath: null, saveDirectoryPath: null, trackSaveGeneration: vi.fn(), + appendOutputGalleryImage: vi.fn(), get: vi.fn(), ...overrides, }; diff --git a/src/store/execution/__tests__/llmGenerateExecutor.test.ts b/src/store/execution/__tests__/llmGenerateExecutor.test.ts index 98ebe15a..73cc2864 100644 --- a/src/store/execution/__tests__/llmGenerateExecutor.test.ts +++ b/src/store/execution/__tests__/llmGenerateExecutor.test.ts @@ -61,6 +61,7 @@ function makeCtx( generationsPath: null, saveDirectoryPath: null, trackSaveGeneration: vi.fn(), + appendOutputGalleryImage: vi.fn(), get: vi.fn(), ...overrides, }; diff --git a/src/store/execution/__tests__/nanoBananaExecutor.test.ts b/src/store/execution/__tests__/nanoBananaExecutor.test.ts index 65f1fc7c..bf31cca8 100644 --- a/src/store/execution/__tests__/nanoBananaExecutor.test.ts +++ b/src/store/execution/__tests__/nanoBananaExecutor.test.ts @@ -71,6 +71,7 @@ function makeCtx( generationsPath: null, saveDirectoryPath: null, trackSaveGeneration: vi.fn(), + appendOutputGalleryImage: vi.fn(), get: vi.fn().mockReturnValue({ edges: [], nodes: [node], @@ -341,8 +342,6 @@ describe("executeNanoBanana", () => { await executeNanoBanana(ctx); - expect(ctx.updateNodeData).toHaveBeenCalledWith("gal-1", { - images: ["data:image/png;base64,result", "old.png"], - }); + expect(ctx.appendOutputGalleryImage).toHaveBeenCalledWith("gal-1", "data:image/png;base64,result"); }); }); diff --git a/src/store/execution/__tests__/simpleNodeExecutors.test.ts b/src/store/execution/__tests__/simpleNodeExecutors.test.ts index 11ce3f0d..a627fd6c 100644 --- a/src/store/execution/__tests__/simpleNodeExecutors.test.ts +++ b/src/store/execution/__tests__/simpleNodeExecutors.test.ts @@ -34,6 +34,7 @@ function makeCtx( generationsPath: null, saveDirectoryPath: null, trackSaveGeneration: vi.fn(), + appendOutputGalleryImage: vi.fn(), get: vi.fn(), ...overrides, }; diff --git a/src/store/execution/__tests__/splitGridExecutor.test.ts b/src/store/execution/__tests__/splitGridExecutor.test.ts index fc86a678..74e26c6e 100644 --- a/src/store/execution/__tests__/splitGridExecutor.test.ts +++ b/src/store/execution/__tests__/splitGridExecutor.test.ts @@ -84,6 +84,7 @@ function makeCtx( generationsPath: null, saveDirectoryPath: null, trackSaveGeneration: vi.fn(), + appendOutputGalleryImage: vi.fn(), get: vi.fn(), ...overrides, }; diff --git a/src/store/execution/__tests__/videoProcessingExecutors.test.ts b/src/store/execution/__tests__/videoProcessingExecutors.test.ts index ef2db9e3..14588d09 100644 --- a/src/store/execution/__tests__/videoProcessingExecutors.test.ts +++ b/src/store/execution/__tests__/videoProcessingExecutors.test.ts @@ -48,6 +48,7 @@ function makeCtx( generationsPath: null, saveDirectoryPath: null, trackSaveGeneration: vi.fn(), + appendOutputGalleryImage: vi.fn(), get: vi.fn(), ...overrides, }; diff --git a/src/store/execution/nanoBananaExecutor.ts b/src/store/execution/nanoBananaExecutor.ts index 627d27a5..b178bc7c 100644 --- a/src/store/execution/nanoBananaExecutor.ts +++ b/src/store/execution/nanoBananaExecutor.ts @@ -7,7 +7,6 @@ import type { NanoBananaNodeData, - OutputGalleryNodeData, } from "@/types"; import { calculateGenerationCost } from "@/utils/costCalculator"; import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; @@ -35,6 +34,7 @@ export async function executeNanoBanana( addToGlobalHistory, generationsPath, trackSaveGeneration, + appendOutputGalleryImage, get, } = ctx; @@ -150,7 +150,7 @@ export async function executeNanoBanana( selectedHistoryIndex: 0, }); - // Push new image to connected downstream outputGallery nodes + // Push new image to connected downstream outputGallery nodes (atomic append) const edges = getEdges(); const nodes = getNodes(); edges @@ -158,10 +158,7 @@ export async function executeNanoBanana( .forEach((e) => { const target = nodes.find((n) => n.id === e.target); if (target?.type === "outputGallery") { - const gData = target.data as OutputGalleryNodeData; - updateNodeData(target.id, { - images: [result.image, ...(gData.images || [])], - }); + appendOutputGalleryImage(target.id, result.image); } }); diff --git a/src/store/execution/types.ts b/src/store/execution/types.ts index 470c852c..861b7956 100644 --- a/src/store/execution/types.ts +++ b/src/store/execution/types.ts @@ -46,6 +46,7 @@ export interface NodeExecutionContext { generationsPath: string | null; saveDirectoryPath: string | null; trackSaveGeneration: (key: string, promise: Promise) => void; + appendOutputGalleryImage: (targetId: string, image: string) => void; get: () => unknown; } diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 70107aeb..f24f917e 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -14,6 +14,7 @@ import { WorkflowEdge, NodeType, NanoBananaNodeData, + OutputGalleryNodeData, WorkflowNodeData, ImageHistoryItem, NodeGroup, @@ -815,6 +816,16 @@ export const useWorkflowStore = create((set, get) => ({ pendingImageSyncs.set(key, promise); promise.finally(() => pendingImageSyncs.delete(key)); }, + appendOutputGalleryImage: (targetId: string, image: string) => { + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === targetId && n.type === "outputGallery" + ? { ...n, data: { ...n.data, images: [image, ...((n.data as OutputGalleryNodeData).images || [])] } as WorkflowNodeData } + : n + ) as WorkflowNode[], + hasUnsavedChanges: true, + })); + }, get: get as () => unknown, }; @@ -1022,6 +1033,16 @@ export const useWorkflowStore = create((set, get) => ({ pendingImageSyncs.set(key, promise); promise.finally(() => pendingImageSyncs.delete(key)); }, + appendOutputGalleryImage: (targetId: string, image: string) => { + set((state) => ({ + nodes: state.nodes.map((n) => + n.id === targetId && n.type === "outputGallery" + ? { ...n, data: { ...n.data, images: [image, ...((n.data as OutputGalleryNodeData).images || [])] } as WorkflowNodeData } + : n + ) as WorkflowNode[], + hasUnsavedChanges: true, + })); + }, get: get as () => unknown, };