Browse Source

overhaul save process to store images externally to json file

handoff-20260429-1057
shrimbly 6 months ago
parent
commit
fe9c5851b2
  1. 710
      package-lock.json
  2. 230
      src/app/api/workflow-images/route.ts
  3. 16
      src/app/api/workflow/route.ts
  4. 8
      src/components/Header.tsx
  5. 106
      src/components/ProjectSetupModal.tsx
  6. 8
      src/components/WorkflowCanvas.tsx
  7. 54
      src/store/workflowStore.ts
  8. 8
      src/types/index.ts
  9. 427
      src/utils/imageStorage.ts

710
package-lock.json

File diff suppressed because it is too large

230
src/app/api/workflow-images/route.ts

@ -0,0 +1,230 @@
import { NextRequest, NextResponse } from "next/server";
import * as fs from "fs/promises";
import * as path from "path";
import { logger } from "@/utils/logger";
export const maxDuration = 300; // 5 minute timeout for large image operations
const IMAGES_FOLDER = "inputs";
const LEGACY_IMAGES_FOLDER = ".images"; // For backward compatibility
// POST: Save an image to the workflow's inputs or generations folder
export async function POST(request: NextRequest) {
let workflowPath: string | undefined;
let imageId: string | undefined;
let folder: string | undefined;
try {
const body = await request.json();
workflowPath = body.workflowPath;
imageId = body.imageId;
folder = body.folder || IMAGES_FOLDER; // Default to "inputs"
const imageData = body.imageData; // Base64 data URL
// Validate folder is one of the allowed values
if (folder !== IMAGES_FOLDER && folder !== "generations") {
folder = IMAGES_FOLDER;
}
logger.info('file.save', 'Workflow image save request received', {
workflowPath,
imageId,
folder,
hasImageData: !!imageData,
});
if (!workflowPath || !imageId || !imageData) {
logger.warn('file.save', 'Workflow image save validation failed: missing fields', {
hasWorkflowPath: !!workflowPath,
hasImageId: !!imageId,
hasImageData: !!imageData,
});
return NextResponse.json(
{ success: false, error: "Missing required fields (workflowPath, imageId, imageData)" },
{ status: 400 }
);
}
// Validate workflow directory exists
try {
const stats = await fs.stat(workflowPath);
if (!stats.isDirectory()) {
logger.warn('file.error', 'Workflow image save failed: path is not a directory', {
workflowPath,
});
return NextResponse.json(
{ success: false, error: "Workflow path is not a directory" },
{ status: 400 }
);
}
} catch (dirError) {
logger.warn('file.error', 'Workflow image save failed: directory does not exist', {
workflowPath,
});
return NextResponse.json(
{ success: false, error: "Workflow directory does not exist" },
{ status: 400 }
);
}
// Create target folder if it doesn't exist
const targetFolder = path.join(workflowPath, folder);
try {
await fs.mkdir(targetFolder, { recursive: true });
} catch (mkdirError) {
logger.error('file.error', 'Failed to create target folder', {
targetFolder,
}, mkdirError instanceof Error ? mkdirError : undefined);
return NextResponse.json(
{ success: false, error: "Failed to create target folder" },
{ status: 500 }
);
}
// Construct file path
const filename = `${imageId}.png`;
const filePath = path.join(targetFolder, filename);
// Extract base64 data and convert to buffer
const base64Data = imageData.replace(/^data:image\/\w+;base64,/, "");
const buffer = Buffer.from(base64Data, "base64");
// Write the image file
await fs.writeFile(filePath, buffer);
logger.info('file.save', 'Workflow image saved successfully', {
filePath,
imageId,
fileSize: buffer.length,
});
return NextResponse.json({
success: true,
imageId,
filePath,
});
} catch (error) {
logger.error('file.error', 'Failed to save workflow image', {
workflowPath,
imageId,
}, error instanceof Error ? error : undefined);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Save failed",
},
{ status: 500 }
);
}
}
// GET: Load an image from the workflow's folders (inputs, generations, or legacy .images)
export async function GET(request: NextRequest) {
const workflowPath = request.nextUrl.searchParams.get("workflowPath");
const imageId = request.nextUrl.searchParams.get("imageId");
const folder = request.nextUrl.searchParams.get("folder"); // Optional hint for which folder to check first
logger.info('file.load', 'Workflow image load request received', {
workflowPath,
imageId,
folder,
});
if (!workflowPath || !imageId) {
logger.warn('file.load', 'Workflow image load validation failed: missing parameters', {
hasWorkflowPath: !!workflowPath,
hasImageId: !!imageId,
});
return NextResponse.json(
{ success: false, error: "Missing required parameters (workflowPath, imageId)" },
{ status: 400 }
);
}
try {
// Validate workflow directory exists
try {
const stats = await fs.stat(workflowPath);
if (!stats.isDirectory()) {
return NextResponse.json(
{ success: false, error: "Workflow path is not a directory" },
{ status: 400 }
);
}
} catch {
return NextResponse.json(
{ success: false, error: "Workflow directory does not exist" },
{ status: 400 }
);
}
// Construct file path - check folders in order based on hint
const filename = `${imageId}.png`;
const inputsFolder = path.join(workflowPath, IMAGES_FOLDER);
const generationsFolder = path.join(workflowPath, "generations");
const legacyFolder = path.join(workflowPath, LEGACY_IMAGES_FOLDER);
// Build search order based on folder hint
const searchOrder = folder === "generations"
? [generationsFolder, inputsFolder, legacyFolder]
: [inputsFolder, generationsFolder, legacyFolder];
let filePath: string | null = null;
// Check each folder in order
for (const searchFolder of searchOrder) {
const candidatePath = path.join(searchFolder, filename);
try {
await fs.access(candidatePath);
filePath = candidatePath;
if (searchFolder === legacyFolder) {
logger.info('file.load', 'Found image in legacy .images folder', { filePath });
}
break;
} catch {
// File not found in this folder, try next
}
}
if (!filePath) {
logger.warn('file.error', 'Workflow image load failed: file not found', {
imageId,
searchedFolders: searchOrder,
});
return NextResponse.json(
{ success: false, error: "Image file not found" },
{ status: 404 }
);
}
// Read the image file
const buffer = await fs.readFile(filePath);
// Convert to base64 data URL
const base64 = buffer.toString("base64");
const dataUrl = `data:image/png;base64,${base64}`;
logger.info('file.load', 'Workflow image loaded successfully', {
filePath,
imageId,
fileSize: buffer.length,
});
return NextResponse.json({
success: true,
imageId,
image: dataUrl,
});
} catch (error) {
logger.error('file.error', 'Failed to load workflow image', {
workflowPath,
imageId,
}, error instanceof Error ? error : undefined);
return NextResponse.json(
{
success: false,
error: error instanceof Error ? error.message : "Load failed",
},
{ status: 500 }
);
}
}

16
src/app/api/workflow/route.ts

@ -57,6 +57,22 @@ export async function POST(request: NextRequest) {
);
}
// Auto-create subfolders for inputs and generations
const inputsFolder = path.join(directoryPath, "inputs");
const generationsFolder = path.join(directoryPath, "generations");
try {
await fs.mkdir(inputsFolder, { recursive: true });
await fs.mkdir(generationsFolder, { recursive: true });
} catch (mkdirError) {
logger.warn('file.save', 'Failed to create subfolders (non-fatal)', {
inputsFolder,
generationsFolder,
error: mkdirError instanceof Error ? mkdirError.message : 'Unknown error',
});
// Continue anyway - folders may already exist or be created later
}
// Sanitize filename (remove special chars, ensure .json extension)
const safeName = filename.replace(/[^a-zA-Z0-9-_]/g, "_");
const filePath = path.join(directoryPath, `${safeName}.json`);

8
src/components/Header.tsx

@ -51,11 +51,11 @@ export function Header() {
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
reader.onload = async (event) => {
try {
const workflow = JSON.parse(event.target?.result as string) as WorkflowFile;
if (workflow.version && workflow.nodes && workflow.edges) {
loadWorkflow(workflow);
await loadWorkflow(workflow);
} else {
alert("Invalid workflow file format");
}
@ -69,8 +69,8 @@ export function Header() {
e.target.value = "";
};
const handleProjectSave = async (id: string, name: string, path: string, genPath: string | null) => {
setWorkflowMetadata(id, name, path, genPath);
const handleProjectSave = async (id: string, name: string, path: string) => {
setWorkflowMetadata(id, name, path); // generationsPath is auto-derived
setShowProjectModal(false);
// Small delay to let state update
setTimeout(() => {

106
src/components/ProjectSetupModal.tsx

@ -6,7 +6,7 @@ import { generateWorkflowId, useWorkflowStore } from "@/store/workflowStore";
interface ProjectSetupModalProps {
isOpen: boolean;
onClose: () => void;
onSave: (id: string, name: string, directoryPath: string, generationsPath: string | null) => void;
onSave: (id: string, name: string, directoryPath: string) => void;
mode: "new" | "settings";
}
@ -16,14 +16,13 @@ export function ProjectSetupModal({
onSave,
mode,
}: ProjectSetupModalProps) {
const { workflowName, saveDirectoryPath, generationsPath } = useWorkflowStore();
const { workflowName, saveDirectoryPath, useExternalImageStorage, setUseExternalImageStorage } = useWorkflowStore();
const [name, setName] = useState("");
const [directoryPath, setDirectoryPath] = useState("");
const [genPath, setGenPath] = useState("");
const [externalStorage, setExternalStorage] = useState(true);
const [isValidating, setIsValidating] = useState(false);
const [isBrowsingWorkflow, setIsBrowsingWorkflow] = useState(false);
const [isBrowsingGen, setIsBrowsingGen] = useState(false);
const [isBrowsing, setIsBrowsing] = useState(false);
const [error, setError] = useState<string | null>(null);
// Pre-fill when opening in settings mode
@ -31,18 +30,15 @@ export function ProjectSetupModal({
if (isOpen && mode === "settings") {
setName(workflowName || "");
setDirectoryPath(saveDirectoryPath || "");
setGenPath(generationsPath || "");
setExternalStorage(useExternalImageStorage);
} else if (isOpen && mode === "new") {
setName("");
setDirectoryPath("");
setGenPath("");
setExternalStorage(true);
}
}, [isOpen, mode, workflowName, saveDirectoryPath, generationsPath]);
const handleBrowse = async (target: "workflow" | "generations") => {
const setIsBrowsing = target === "workflow" ? setIsBrowsingWorkflow : setIsBrowsingGen;
const setPath = target === "workflow" ? setDirectoryPath : setGenPath;
}, [isOpen, mode, workflowName, saveDirectoryPath, useExternalImageStorage]);
const handleBrowse = async () => {
setIsBrowsing(true);
setError(null);
@ -60,7 +56,7 @@ export function ProjectSetupModal({
}
if (result.path) {
setPath(result.path);
setDirectoryPath(result.path);
}
} catch (err) {
setError(
@ -78,7 +74,7 @@ export function ProjectSetupModal({
}
if (!directoryPath.trim()) {
setError("Workflow directory is required");
setError("Project directory is required");
return;
}
@ -86,57 +82,39 @@ export function ProjectSetupModal({
setError(null);
try {
// Validate workflow directory exists
// Validate project directory exists
const response = await fetch(
`/api/workflow?path=${encodeURIComponent(directoryPath.trim())}`
);
const result = await response.json();
if (!result.exists) {
setError("Workflow directory does not exist");
setError("Project directory does not exist");
setIsValidating(false);
return;
}
if (!result.isDirectory) {
setError("Workflow path is not a directory");
setError("Project path is not a directory");
setIsValidating(false);
return;
}
// Validate generations directory if provided
if (genPath.trim()) {
const genResponse = await fetch(
`/api/workflow?path=${encodeURIComponent(genPath.trim())}`
);
const genResult = await genResponse.json();
if (!genResult.exists) {
setError("Generations directory does not exist");
setIsValidating(false);
return;
}
if (!genResult.isDirectory) {
setError("Generations path is not a directory");
setIsValidating(false);
return;
}
}
const id = mode === "new" ? generateWorkflowId() : useWorkflowStore.getState().workflowId || generateWorkflowId();
onSave(id, name.trim(), directoryPath.trim(), genPath.trim() || null);
// Update external storage setting
setUseExternalImageStorage(externalStorage);
onSave(id, name.trim(), directoryPath.trim());
setIsValidating(false);
} catch (err) {
setError(
`Failed to validate directories: ${err instanceof Error ? err.message : "Unknown error"}`
`Failed to validate directory: ${err instanceof Error ? err.message : "Unknown error"}`
);
setIsValidating(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !isValidating && !isBrowsingWorkflow && !isBrowsingGen) {
if (e.key === "Enter" && !isValidating && !isBrowsing) {
handleSave();
}
if (e.key === "Escape") {
@ -146,8 +124,6 @@ export function ProjectSetupModal({
if (!isOpen) return null;
const isBrowsing = isBrowsingWorkflow || isBrowsingGen;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50">
<div
@ -175,55 +151,45 @@ export function ProjectSetupModal({
<div>
<label className="block text-sm text-neutral-400 mb-1">
Workflow Directory
Project Directory
</label>
<div className="flex gap-2">
<input
type="text"
value={directoryPath}
onChange={(e) => setDirectoryPath(e.target.value)}
placeholder="/Users/username/projects"
placeholder="/Users/username/projects/my-project"
className="flex-1 px-3 py-2 bg-neutral-900 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
/>
<button
type="button"
onClick={() => handleBrowse("workflow")}
onClick={handleBrowse}
disabled={isBrowsing}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 disabled:bg-neutral-700 disabled:opacity-50 text-neutral-200 text-sm rounded transition-colors"
>
{isBrowsingWorkflow ? "..." : "Browse"}
{isBrowsing ? "..." : "Browse"}
</button>
</div>
<p className="text-xs text-neutral-500 mt-1">
Where the workflow JSON file will be saved
Workflow files and images will be saved here. Subfolders for inputs and generations will be auto-created.
</p>
</div>
<div>
<label className="block text-sm text-neutral-400 mb-1">
Generations Directory
<span className="text-neutral-500 ml-1">(optional)</span>
</label>
<div className="flex gap-2">
<div className="pt-2 border-t border-neutral-700">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="text"
value={genPath}
onChange={(e) => setGenPath(e.target.value)}
placeholder="/Users/username/generations"
className="flex-1 px-3 py-2 bg-neutral-900 border border-neutral-600 rounded text-neutral-100 text-sm focus:outline-none focus:border-neutral-500"
type="checkbox"
checked={!externalStorage}
onChange={(e) => setExternalStorage(!e.target.checked)}
className="w-4 h-4 rounded border-neutral-600 bg-neutral-900 text-blue-500 focus:ring-blue-500 focus:ring-offset-neutral-800"
/>
<button
type="button"
onClick={() => handleBrowse("generations")}
disabled={isBrowsing}
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 disabled:bg-neutral-700 disabled:opacity-50 text-neutral-200 text-sm rounded transition-colors"
>
{isBrowsingGen ? "..." : "Browse"}
</button>
</div>
<p className="text-xs text-neutral-500 mt-1">
Generated images will be automatically saved here
</p>
<div>
<span className="text-sm text-neutral-200">Embed images as base64</span>
<p className="text-xs text-neutral-500">
Embeds all images in workflow, larger workflow files. Can hit memory limits on very large workflows.
</p>
</div>
</label>
</div>
{error && <p className="text-sm text-red-400">{error}</p>}

8
src/components/WorkflowCanvas.tsx

@ -992,11 +992,11 @@ export function WorkflowCanvas() {
if (jsonFiles.length > 0) {
const file = jsonFiles[0];
const reader = new FileReader();
reader.onload = (e) => {
reader.onload = async (e) => {
try {
const workflow = JSON.parse(e.target?.result as string) as WorkflowFile;
if (workflow.version && workflow.nodes && workflow.edges) {
loadWorkflow(workflow);
await loadWorkflow(workflow);
} else {
alert("Invalid workflow file format");
}
@ -1084,8 +1084,8 @@ export function WorkflowCanvas() {
{/* Welcome Modal */}
{isCanvasEmpty && showQuickstart && (
<WelcomeModal
onWorkflowGenerated={(workflow) => {
loadWorkflow(workflow);
onWorkflowGenerated={async (workflow) => {
await loadWorkflow(workflow);
setShowQuickstart(false);
}}
onClose={() => setShowQuickstart(false)}

54
src/store/workflowStore.ts

@ -29,6 +29,7 @@ import {
import { useToast } from "@/components/Toast";
import { calculateGenerationCost } from "@/utils/costCalculator";
import { logger } from "@/utils/logger";
import { externalizeWorkflowImages, hydrateWorkflowImages } from "@/utils/imageStorage";
export type EdgeStyle = "angular" | "curved";
@ -105,7 +106,7 @@ interface WorkflowStore {
// Save/Load
saveWorkflow: (name?: string) => void;
loadWorkflow: (workflow: WorkflowFile) => void;
loadWorkflow: (workflow: WorkflowFile, workflowPath?: string) => Promise<void>;
clearWorkflow: () => void;
// Helpers
@ -127,12 +128,14 @@ interface WorkflowStore {
hasUnsavedChanges: boolean;
autoSaveEnabled: boolean;
isSaving: boolean;
useExternalImageStorage: boolean; // Store images as separate files vs embedded base64
// Auto-save actions
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath: string | null) => void;
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath?: string | null) => void;
setWorkflowName: (name: string) => void;
setGenerationsPath: (path: string | null) => void;
setAutoSaveEnabled: (enabled: boolean) => void;
setUseExternalImageStorage: (enabled: boolean) => void;
markAsUnsaved: () => void;
saveToFile: () => Promise<boolean>;
initializeAutoSave: () => void;
@ -343,6 +346,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
hasUnsavedChanges: false,
autoSaveEnabled: true,
isSaving: false,
useExternalImageStorage: true, // Default: store images as separate files
// Cost tracking initial state
incurredCost: 0,
@ -1765,7 +1769,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
URL.revokeObjectURL(url);
},
loadWorkflow: (workflow: WorkflowFile) => {
loadWorkflow: async (workflow: WorkflowFile, workflowPath?: string) => {
// Update nodeIdCounter to avoid ID collisions
const maxNodeId = workflow.nodes.reduce((max, node) => {
const match = node.id.match(/-(\d+)$/);
@ -1790,20 +1794,34 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
const configs = loadSaveConfigs();
const savedConfig = workflow.id ? configs[workflow.id] : null;
// Determine the workflow directory path (passed in or from saved config)
const directoryPath = workflowPath || savedConfig?.directoryPath;
// Hydrate images if we have a directory path and the workflow has image refs
let hydratedWorkflow = workflow;
if (directoryPath) {
try {
hydratedWorkflow = await hydrateWorkflowImages(workflow, directoryPath);
} catch (error) {
console.error("Failed to hydrate workflow images:", error);
// Continue with original workflow if hydration fails
}
}
// Load cost data for this workflow
const costData = workflow.id ? loadWorkflowCostData(workflow.id) : null;
set({
nodes: workflow.nodes,
edges: workflow.edges,
edgeStyle: workflow.edgeStyle || "angular",
groups: workflow.groups || {},
nodes: hydratedWorkflow.nodes,
edges: hydratedWorkflow.edges,
edgeStyle: hydratedWorkflow.edgeStyle || "angular",
groups: hydratedWorkflow.groups || {},
isRunning: false,
currentNodeId: null,
// Restore workflow ID and paths from localStorage if available
workflowId: workflow.id || null,
workflowName: workflow.name,
saveDirectoryPath: savedConfig?.directoryPath || null,
saveDirectoryPath: directoryPath || null,
generationsPath: savedConfig?.generationsPath || null,
lastSavedAt: savedConfig?.lastSavedAt || null,
hasUnsavedChanges: false,
@ -1847,12 +1865,16 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
},
// Auto-save actions
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath: string | null) => {
setWorkflowMetadata: (id: string, name: string, path: string, generationsPath?: string | null) => {
// Auto-derive generationsPath: use provided value, fall back to existing, then auto-derive
const currentGenPath = get().generationsPath;
const derivedGenerationsPath = generationsPath ?? currentGenPath ?? `${path}/generations`;
set({
workflowId: id,
workflowName: name,
saveDirectoryPath: path,
generationsPath: generationsPath,
generationsPath: derivedGenerationsPath,
});
},
@ -1873,6 +1895,10 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ autoSaveEnabled: enabled });
},
setUseExternalImageStorage: (enabled: boolean) => {
set({ useExternalImageStorage: enabled });
},
markAsUnsaved: () => {
set({ hasUnsavedChanges: true });
},
@ -1886,6 +1912,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
workflowId,
workflowName,
saveDirectoryPath,
useExternalImageStorage,
} = get();
if (!workflowId || !workflowName || !saveDirectoryPath) {
@ -1895,7 +1922,7 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
set({ isSaving: true });
try {
const workflow: WorkflowFile = {
let workflow: WorkflowFile = {
version: 1,
id: workflowId,
name: workflowName,
@ -1905,6 +1932,11 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
groups: Object.keys(groups).length > 0 ? groups : undefined,
};
// If external image storage is enabled, externalize images before saving
if (useExternalImageStorage) {
workflow = await externalizeWorkflowImages(workflow, saveDirectoryPath);
}
const response = await fetch("/api/workflow", {
method: "POST",
headers: { "Content-Type": "application/json" },

8
src/types/index.ts

@ -43,6 +43,7 @@ export interface BaseNodeData extends Record<string, unknown> {
// Image Input Node Data
export interface ImageInputNodeData extends BaseNodeData {
image: string | null;
imageRef?: string; // External image reference for storage optimization
filename: string | null;
dimensions: { width: number; height: number } | null;
}
@ -101,8 +102,10 @@ export type AnnotationShape =
// Annotation Node Data
export interface AnnotationNodeData extends BaseNodeData {
sourceImage: string | null;
sourceImageRef?: string; // External image reference for storage optimization
annotations: AnnotationShape[];
outputImage: string | null;
outputImageRef?: string; // External image reference for storage optimization
}
// Prompt Node Data
@ -132,8 +135,10 @@ export interface CarouselImageItem {
// Nano Banana Node Data (Image Generation)
export interface NanoBananaNodeData extends BaseNodeData {
inputImages: string[]; // Now supports multiple images
inputImageRefs?: string[]; // External image references for storage optimization
inputPrompt: string | null;
outputImage: string | null;
outputImageRef?: string; // External image reference for storage optimization
aspectRatio: AspectRatio;
resolution: Resolution; // Only used by Nano Banana Pro
model: ModelType;
@ -148,6 +153,7 @@ export interface NanoBananaNodeData extends BaseNodeData {
export interface LLMGenerateNodeData extends BaseNodeData {
inputPrompt: string | null;
inputImages: string[];
inputImageRefs?: string[]; // External image references for storage optimization
outputText: string | null;
provider: LLMProvider;
model: LLMModelType;
@ -160,11 +166,13 @@ export interface LLMGenerateNodeData extends BaseNodeData {
// Output Node Data
export interface OutputNodeData extends BaseNodeData {
image: string | null;
imageRef?: string; // External image reference for storage optimization
}
// Split Grid Node Data (Utility Node)
export interface SplitGridNodeData extends BaseNodeData {
sourceImage: string | null;
sourceImageRef?: string; // External image reference for storage optimization
targetCount: number; // 4, 6, 8, 9, or 10
defaultPrompt: string;
generateSettings: {

427
src/utils/imageStorage.ts

@ -0,0 +1,427 @@
import { WorkflowNode, WorkflowNodeData } from "@/types";
import { WorkflowFile } from "@/store/workflowStore";
/**
* Generate a unique image ID for external storage
*/
export function generateImageId(): string {
const timestamp = Date.now().toString(36);
const random = Math.random().toString(36).substring(2, 8);
return `img-${timestamp}-${random}`;
}
/**
* Check if a string is a base64 data URL
*/
function isBase64DataUrl(str: string | null | undefined): str is string {
return typeof str === "string" && str.startsWith("data:");
}
/**
* Extract and save all images from a workflow, replacing base64 data with refs
* Returns a new workflow object with image refs instead of base64 data
*/
export async function externalizeWorkflowImages(
workflow: WorkflowFile,
workflowPath: string
): Promise<WorkflowFile> {
const externalizedNodes: WorkflowNode[] = [];
const savedImageIds = new Map<string, string>(); // base64 hash -> imageId (for deduplication)
for (const node of workflow.nodes) {
const newNode = await externalizeNodeImages(node, workflowPath, savedImageIds);
externalizedNodes.push(newNode);
}
return {
...workflow,
nodes: externalizedNodes,
};
}
/**
* Externalize images from a single node
*/
async function externalizeNodeImages(
node: WorkflowNode,
workflowPath: string,
savedImageIds: Map<string, string>
): Promise<WorkflowNode> {
const data = node.data as WorkflowNodeData;
let newData: WorkflowNodeData;
switch (node.type) {
case "imageInput": {
const d = data as import("@/types").ImageInputNodeData;
if (isBase64DataUrl(d.image)) {
const imageId = await saveImageAndGetId(d.image, workflowPath, savedImageIds, "inputs");
newData = {
...d,
image: null,
imageRef: imageId,
};
} else {
newData = d;
}
break;
}
case "annotation": {
const d = data as import("@/types").AnnotationNodeData;
let sourceImageRef = d.sourceImageRef;
let outputImageRef = d.outputImageRef;
let sourceImage = d.sourceImage;
let outputImage = d.outputImage;
// Annotation images are user-created, save to inputs
if (isBase64DataUrl(d.sourceImage)) {
sourceImageRef = await saveImageAndGetId(d.sourceImage, workflowPath, savedImageIds, "inputs");
sourceImage = null;
}
if (isBase64DataUrl(d.outputImage)) {
outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "inputs");
outputImage = null;
}
newData = {
...d,
sourceImage,
sourceImageRef,
outputImage,
outputImageRef,
};
break;
}
case "nanoBanana": {
const d = data as import("@/types").NanoBananaNodeData;
let outputImageRef = d.outputImageRef;
let outputImage = d.outputImage;
const inputImageRefs: string[] = [];
const inputImages: string[] = [];
// Handle output image - AI generated, save to generations
if (isBase64DataUrl(d.outputImage)) {
outputImageRef = await saveImageAndGetId(d.outputImage, workflowPath, savedImageIds, "generations");
outputImage = null;
}
// Handle input images array (these come from connected nodes, save to inputs if present)
for (const img of d.inputImages) {
if (isBase64DataUrl(img)) {
const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs");
inputImageRefs.push(ref);
inputImages.push(""); // Empty placeholder
} else {
inputImages.push(img);
}
}
newData = {
...d,
inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages,
inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined,
outputImage,
outputImageRef,
};
break;
}
case "llmGenerate": {
const d = data as import("@/types").LLMGenerateNodeData;
const inputImageRefs: string[] = [];
const inputImages: string[] = [];
// Handle input images array (save to inputs)
for (const img of d.inputImages) {
if (isBase64DataUrl(img)) {
const ref = await saveImageAndGetId(img, workflowPath, savedImageIds, "inputs");
inputImageRefs.push(ref);
inputImages.push(""); // Empty placeholder
} else {
inputImages.push(img);
}
}
newData = {
...d,
inputImages: inputImages.length > 0 && inputImages.every(i => i === "") ? [] : inputImages,
inputImageRefs: inputImageRefs.length > 0 ? inputImageRefs : undefined,
};
break;
}
case "output": {
const d = data as import("@/types").OutputNodeData;
// Output displays generated content, save to generations
if (isBase64DataUrl(d.image)) {
const imageId = await saveImageAndGetId(d.image, workflowPath, savedImageIds, "generations");
newData = {
...d,
image: null,
imageRef: imageId,
};
} else {
newData = d;
}
break;
}
case "splitGrid": {
const d = data as import("@/types").SplitGridNodeData;
// SplitGrid source is input content, save to inputs
if (isBase64DataUrl(d.sourceImage)) {
const imageId = await saveImageAndGetId(d.sourceImage, workflowPath, savedImageIds, "inputs");
newData = {
...d,
sourceImage: null,
sourceImageRef: imageId,
};
} else {
newData = d;
}
break;
}
default:
newData = data;
}
return {
...node,
data: newData,
} as WorkflowNode;
}
/**
* Save an image and return its ID (with deduplication)
* @param folder - "inputs" for user-uploaded images, "generations" for AI-generated images
*/
async function saveImageAndGetId(
imageData: string,
workflowPath: string,
savedImageIds: Map<string, string>,
folder: "inputs" | "generations" = "inputs"
): Promise<string> {
// Create a hash using length + samples from different parts of the data
// This avoids issues where all images of the same format have identical headers
// Include folder in hash so same image in different folders gets different IDs
const len = imageData.length;
const mid = Math.floor(len / 2);
const hash = `${folder}-${len}-${imageData.substring(50, 100)}-${imageData.substring(mid, mid + 50)}-${imageData.substring(Math.max(0, len - 50))}`;
if (savedImageIds.has(hash)) {
return savedImageIds.get(hash)!;
}
const imageId = generateImageId();
const response = await fetch("/api/workflow-images", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
workflowPath,
imageId,
imageData,
folder,
}),
});
const result = await response.json();
if (!result.success) {
throw new Error(`Failed to save image: ${result.error}`);
}
savedImageIds.set(hash, imageId);
return imageId;
}
/**
* Load all external images into a workflow, replacing refs with base64 data
* Returns a new workflow object with base64 data instead of refs
*/
export async function hydrateWorkflowImages(
workflow: WorkflowFile,
workflowPath: string
): Promise<WorkflowFile> {
const hydratedNodes: WorkflowNode[] = [];
const loadedImages = new Map<string, string>(); // imageId -> base64 (for caching)
for (const node of workflow.nodes) {
const newNode = await hydrateNodeImages(node, workflowPath, loadedImages);
hydratedNodes.push(newNode);
}
return {
...workflow,
nodes: hydratedNodes,
};
}
/**
* Hydrate images for a single node
*/
async function hydrateNodeImages(
node: WorkflowNode,
workflowPath: string,
loadedImages: Map<string, string>
): Promise<WorkflowNode> {
const data = node.data as WorkflowNodeData;
let newData: WorkflowNodeData;
switch (node.type) {
case "imageInput": {
const d = data as import("@/types").ImageInputNodeData;
if (d.imageRef && !d.image) {
const image = await loadImageById(d.imageRef, workflowPath, loadedImages, "inputs");
newData = {
...d,
image,
};
} else {
newData = d;
}
break;
}
case "annotation": {
const d = data as import("@/types").AnnotationNodeData;
let sourceImage = d.sourceImage;
let outputImage = d.outputImage;
if (d.sourceImageRef && !d.sourceImage) {
sourceImage = await loadImageById(d.sourceImageRef, workflowPath, loadedImages, "inputs");
}
if (d.outputImageRef && !d.outputImage) {
outputImage = await loadImageById(d.outputImageRef, workflowPath, loadedImages, "inputs");
}
newData = {
...d,
sourceImage,
outputImage,
};
break;
}
case "nanoBanana": {
const d = data as import("@/types").NanoBananaNodeData;
let outputImage = d.outputImage;
const inputImages = [...d.inputImages];
if (d.outputImageRef && !d.outputImage) {
outputImage = await loadImageById(d.outputImageRef, workflowPath, loadedImages, "generations");
}
// Hydrate input images from refs
if (d.inputImageRefs && d.inputImageRefs.length > 0) {
for (let i = 0; i < d.inputImageRefs.length; i++) {
const ref = d.inputImageRefs[i];
if (ref) {
inputImages[i] = await loadImageById(ref, workflowPath, loadedImages, "inputs");
}
}
}
newData = {
...d,
inputImages,
outputImage,
};
break;
}
case "llmGenerate": {
const d = data as import("@/types").LLMGenerateNodeData;
const inputImages = [...d.inputImages];
// Hydrate input images from refs
if (d.inputImageRefs && d.inputImageRefs.length > 0) {
for (let i = 0; i < d.inputImageRefs.length; i++) {
const ref = d.inputImageRefs[i];
if (ref) {
inputImages[i] = await loadImageById(ref, workflowPath, loadedImages, "inputs");
}
}
}
newData = {
...d,
inputImages,
};
break;
}
case "output": {
const d = data as import("@/types").OutputNodeData;
if (d.imageRef && !d.image) {
const image = await loadImageById(d.imageRef, workflowPath, loadedImages, "generations");
newData = {
...d,
image,
};
} else {
newData = d;
}
break;
}
case "splitGrid": {
const d = data as import("@/types").SplitGridNodeData;
if (d.sourceImageRef && !d.sourceImage) {
const sourceImage = await loadImageById(d.sourceImageRef, workflowPath, loadedImages, "inputs");
newData = {
...d,
sourceImage,
};
} else {
newData = d;
}
break;
}
default:
newData = data;
}
return {
...node,
data: newData,
} as WorkflowNode;
}
/**
* Load an image by ID (with caching)
* @param folder - Optional hint for which folder to check first
*/
async function loadImageById(
imageId: string,
workflowPath: string,
loadedImages: Map<string, string>,
folder?: "inputs" | "generations"
): Promise<string> {
if (loadedImages.has(imageId)) {
return loadedImages.get(imageId)!;
}
const params = new URLSearchParams({
workflowPath,
imageId,
});
if (folder) {
params.set("folder", folder);
}
const response = await fetch(`/api/workflow-images?${params.toString()}`);
const result = await response.json();
if (!result.success) {
console.error(`Failed to load image ${imageId}: ${result.error}`);
return ""; // Return empty string on error to avoid breaking the workflow
}
loadedImages.set(imageId, result.image);
return result.image;
}
Loading…
Cancel
Save