@@ -35,6 +35,21 @@ const COLOR = {
3535const HISTORY_LIMIT = 80 ;
3636const POINT_HIT_PX = 8 ;
3737
38+ // Module-level decoded-image cache keyed by URL.
39+ // Fixes "slow image load": when the user pans the graph or re-opens the
40+ // node, we re-use the already-decoded HTMLImageElement instead of issuing
41+ // a fresh fetch + decode every time the URL is re-set.
42+ const IMG_CACHE = new Map ( ) ; // url -> {img, w, h}
43+ const IMG_CACHE_MAX = 32 ;
44+ function _cacheGet ( url ) { return IMG_CACHE . get ( url ) || null ; }
45+ function _cachePut ( url , entry ) {
46+ IMG_CACHE . set ( url , entry ) ;
47+ if ( IMG_CACHE . size > IMG_CACHE_MAX ) {
48+ const firstKey = IMG_CACHE . keys ( ) . next ( ) . value ;
49+ if ( firstKey !== undefined ) IMG_CACHE . delete ( firstKey ) ;
50+ }
51+ }
52+
3853class Editor {
3954 constructor ( node ) {
4055 this . node = node ;
@@ -63,6 +78,7 @@ class Editor {
6378 this . splineType = "catmull_rom" ;
6479 this . closedDefault = true ;
6580 this . samplesPerSegment = 20 ;
81+ this . centripetalAlpha = 0.5 ;
6682 }
6783
6884 snapshot ( ) { return JSON . stringify ( { s : this . shapes , a : this . active } ) ; }
@@ -173,7 +189,30 @@ class Editor {
173189
174190 setRefImage ( url , ow , oh ) {
175191 if ( url === this . refUrl && this . refImg && this . refImg . complete ) return ;
192+ const sameUrl = ( url === this . refUrl ) ;
176193 this . refUrl = url ;
194+
195+ // Cache hit: zero-cost re-use; do NOT reset zoom/pan when the URL
196+ // didn't actually change (was the source of the bounce bug — every
197+ // re-render set refImg again, clearing _fitted, snapping the view).
198+ const cached = _cacheGet ( url ) ;
199+ if ( cached ?. img ?. complete ) {
200+ this . refImg = cached . img ;
201+ const w = ow || cached . w ;
202+ const h = oh || cached . h ;
203+ if ( w > 0 && h > 0 && ( this . canvasW !== w || this . canvasH !== h ) ) {
204+ this . canvasW = w ; this . canvasH = h ;
205+ const wW = this . node . widgets ?. find ( x => x . name === "width" ) ;
206+ const hW = this . node . widgets ?. find ( x => x . name === "height" ) ;
207+ if ( wW && + wW . value !== w ) wW . value = w ;
208+ if ( hW && + hW . value !== h ) hW . value = h ;
209+ this . _fitted = false ; // dims changed — a fresh fit is correct
210+ }
211+ if ( ! sameUrl ) this . _fitted = false ;
212+ this . onLoaded ?. ( ) ;
213+ return ;
214+ }
215+
177216 const img = new Image ( ) ;
178217 img . crossOrigin = "anonymous" ;
179218 img . onload = ( ) => {
@@ -187,17 +226,15 @@ class Editor {
187226 if ( wW && + wW . value !== w ) wW . value = w ;
188227 if ( hW && + hW . value !== h ) hW . value = h ;
189228 }
229+ _cachePut ( url , { img, w, h } ) ;
190230 this . _fitted = false ;
191231 this . onLoaded ?. ( ) ;
192232 } ;
193233 img . src = url ;
194234 }
195235}
196236
197- // ─────────────────────────────────────────────────────────────────────
198- // Spline sampling (Catmull-Rom, polyline) – used only for preview rendering
199- // ─────────────────────────────────────────────────────────────────────
200- function catmullRom ( points , samplesPerSeg , closed ) {
237+ function catmullRom ( points , samplesPerSeg , closed , alpha ) {
201238 const n = points . length ;
202239 if ( n < 2 ) return points . slice ( ) ;
203240 if ( n === 2 ) {
@@ -215,10 +252,11 @@ function catmullRom(points, samplesPerSeg, closed) {
215252
216253 const out = [ ] ;
217254 const segs = closed ? n : n - 1 ;
255+ // Centripetal Catmull-Rom — alpha is now configurable (0=uniform,
256+ // 0.5=centripetal, 1=chordal). 0.5 is the default and avoids cusps.
257+ const a = ( typeof alpha === "number" ) ? Math . max ( 0 , Math . min ( 1 , alpha ) ) : 0.5 ;
218258 for ( let i = 0 ; i < segs ; i ++ ) {
219259 const p0 = ext [ i ] , p1 = ext [ i + 1 ] , p2 = ext [ i + 2 ] , p3 = ext [ i + 3 ] ;
220- // Centripetal alpha=0.5
221- const a = 0.5 ;
222260 const t01 = Math . pow ( Math . hypot ( p1 . x - p0 . x , p1 . y - p0 . y ) , a ) || 1e-6 ;
223261 const t12 = Math . pow ( Math . hypot ( p2 . x - p1 . x , p2 . y - p1 . y ) , a ) || 1e-6 ;
224262 const t23 = Math . pow ( Math . hypot ( p3 . x - p2 . x , p3 . y - p2 . y ) , a ) || 1e-6 ;
@@ -244,11 +282,11 @@ function catmullRom(points, samplesPerSeg, closed) {
244282 return out ;
245283}
246284
247- function sampleShape ( sh , samplesPerSeg ) {
285+ function sampleShape ( sh , samplesPerSeg , alpha ) {
248286 const pts = sh . points ;
249287 if ( pts . length < 2 ) return pts . slice ( ) ;
250288 if ( sh . type === "polyline" ) return pts . slice ( ) ;
251- return catmullRom ( pts , samplesPerSeg , sh . closed ) ;
289+ return catmullRom ( pts , samplesPerSeg , sh . closed , alpha ) ;
252290}
253291
254292function draw ( ed , ctx , vw , vh ) {
@@ -278,7 +316,7 @@ function draw(ed, ctx, vw, vh) {
278316 const isActive = s === ed . active ;
279317
280318 if ( sh . points . length >= 2 ) {
281- const sampled = sampleShape ( sh , ed . samplesPerSegment ) ;
319+ const sampled = sampleShape ( sh , ed . samplesPerSegment , ed . centripetalAlpha ) ;
282320 ctx . strokeStyle = c ;
283321 ctx . lineWidth = ( isActive ? 2.0 : 1.5 ) / z ;
284322 ctx . fillStyle = c + "22" ;
@@ -370,11 +408,13 @@ function installEditor(node) {
370408 const sps = node . widgets ?. find ( x => x . name === "samples_per_segment" ) ;
371409 const w = node . widgets ?. find ( x => x . name === "width" ) ;
372410 const h = node . widgets ?. find ( x => x . name === "height" ) ;
411+ const ca = node . widgets ?. find ( x => x . name === "centripetal_alpha" ) ;
373412 if ( t ) ed . splineType = t . value ;
374413 if ( c ) ed . closedDefault = ! ! c . value ;
375414 if ( sps ) ed . samplesPerSegment = + sps . value || 20 ;
376415 if ( w && + w . value > 0 ) ed . canvasW = + w . value ;
377416 if ( h && + h . value > 0 ) ed . canvasH = + h . value ;
417+ if ( ca && typeof ca . value === "number" ) ed . centripetalAlpha = ca . value ;
378418 } ;
379419 syncFromWidgets ( ) ;
380420 ed . load ( ) ;
@@ -588,9 +628,15 @@ function installEditor(node) {
588628 canvas . width = w * dpr ;
589629 canvas . height = h * dpr ;
590630 ctx . setTransform ( dpr , 0 , 0 , dpr , 0 , 0 ) ;
591- // Re-fit on every layout change — fixes "image pad zoom drift" when
592- // the parent ComfyUI graph is zoomed (was: only fit once).
593- if ( sizeChanged || ! ed . _fitted ) ed . fitView ( w , h ) ;
631+ // Auto-fit ONLY on first sizing or when a new image set _fitted=false.
632+ // Previously we re-fit on every resize → any container reflow snapped
633+ // the user's zoom/pan back to "fit", which is the "bouncy image"
634+ // complaint. Now we just clamp pan into the new viewport instead.
635+ if ( ! ed . _fitted ) {
636+ ed . fitView ( w , h ) ;
637+ } else {
638+ ed . clampPan ( w , h ) ;
639+ }
594640 return true ;
595641 }
596642
@@ -784,7 +830,7 @@ function installEditor(node) {
784830 } ) ;
785831
786832 // React to widget edits
787- for ( const wn of [ "spline_type" , "closed" , "samples_per_segment" , "width" , "height" ] ) {
833+ for ( const wn of [ "spline_type" , "closed" , "samples_per_segment" , "width" , "height" , "centripetal_alpha" ] ) {
788834 const w = node . widgets ?. find ( x => x . name === wn ) ;
789835 if ( ! w ) continue ;
790836 const orig = w . callback ;
@@ -816,8 +862,12 @@ function installEditor(node) {
816862 const origExec = node . onExecuted ;
817863 node . onExecuted = function ( out ) {
818864 origExec ?. apply ( this , arguments ) ;
819- if ( out ?. preview_b64 ?. [ 0 ] ) {
820- ed . setRefImage ( "data:image/jpeg;base64," + out . preview_b64 [ 0 ] ) ;
865+ // Server may emit either key depending on version; accept both.
866+ const b64 = out ?. preview_b64 ?. [ 0 ] ?? out ?. preview ?. [ 0 ] ;
867+ if ( b64 ) {
868+ // Bare base64 → prepend data-url prefix; full data URL → pass through.
869+ const url = b64 . startsWith ( "data:" ) ? b64 : ( "data:image/jpeg;base64," + b64 ) ;
870+ ed . setRefImage ( url ) ;
821871 } else {
822872 tryDiscoverRef ( ) ;
823873 }
@@ -853,3 +903,8 @@ app.registerExtension({
853903 installEditor ( node ) ;
854904 } ,
855905} ) ;
906+
907+ // Export for reuse by sibling editors (e.g. SplinePathFlowMaskMEC).
908+ // Stash on window so a separate module file can pick it up without a build step.
909+ window . __MEC_SPLINE_EDITOR__ = { installEditor, IMG_CACHE } ;
910+
0 commit comments