Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
8a2ed02ff6
  1. 309
      src/store/workflowStore.ts

309
src/store/workflowStore.ts

@ -195,6 +195,75 @@ let nodeIdCounter = 0;
let groupIdCounter = 0;
let autoSaveIntervalId: ReturnType<typeof setInterval> | null = null;
// Track pending save-generation syncs to ensure IDs are resolved before workflow save
const pendingImageSyncs = new Map<string, Promise<void>>();
// 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<WorkflowNodeData>) => 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<void> {
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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<string, unknown>;
const extData = externalizedNode.data as Record<string, unknown>;
// 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({

Loading…
Cancel
Save