From 8a2ed02ff6fa232eac70f23619556442ba881a0d Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sun, 18 Jan 2026 01:04:07 +1300 Subject: [PATCH] fix: resolve image history race condition and duplicate images on save Two related issues fixed: 1. Image history IDs not syncing before workflow save (PR#27 follow-up): - Added pending sync tracking for save-generation API calls - saveToFile() now awaits all pending syncs before saving - Ensures imageHistory/videoHistory IDs match actual saved filenames 2. Duplicate images created on every save: - After externalization, store nodes now receive the image refs - Subsequent saves recognize existing refs and skip creating new files - Prevents inputs/ and generations/ folders from accumulating duplicates Co-Authored-By: Claude Opus 4.5 --- src/store/workflowStore.ts | 309 ++++++++++++++++--------------------- 1 file changed, 129 insertions(+), 180 deletions(-) diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index 7d907e09..001a692d 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -195,6 +195,75 @@ let nodeIdCounter = 0; let groupIdCounter = 0; let autoSaveIntervalId: ReturnType | null = null; +// Track pending save-generation syncs to ensure IDs are resolved before workflow save +const pendingImageSyncs = new Map>(); + +// Helper to save a generation and sync the history ID +// Returns immediately but tracks the async operation for later awaiting +function trackSaveGeneration( + genPath: string, + content: { image?: string; video?: string }, + prompt: string | null, + tempId: string, + nodeId: string, + historyType: 'image' | 'video', + get: () => WorkflowStore, + updateNodeData: (nodeId: string, data: Partial) => void +): void { + const syncPromise = fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: genPath, + image: content.image, + video: content.video, + prompt, + imageId: tempId, + }), + }) + .then((res) => res.json()) + .then((saveResult) => { + // Update history with actual saved ID for carousel loading + if (saveResult.success && saveResult.imageId && saveResult.imageId !== tempId) { + const currentNode = get().nodes.find((n) => n.id === nodeId); + if (currentNode) { + if (historyType === 'image') { + const currentData = currentNode.data as NanoBananaNodeData; + const updatedHistory = [...(currentData.imageHistory || [])]; + const entryIndex = updatedHistory.findIndex((h) => h.id === tempId); + if (entryIndex !== -1) { + updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; + updateNodeData(nodeId, { imageHistory: updatedHistory }); + } + } else { + const currentData = currentNode.data as GenerateVideoNodeData; + const updatedHistory = [...(currentData.videoHistory || [])]; + const entryIndex = updatedHistory.findIndex((h) => h.id === tempId); + if (entryIndex !== -1) { + updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; + updateNodeData(nodeId, { videoHistory: updatedHistory }); + } + } + } + } + }) + .catch((err) => { + console.error(`Failed to save ${historyType === 'video' ? 'video' : ''} generation:`, err); + }) + .finally(() => { + // Remove from pending syncs when done (success or failure) + pendingImageSyncs.delete(tempId); + }); + + pendingImageSyncs.set(tempId, syncPromise); +} + +// Wait for all pending image syncs to complete +async function waitForPendingImageSyncs(): Promise { + if (pendingImageSyncs.size === 0) return; + await Promise.all(pendingImageSyncs.values()); +} + // Re-export for backward compatibility export { generateWorkflowId, saveGenerateImageDefaults, saveNanoBananaDefaults } from "./utils/localStorage"; export { GROUP_COLORS } from "./utils/nodeDefaults"; @@ -1035,35 +1104,7 @@ export const useWorkflowStore = create((set, get) => ({ // Auto-save to generations folder if configured const genPath = get().generationsPath; if (genPath) { - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: genPath, - image: result.image, - prompt: text, - imageId, - }), - }) - .then((res) => res.json()) - .then((saveResult) => { - // Update imageHistory with actual saved ID for carousel loading - if (saveResult.success && saveResult.imageId && saveResult.imageId !== imageId) { - const currentNode = get().nodes.find((n) => n.id === node.id); - if (currentNode) { - const currentData = currentNode.data as NanoBananaNodeData; - const updatedHistory = [...(currentData.imageHistory || [])]; - const entryIndex = updatedHistory.findIndex((h) => h.id === imageId); - if (entryIndex !== -1) { - updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; - updateNodeData(node.id, { imageHistory: updatedHistory }); - } - } - } - }) - .catch((err) => { - console.error("Failed to save generation:", err); - }); + trackSaveGeneration(genPath, { image: result.image }, text, imageId, node.id, 'image', get, updateNodeData); } } else { logger.error('api.error', `${provider} API generation failed`, { @@ -1261,35 +1302,7 @@ export const useWorkflowStore = create((set, get) => ({ // Auto-save video to generations folder if configured const genPath = get().generationsPath; if (genPath) { - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: genPath, - video: videoData, - prompt: text, - imageId: videoId, - }), - }) - .then((res) => res.json()) - .then((saveResult) => { - // Update videoHistory with actual saved ID for carousel loading - if (saveResult.success && saveResult.imageId && saveResult.imageId !== videoId) { - const currentNode = get().nodes.find((n) => n.id === node.id); - if (currentNode) { - const currentData = currentNode.data as GenerateVideoNodeData; - const updatedHistory = [...(currentData.videoHistory || [])]; - const entryIndex = updatedHistory.findIndex((h) => h.id === videoId); - if (entryIndex !== -1) { - updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; - updateNodeData(node.id, { videoHistory: updatedHistory }); - } - } - } - }) - .catch((err) => { - console.error("Failed to save video generation:", err); - }); + trackSaveGeneration(genPath, { video: videoData }, text, videoId, node.id, 'video', get, updateNodeData); } } else if (result.success && result.image) { // Some models might return an image preview; treat as video for now @@ -1322,35 +1335,7 @@ export const useWorkflowStore = create((set, get) => ({ // Auto-save image preview to generations folder if configured const genPath = get().generationsPath; if (genPath) { - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: genPath, - image: result.image, - prompt: text, - imageId: videoId, - }), - }) - .then((res) => res.json()) - .then((saveResult) => { - // Update videoHistory with actual saved ID for carousel loading - if (saveResult.success && saveResult.imageId && saveResult.imageId !== videoId) { - const currentNode = get().nodes.find((n) => n.id === node.id); - if (currentNode) { - const currentData = currentNode.data as GenerateVideoNodeData; - const updatedHistory = [...(currentData.videoHistory || [])]; - const entryIndex = updatedHistory.findIndex((h) => h.id === videoId); - if (entryIndex !== -1) { - updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; - updateNodeData(node.id, { videoHistory: updatedHistory }); - } - } - } - }) - .catch((err) => { - console.error("Failed to save video generation:", err); - }); + trackSaveGeneration(genPath, { image: result.image }, text, videoId, node.id, 'video', get, updateNodeData); } } else { logger.error('api.error', `${provider} API video generation failed`, { @@ -1833,35 +1818,7 @@ export const useWorkflowStore = create((set, get) => ({ // Auto-save to generations folder if configured const genPath = get().generationsPath; if (genPath) { - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: genPath, - image: result.image, - prompt: text, - imageId, - }), - }) - .then((res) => res.json()) - .then((saveResult) => { - // Update imageHistory with actual saved ID for carousel loading - if (saveResult.success && saveResult.imageId && saveResult.imageId !== imageId) { - const currentNode = get().nodes.find((n) => n.id === nodeId); - if (currentNode) { - const currentData = currentNode.data as NanoBananaNodeData; - const updatedHistory = [...(currentData.imageHistory || [])]; - const entryIndex = updatedHistory.findIndex((h) => h.id === imageId); - if (entryIndex !== -1) { - updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; - updateNodeData(nodeId, { imageHistory: updatedHistory }); - } - } - } - }) - .catch((err) => { - console.error("Failed to save generation:", err); - }); + trackSaveGeneration(genPath, { image: result.image }, text, imageId, nodeId, 'image', get, updateNodeData); } } else { updateNodeData(nodeId, { @@ -2112,35 +2069,7 @@ export const useWorkflowStore = create((set, get) => ({ // Auto-save video to generations folder if configured const genPath = get().generationsPath; if (genPath) { - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: genPath, - video: videoData, - prompt: text, - imageId: videoId, - }), - }) - .then((res) => res.json()) - .then((saveResult) => { - // Update videoHistory with actual saved ID for carousel loading - if (saveResult.success && saveResult.imageId && saveResult.imageId !== videoId) { - const currentNode = get().nodes.find((n) => n.id === nodeId); - if (currentNode) { - const currentData = currentNode.data as GenerateVideoNodeData; - const updatedHistory = [...(currentData.videoHistory || [])]; - const entryIndex = updatedHistory.findIndex((h) => h.id === videoId); - if (entryIndex !== -1) { - updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; - updateNodeData(nodeId, { videoHistory: updatedHistory }); - } - } - } - }) - .catch((err) => { - console.error("Failed to save video generation:", err); - }); + trackSaveGeneration(genPath, { video: videoData }, text, videoId, nodeId, 'video', get, updateNodeData); } } else if (result.success && result.image) { const timestamp = Date.now(); @@ -2172,35 +2101,7 @@ export const useWorkflowStore = create((set, get) => ({ // Auto-save image preview to generations folder if configured const genPath = get().generationsPath; if (genPath) { - fetch("/api/save-generation", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - directoryPath: genPath, - image: result.image, - prompt: text, - imageId: videoId, - }), - }) - .then((res) => res.json()) - .then((saveResult) => { - // Update videoHistory with actual saved ID for carousel loading - if (saveResult.success && saveResult.imageId && saveResult.imageId !== videoId) { - const currentNode = get().nodes.find((n) => n.id === nodeId); - if (currentNode) { - const currentData = currentNode.data as GenerateVideoNodeData; - const updatedHistory = [...(currentData.videoHistory || [])]; - const entryIndex = updatedHistory.findIndex((h) => h.id === videoId); - if (entryIndex !== -1) { - updatedHistory[entryIndex] = { ...updatedHistory[entryIndex], id: saveResult.imageId }; - updateNodeData(nodeId, { videoHistory: updatedHistory }); - } - } - } - }) - .catch((err) => { - console.error("Failed to save video generation:", err); - }); + trackSaveGeneration(genPath, { image: result.image }, text, videoId, nodeId, 'video', get, updateNodeData); } } else { logger.error('api.error', `${provider} API video regeneration failed`, { @@ -2552,11 +2453,18 @@ export const useWorkflowStore = create((set, get) => ({ set({ isSaving: true }); try { + // Wait for any pending image/video saves to complete so their IDs are synced + // This prevents saving workflows with temporary IDs that don't match saved files + await waitForPendingImageSyncs(); + + // Re-fetch nodes after waiting, as imageHistory IDs may have been updated + const currentNodes = get().nodes; + let workflow: WorkflowFile = { version: 1, id: workflowId, name: workflowName, - nodes, + nodes: currentNodes, edges, edgeStyle, groups: Object.keys(groups).length > 0 ? groups : undefined, @@ -2581,11 +2489,52 @@ export const useWorkflowStore = create((set, get) => ({ if (result.success) { const timestamp = Date.now(); - set({ - lastSavedAt: timestamp, - hasUnsavedChanges: false, - isSaving: false, - }); + + // If we externalized images, update store nodes with the refs + // This prevents duplicate images on subsequent saves + if (useExternalImageStorage && workflow.nodes !== currentNodes) { + // Merge refs from externalized nodes into current nodes (keeping image data) + const nodesWithRefs = currentNodes.map((node, index) => { + const externalizedNode = workflow.nodes[index]; + if (!externalizedNode || node.id !== externalizedNode.id) { + return node; // Safety check - nodes should match + } + + // Copy refs from externalized node while keeping current image data + // Use type assertion to access ref fields that may exist on various node types + const mergedData = { ...node.data } as Record; + const extData = externalizedNode.data as Record; + + // Copy ref fields based on node type + if (extData.imageRef && typeof extData.imageRef === 'string') { + mergedData.imageRef = extData.imageRef; + } + if (extData.sourceImageRef && typeof extData.sourceImageRef === 'string') { + mergedData.sourceImageRef = extData.sourceImageRef; + } + if (extData.outputImageRef && typeof extData.outputImageRef === 'string') { + mergedData.outputImageRef = extData.outputImageRef; + } + if (extData.inputImageRefs && Array.isArray(extData.inputImageRefs)) { + mergedData.inputImageRefs = extData.inputImageRefs; + } + + return { ...node, data: mergedData as WorkflowNodeData } as WorkflowNode; + }); + + set({ + nodes: nodesWithRefs, + lastSavedAt: timestamp, + hasUnsavedChanges: false, + isSaving: false, + }); + } else { + set({ + lastSavedAt: timestamp, + hasUnsavedChanges: false, + isSaving: false, + }); + } // Update localStorage saveSaveConfig({