@ -2,6 +2,38 @@ import { WorkflowNode, WorkflowNodeData } from "@/types";
import { WorkflowFile } from "@/store/workflowStore" ;
import { WorkflowFile } from "@/store/workflowStore" ;
import crypto from "crypto" ;
import crypto from "crypto" ;
/ * *
* Fetch with timeout support using AbortController
* @param url - The URL to fetch
* @param options - Fetch options ( RequestInit )
* @param timeout - Timeout in milliseconds ( default : 30000 ms / 30 seconds )
* @returns Promise < Response >
* @throws Error if the request times out or fails
* /
async function fetchWithTimeout (
url : string ,
options : RequestInit ,
timeout : number = 30000
) : Promise < Response > {
const controller = new AbortController ( ) ;
const timeoutId = setTimeout ( ( ) = > controller . abort ( ) , timeout ) ;
try {
const response = await fetch ( url , {
. . . options ,
signal : controller.signal ,
} ) ;
return response ;
} catch ( error ) {
if ( error instanceof Error && error . name === "AbortError" ) {
throw new Error ( ` Request timed out after ${ timeout } ms: ${ url } ` ) ;
}
throw error ;
} finally {
clearTimeout ( timeoutId ) ;
}
}
/ * *
/ * *
* Compute MD5 hash of image content for deduplication
* Compute MD5 hash of image content for deduplication
* Consistent with save - generation API ( Phase 13 decision )
* Consistent with save - generation API ( Phase 13 decision )
@ -34,12 +66,24 @@ export async function externalizeWorkflowImages(
workflow : WorkflowFile ,
workflow : WorkflowFile ,
workflowPath : string
workflowPath : string
) : Promise < WorkflowFile > {
) : Promise < WorkflowFile > {
const externalizedNodes : WorkflowNode [ ] = [ ] ;
const savedImageIds = new Map < string , string > ( ) ; // base64 hash -> imageId (for deduplication)
const savedImageIds = new Map < string , string > ( ) ; // base64 hash -> imageId (for deduplication)
for ( const node of workflow . nodes ) {
// Process nodes in parallel batches with controlled concurrency
const newNode = await externalizeNodeImages ( node , workflowPath , savedImageIds ) ;
const BATCH_SIZE = 3 ;
externalizedNodes . push ( newNode ) ;
const externalizedNodes : WorkflowNode [ ] = new Array ( workflow . nodes . length ) ;
for ( let i = 0 ; i < workflow . nodes . length ; i += BATCH_SIZE ) {
const batch = workflow . nodes . slice ( i , i + BATCH_SIZE ) ;
const results = await Promise . all (
batch . map ( ( node , batchIndex ) = >
externalizeNodeImages ( node , workflowPath , savedImageIds )
. then ( result = > ( { index : i + batchIndex , result } ) )
)
) ;
for ( const { index , result } of results ) {
externalizedNodes [ index ] = result ;
}
}
}
return {
return {
@ -276,16 +320,19 @@ async function saveImageAndGetId(
// Use existing ID if provided (for consistency with imageHistory), otherwise generate new
// Use existing ID if provided (for consistency with imageHistory), otherwise generate new
const imageId = existingId || generateImageId ( ) ;
const imageId = existingId || generateImageId ( ) ;
const response = await fetch ( "/api/workflow-images" , {
const response = await fetchWithTimeout (
method : "POST" ,
"/api/workflow-images" ,
headers : { "Content-Type" : "application/json" } ,
{
body : JSON.stringify ( {
method : "POST" ,
workflowPath ,
headers : { "Content-Type" : "application/json" } ,
imageId ,
body : JSON.stringify ( {
imageData ,
workflowPath ,
folder ,
imageId ,
} ) ,
imageData ,
} ) ;
folder ,
} ) ,
}
) ;
const result = await response . json ( ) ;
const result = await response . json ( ) ;