Browse Source

fix: content-type fallback, replicate polling/validation, atomic gallery append

- fal.ts: Use model capabilities to determine fallback content-type
  (video/mp4 vs image/png) instead of defaulting to video for all models
- replicate.ts: Increase polling timeout to 10 min for video models;
  filter output array to valid strings before URL validation
- nanoBananaExecutor.ts: Replace read-modify-write on outputGallery with
  atomic appendOutputGalleryImage using Zustand set() callback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
424bf1b3e2
  1. 3
      src/app/api/generate/providers/fal.ts
  2. 14
      src/app/api/generate/providers/replicate.ts
  3. 1
      src/store/execution/__tests__/generateVideoExecutor.test.ts
  4. 1
      src/store/execution/__tests__/llmGenerateExecutor.test.ts
  5. 5
      src/store/execution/__tests__/nanoBananaExecutor.test.ts
  6. 1
      src/store/execution/__tests__/simpleNodeExecutors.test.ts
  7. 1
      src/store/execution/__tests__/splitGridExecutor.test.ts
  8. 1
      src/store/execution/__tests__/videoProcessingExecutors.test.ts
  9. 9
      src/store/execution/nanoBananaExecutor.ts
  10. 1
      src/store/execution/types.ts
  11. 21
      src/store/workflowStore.ts

3
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 isVideo = contentType.startsWith("video/");
const mediaArrayBuffer = await mediaResponse.arrayBuffer(); const mediaArrayBuffer = await mediaResponse.arrayBuffer();

14
src/app/api/generate/providers/replicate.ts

@ -156,8 +156,9 @@ export async function generateWithReplicate(
const prediction = await createResponse.json(); const prediction = await createResponse.json();
console.log(`[API:${requestId}] Prediction created: ${prediction.id}`); console.log(`[API:${requestId}] Prediction created: ${prediction.id}`);
// Poll for completion // Poll for completion — video models get a longer timeout
const maxWaitTime = 5 * 60 * 1000; // 5 minutes 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 pollInterval = 1000; // 1 second
const startTime = Date.now(); const startTime = Date.now();
@ -172,7 +173,7 @@ export async function generateWithReplicate(
if (Date.now() - startTime > maxWaitTime) { if (Date.now() - startTime > maxWaitTime) {
return { return {
success: false, 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 // Output can be a single URL string or an array — filter to valid strings only
const outputUrls: string[] = Array.isArray(output) ? output : [output]; 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) { if (outputUrls.length === 0) {
return { return {

1
src/store/execution/__tests__/generateVideoExecutor.test.ts

@ -61,6 +61,7 @@ function makeCtx(
generationsPath: null, generationsPath: null,
saveDirectoryPath: null, saveDirectoryPath: null,
trackSaveGeneration: vi.fn(), trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn(), get: vi.fn(),
...overrides, ...overrides,
}; };

1
src/store/execution/__tests__/llmGenerateExecutor.test.ts

@ -61,6 +61,7 @@ function makeCtx(
generationsPath: null, generationsPath: null,
saveDirectoryPath: null, saveDirectoryPath: null,
trackSaveGeneration: vi.fn(), trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn(), get: vi.fn(),
...overrides, ...overrides,
}; };

5
src/store/execution/__tests__/nanoBananaExecutor.test.ts

@ -71,6 +71,7 @@ function makeCtx(
generationsPath: null, generationsPath: null,
saveDirectoryPath: null, saveDirectoryPath: null,
trackSaveGeneration: vi.fn(), trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn().mockReturnValue({ get: vi.fn().mockReturnValue({
edges: [], edges: [],
nodes: [node], nodes: [node],
@ -341,8 +342,6 @@ describe("executeNanoBanana", () => {
await executeNanoBanana(ctx); await executeNanoBanana(ctx);
expect(ctx.updateNodeData).toHaveBeenCalledWith("gal-1", { expect(ctx.appendOutputGalleryImage).toHaveBeenCalledWith("gal-1", "data:image/png;base64,result");
images: ["data:image/png;base64,result", "old.png"],
});
}); });
}); });

1
src/store/execution/__tests__/simpleNodeExecutors.test.ts

@ -34,6 +34,7 @@ function makeCtx(
generationsPath: null, generationsPath: null,
saveDirectoryPath: null, saveDirectoryPath: null,
trackSaveGeneration: vi.fn(), trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn(), get: vi.fn(),
...overrides, ...overrides,
}; };

1
src/store/execution/__tests__/splitGridExecutor.test.ts

@ -84,6 +84,7 @@ function makeCtx(
generationsPath: null, generationsPath: null,
saveDirectoryPath: null, saveDirectoryPath: null,
trackSaveGeneration: vi.fn(), trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn(), get: vi.fn(),
...overrides, ...overrides,
}; };

1
src/store/execution/__tests__/videoProcessingExecutors.test.ts

@ -48,6 +48,7 @@ function makeCtx(
generationsPath: null, generationsPath: null,
saveDirectoryPath: null, saveDirectoryPath: null,
trackSaveGeneration: vi.fn(), trackSaveGeneration: vi.fn(),
appendOutputGalleryImage: vi.fn(),
get: vi.fn(), get: vi.fn(),
...overrides, ...overrides,
}; };

9
src/store/execution/nanoBananaExecutor.ts

@ -7,7 +7,6 @@
import type { import type {
NanoBananaNodeData, NanoBananaNodeData,
OutputGalleryNodeData,
} from "@/types"; } from "@/types";
import { calculateGenerationCost } from "@/utils/costCalculator"; import { calculateGenerationCost } from "@/utils/costCalculator";
import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders";
@ -35,6 +34,7 @@ export async function executeNanoBanana(
addToGlobalHistory, addToGlobalHistory,
generationsPath, generationsPath,
trackSaveGeneration, trackSaveGeneration,
appendOutputGalleryImage,
get, get,
} = ctx; } = ctx;
@ -150,7 +150,7 @@ export async function executeNanoBanana(
selectedHistoryIndex: 0, selectedHistoryIndex: 0,
}); });
// Push new image to connected downstream outputGallery nodes // Push new image to connected downstream outputGallery nodes (atomic append)
const edges = getEdges(); const edges = getEdges();
const nodes = getNodes(); const nodes = getNodes();
edges edges
@ -158,10 +158,7 @@ export async function executeNanoBanana(
.forEach((e) => { .forEach((e) => {
const target = nodes.find((n) => n.id === e.target); const target = nodes.find((n) => n.id === e.target);
if (target?.type === "outputGallery") { if (target?.type === "outputGallery") {
const gData = target.data as OutputGalleryNodeData; appendOutputGalleryImage(target.id, result.image);
updateNodeData(target.id, {
images: [result.image, ...(gData.images || [])],
});
} }
}); });

1
src/store/execution/types.ts

@ -46,6 +46,7 @@ export interface NodeExecutionContext {
generationsPath: string | null; generationsPath: string | null;
saveDirectoryPath: string | null; saveDirectoryPath: string | null;
trackSaveGeneration: (key: string, promise: Promise<void>) => void; trackSaveGeneration: (key: string, promise: Promise<void>) => void;
appendOutputGalleryImage: (targetId: string, image: string) => void;
get: () => unknown; get: () => unknown;
} }

21
src/store/workflowStore.ts

@ -14,6 +14,7 @@ import {
WorkflowEdge, WorkflowEdge,
NodeType, NodeType,
NanoBananaNodeData, NanoBananaNodeData,
OutputGalleryNodeData,
WorkflowNodeData, WorkflowNodeData,
ImageHistoryItem, ImageHistoryItem,
NodeGroup, NodeGroup,
@ -815,6 +816,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
pendingImageSyncs.set(key, promise); pendingImageSyncs.set(key, promise);
promise.finally(() => pendingImageSyncs.delete(key)); 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, get: get as () => unknown,
}; };
@ -1022,6 +1033,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
pendingImageSyncs.set(key, promise); pendingImageSyncs.set(key, promise);
promise.finally(() => pendingImageSyncs.delete(key)); 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, get: get as () => unknown,
}; };

Loading…
Cancel
Save