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 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();
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 {

1
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,
};

1
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,
};

5
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");
});
});

1
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,
};

1
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,
};

1
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,
};

9
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);
}
});

1
src/store/execution/types.ts

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

21
src/store/workflowStore.ts

@ -14,6 +14,7 @@ import {
WorkflowEdge,
NodeType,
NanoBananaNodeData,
OutputGalleryNodeData,
WorkflowNodeData,
ImageHistoryItem,
NodeGroup,
@ -815,6 +816,16 @@ export const useWorkflowStore = create<WorkflowStore>((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<WorkflowStore>((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,
};

Loading…
Cancel
Save