@@ -60,6 +60,41 @@ async function splitImage(src: string, rows: number, cols: number): Promise<stri
6060 return tiles ;
6161}
6262
63+ function canvasToBlob ( canvas : HTMLCanvasElement , type : string , quality ?: number ) : Promise < Blob > {
64+ return new Promise ( ( resolve , reject ) => {
65+ canvas . toBlob ( ( b ) => ( b ? resolve ( b ) : reject ( new Error ( "Canvas toBlob failed" ) ) ) , type , quality ) ;
66+ } ) ;
67+ }
68+
69+ /**
70+ * PNG when it fits under `maxBytes`; otherwise JPEG with descending quality,
71+ * then 25%-per-pass downscale. Ensures the merged image stays small enough to
72+ * be used as a source for downstream nodes (model upload limits).
73+ */
74+ async function canvasToCappedBlob ( canvas : HTMLCanvasElement , maxBytes : number ) : Promise < Blob > {
75+ const png = await canvasToBlob ( canvas , "image/png" ) ;
76+ if ( png . size <= maxBytes ) return png ;
77+
78+ let work = canvas ;
79+ let quality = 0.92 ;
80+ for ( let attempt = 0 ; attempt < 6 ; attempt ++ ) {
81+ const blob = await canvasToBlob ( work , "image/jpeg" , quality ) ;
82+ if ( blob . size <= maxBytes ) return blob ;
83+ if ( quality > 0.6 ) { quality -= 0.1 ; continue ; }
84+ const scaled = document . createElement ( "canvas" ) ;
85+ scaled . width = Math . max ( 1 , Math . floor ( work . width * 0.75 ) ) ;
86+ scaled . height = Math . max ( 1 , Math . floor ( work . height * 0.75 ) ) ;
87+ const ctx = scaled . getContext ( "2d" ) ;
88+ if ( ! ctx ) break ;
89+ ctx . drawImage ( work , 0 , 0 , scaled . width , scaled . height ) ;
90+ work = scaled ;
91+ quality = 0.85 ;
92+ }
93+ return canvasToBlob ( work , "image/jpeg" , 0.7 ) ;
94+ }
95+
96+ const REFINE_MERGED_MAX_BYTES = 15 * 1024 * 1024 ;
97+
6398async function stitchTiles ( srcs : string [ ] , rows : number , cols : number ) : Promise < Blob > {
6499 const imgs = await Promise . all ( srcs . map ( loadImage ) ) ;
65100 // Use the largest dimensions seen across tiles (model output sizes can drift slightly)
@@ -76,9 +111,7 @@ async function stitchTiles(srcs: string[], rows: number, cols: number): Promise<
76111 ctx . drawImage ( imgs [ idx ] , c * tileW , r * tileH , tileW , tileH ) ;
77112 }
78113 }
79- return new Promise ( ( resolve , reject ) => {
80- canvas . toBlob ( ( blob ) => ( blob ? resolve ( blob ) : reject ( new Error ( "Canvas toBlob failed" ) ) ) , "image/png" ) ;
81- } ) ;
114+ return canvasToCappedBlob ( canvas , REFINE_MERGED_MAX_BYTES ) ;
82115}
83116
84117async function uploadBlob ( blob : Blob ) : Promise < string > {
@@ -353,68 +386,19 @@ export function useWorkflowState(): WorkflowContextValue {
353386
354387 // ── Workflow execution engine ──────────────────────────────────
355388
356- const executeWorkflow = useCallback ( async ( ) => {
357- if ( ! activeIdRef . current || executing ) return ;
358- setExecuting ( true ) ;
359- setError ( null ) ;
360-
361- try {
362- // Topological sort
363- const nodeMap = new Map ( nodes . map ( ( n ) => [ n . id , n ] ) ) ;
364- const inDegree = new Map < string , number > ( ) ;
365- const adjList = new Map < string , string [ ] > ( ) ;
366-
367- nodes . forEach ( ( n ) => {
368- inDegree . set ( n . id , 0 ) ;
369- adjList . set ( n . id , [ ] ) ;
370- } ) ;
389+ type ExecOutputs = Map < string , { text ?: string ; promptText ?: string ; imageUrl ?: string } > ;
371390
372- // Real edges + virtual edges from {{nodeId}} pill references inside prompt text.
373- // Without these, a prompt that references an analyze node only via {{...}} (no
374- // drawn edge) can run before the analyze node and resolve to "".
375- const allDeps : Array < { source : string ; target : string } > = edges . map ( ( e ) => ( { source : e . source , target : e . target } ) ) ;
376- for ( const n of nodes ) {
377- if ( n . type !== "prompt" ) continue ;
378- const text = ( ( n . data as Record < string , unknown > ) . text as string ) || "" ;
379- for ( const m of text . matchAll ( / \{ \{ ( .+ ?) \} \} / g) ) {
380- const refId = m [ 1 ] . trim ( ) ;
381- if ( refId === n . id || ! nodeMap . has ( refId ) ) continue ;
382- if ( allDeps . some ( ( d ) => d . source === refId && d . target === n . id ) ) continue ;
383- allDeps . push ( { source : refId , target : n . id } ) ;
384- }
385- }
386- allDeps . forEach ( ( e ) => {
387- inDegree . set ( e . target , ( inDegree . get ( e . target ) || 0 ) + 1 ) ;
388- adjList . get ( e . source ) ?. push ( e . target ) ;
389- } ) ;
390-
391- // Topological levels — nodes at the same level have no dependencies on
392- // each other and can run in parallel.
393- const levels : string [ ] [ ] = [ ] ;
394- let frontier : string [ ] = [ ] ;
395- inDegree . forEach ( ( deg , id ) => { if ( deg === 0 ) frontier . push ( id ) ; } ) ;
396- while ( frontier . length > 0 ) {
397- levels . push ( frontier ) ;
398- const next : string [ ] = [ ] ;
399- for ( const id of frontier ) {
400- for ( const child of adjList . get ( id ) || [ ] ) {
401- const newDeg = ( inDegree . get ( child ) || 1 ) - 1 ;
402- inDegree . set ( child , newDeg ) ;
403- if ( newDeg === 0 ) next . push ( child ) ;
404- }
405- }
406- frontier = next ;
407- }
408-
409- // Execute nodes in order, passing data through edges
410- const outputs = new Map < string , { text ?: string ; promptText ?: string ; imageUrl ?: string } > ( ) ;
411-
412- const executeNode = async ( nodeId : string ) => {
391+ const executeNode = useCallback ( async (
392+ nodeId : string ,
393+ nodeMap : Map < string , Node > ,
394+ allEdges : Edge [ ] ,
395+ outputs : ExecOutputs ,
396+ ) => {
413397 const node = nodeMap . get ( nodeId ) ;
414398 if ( ! node ) return ;
415399
416400 // Gather inputs from connected source nodes
417- const incoming = edges . filter ( ( e ) => e . target === nodeId ) ;
401+ const incoming = allEdges . filter ( ( e ) => e . target === nodeId ) ;
418402 let inputText = "" ;
419403 let inputPromptText = "" ;
420404 const inputImages : string [ ] = [ ] ;
@@ -602,20 +586,64 @@ export function useWorkflowState(): WorkflowContextValue {
602586 break ;
603587 }
604588 }
605- } ;
589+ } , [ updateNodeData ] ) ;
590+
591+ const executeWorkflow = useCallback ( async ( ) => {
592+ if ( ! activeIdRef . current || executing ) return ;
593+ setExecuting ( true ) ;
594+ setError ( null ) ;
595+
596+ try {
597+ // Topological sort
598+ const nodeMap = new Map ( nodes . map ( ( n ) => [ n . id , n ] ) ) ;
599+ const inDegree = new Map < string , number > ( ) ;
600+ const adjList = new Map < string , string [ ] > ( ) ;
601+
602+ nodes . forEach ( ( n ) => {
603+ inDegree . set ( n . id , 0 ) ;
604+ adjList . set ( n . id , [ ] ) ;
605+ } ) ;
606+
607+ // Real edges + virtual edges from {{nodeId}} pill references inside prompt text.
608+ const allDeps : Array < { source : string ; target : string } > = edges . map ( ( e ) => ( { source : e . source , target : e . target } ) ) ;
609+ for ( const n of nodes ) {
610+ if ( n . type !== "prompt" ) continue ;
611+ const text = ( ( n . data as Record < string , unknown > ) . text as string ) || "" ;
612+ for ( const m of text . matchAll ( / \{ \{ ( .+ ?) \} \} / g) ) {
613+ const refId = m [ 1 ] . trim ( ) ;
614+ if ( refId === n . id || ! nodeMap . has ( refId ) ) continue ;
615+ if ( allDeps . some ( ( d ) => d . source === refId && d . target === n . id ) ) continue ;
616+ allDeps . push ( { source : refId , target : n . id } ) ;
617+ }
618+ }
619+ allDeps . forEach ( ( e ) => {
620+ inDegree . set ( e . target , ( inDegree . get ( e . target ) || 0 ) + 1 ) ;
621+ adjList . get ( e . source ) ?. push ( e . target ) ;
622+ } ) ;
623+
624+ const levels : string [ ] [ ] = [ ] ;
625+ let frontier : string [ ] = [ ] ;
626+ inDegree . forEach ( ( deg , id ) => { if ( deg === 0 ) frontier . push ( id ) ; } ) ;
627+ while ( frontier . length > 0 ) {
628+ levels . push ( frontier ) ;
629+ const next : string [ ] = [ ] ;
630+ for ( const id of frontier ) {
631+ for ( const child of adjList . get ( id ) || [ ] ) {
632+ const newDeg = ( inDegree . get ( child ) || 1 ) - 1 ;
633+ inDegree . set ( child , newDeg ) ;
634+ if ( newDeg === 0 ) next . push ( child ) ;
635+ }
636+ }
637+ frontier = next ;
638+ }
606639
607- // Same-level nodes run in parallel; rate-limit-aware throttling lives
608- // server-side (the /api/analyze and /api/generate endpoints retry on
609- // 429 with backoff). Promise.allSettled prevents one failed node from
610- // killing the rest of the level.
640+ const outputs : ExecOutputs = new Map ( ) ;
611641 for ( const level of levels ) {
612- await Promise . allSettled ( level . map ( executeNode ) ) ;
642+ await Promise . allSettled ( level . map ( ( nid ) => executeNode ( nid , nodeMap , edges , outputs ) ) ) ;
613643 }
614644
615- // Auto-save after execution
616645 await saveWorkflow ( ) ;
617646
618- // Refresh generations
619647 if ( activeIdRef . current ) {
620648 const gens = await api < Generation [ ] > ( "GET" , `/api/generations/${ activeIdRef . current } ` ) ;
621649 setGenerations ( gens ) ;
@@ -625,7 +653,52 @@ export function useWorkflowState(): WorkflowContextValue {
625653 } finally {
626654 setExecuting ( false ) ;
627655 }
628- } , [ nodes , edges , executing , updateNodeData , saveWorkflow ] ) ;
656+ } , [ nodes , edges , executing , executeNode , saveWorkflow ] ) ;
657+
658+ /**
659+ * Run a single node, reusing upstream nodes' previously-stored runtime
660+ * outputs as inputs. Useful for tweaking config (model, prompts) without
661+ * paying for the whole upstream chain again.
662+ */
663+ const runNode = useCallback ( async ( nodeId : string ) => {
664+ if ( ! activeIdRef . current || executing ) return ;
665+ setExecuting ( true ) ;
666+ setError ( null ) ;
667+
668+ try {
669+ const nodeMap = new Map ( nodes . map ( ( n ) => [ n . id , n ] ) ) ;
670+ const target = nodeMap . get ( nodeId ) ;
671+ if ( ! target ) return ;
672+
673+ // Snapshot upstream outputs from each source node's persisted runtime data.
674+ const outputs : ExecOutputs = new Map ( ) ;
675+ const incoming = edges . filter ( ( e ) => e . target === nodeId ) ;
676+ for ( const edge of incoming ) {
677+ const src = nodeMap . get ( edge . source ) ;
678+ if ( ! src ) continue ;
679+ const d = src . data as Record < string , unknown > ;
680+ const out : { text ?: string ; promptText ?: string ; imageUrl ?: string } = { } ;
681+ const txt = ( d . text as string ) || ( d . result as string ) ;
682+ if ( txt ) out . text = txt ;
683+ const img = d . imageUrl as string ;
684+ if ( img ) out . imageUrl = img ;
685+ const lastPrompt = d . lastPrompt as string ;
686+ if ( lastPrompt && ! out . text ) out . promptText = lastPrompt ;
687+ outputs . set ( edge . source , out ) ;
688+ }
689+
690+ await executeNode ( nodeId , nodeMap , edges , outputs ) ;
691+
692+ if ( activeIdRef . current ) {
693+ const gens = await api < Generation [ ] > ( "GET" , `/api/generations/${ activeIdRef . current } ` ) ;
694+ setGenerations ( gens ) ;
695+ }
696+ } catch ( e ) {
697+ setError ( String ( e ) ) ;
698+ } finally {
699+ setExecuting ( false ) ;
700+ }
701+ } , [ nodes , edges , executing , executeNode ] ) ;
629702
630703 const clearError = useCallback ( ( ) => setError ( null ) , [ ] ) ;
631704
@@ -646,6 +719,7 @@ export function useWorkflowState(): WorkflowContextValue {
646719 deleteNode,
647720 saveWorkflow,
648721 executeWorkflow,
722+ runNode,
649723 executing,
650724 models,
651725 generations,
0 commit comments