@@ -87,6 +87,7 @@ <h3>stats</h3>
8787 < div class ="stat "> < div class ="val " id ="st-on "> 0</ div > < div class ="lbl "> active</ div > </ div >
8888 < div class ="stat "> < div class ="val " id ="st-and "> —</ div > < div class ="lbl "> AND-N</ div > </ div >
8989 < div class ="stat "> < div class ="val " id ="st-lbl "> —</ div > < div class ="lbl "> layer</ div > </ div >
90+ < div class ="stat " id ="st-frame-box " style ="display:none "> < div class ="val " id ="st-frame "> 1/1</ div > < div class ="lbl "> frame</ div > </ div >
9091 </ div >
9192 </ div >
9293
@@ -111,6 +112,28 @@ <h3>playback</h3>
111112 < input type ="range " id ="speed " min ="1 " max ="300 " value ="30 " style ="flex:1 "
112113 oninput ="stepsPerSec=+this.value;document.getElementById('spd-lbl').textContent=this.value+'/s' ">
113114 </ div >
115+ < div id ="anim-controls " style ="display:flex;align-items:center;gap:6px;margin-top:4px;font-size:10px;color:#555 ">
116+ frame pause < span id ="fp-lbl "> 300ms</ span >
117+ < input type ="range " id ="frame-pause " min ="0 " max ="2000 " value ="300 " style ="flex:1 "
118+ oninput ="framePauseMs=+this.value;document.getElementById('fp-lbl').textContent=this.value+'ms' ">
119+ </ div >
120+ </ div >
121+
122+ < div class ="panel ">
123+ < h3 > export GIF</ h3 >
124+ < div style ="display:flex;flex-direction:column;gap:5px ">
125+ < div style ="display:flex;gap:5px;align-items:center;font-size:10px;color:#777 ">
126+ FPS < input type ="number " id ="gif-fps " value ="10 " min ="1 " max ="60 "
127+ style ="width:38px;background:#111;border:1px solid #333;color:#ccc;padding:2px 4px;font-size:10px ">
128+ step < input type ="number " id ="gif-step " value ="5 " min ="1 " max ="100 "
129+ style ="width:38px;background:#111;border:1px solid #333;color:#ccc;padding:2px 4px;font-size:10px ">
130+ loop < input type ="number " id ="gif-loop " value ="0 " min ="0 " max ="99 "
131+ style ="width:32px;background:#111;border:1px solid #333;color:#ccc;padding:2px 4px;font-size:10px "
132+ title ="0=infinite ">
133+ </ div >
134+ < button onclick ="startGifExport() " id ="gif-btn "> Export GIF</ button >
135+ < div id ="gif-status " style ="font-size:9px;color:#555;min-height:12px "> </ div >
136+ </ div >
114137 </ div >
115138
116139 < div class ="panel ">
@@ -152,6 +175,7 @@ <h3>seeds <span style="color:#3a3a3a;font-size:9px">click = toggle one</span></h
152175const W = 128 , H = 96 ;
153176let seeds = [ ] , bufs = [ ] , active = [ ] , pix = new Uint8Array ( W * H ) ;
154177let cursor = 0 , snapshots = { } , playing = false , stepsPerSec = 30 ;
178+ let animMode = false , frameStarts = [ ] , framePauseMs = 300 ;
155179
156180// ── canvas ────────────────────────────────────────────────────────────────────
157181const cv = document . getElementById ( 'cv' ) , ctx = cv . getContext ( '2d' ) ;
@@ -193,15 +217,31 @@ <h3>seeds <span style="color:#3a3a3a;font-size:9px">click = toggle one</span></h
193217 cursor = n ; updateUI ( ) ; redraw ( ) ;
194218}
195219function stepBy ( d ) { jumpTo ( cursor + d ) ; }
220+ function jumpToFrame ( fi ) { if ( animMode && fi >= 0 && fi < frameStarts . length ) jumpTo ( frameStarts [ fi ] ) ; }
196221function togglePlay ( ) {
197222 playing = ! playing ;
198223 document . getElementById ( 'btn-play' ) . classList . toggle ( 'on' , playing ) ;
199224 document . getElementById ( 'btn-play' ) . textContent = playing ?'⏸' :'▶' ;
200225 if ( playing ) tick ( ) ;
201226}
227+ function currentFrame ( ) {
228+ if ( ! animMode || ! frameStarts . length ) return - 1 ;
229+ let f = 0 ;
230+ for ( let i = 0 ; i < frameStarts . length ; i ++ ) if ( cursor >= frameStarts [ i ] ) f = i ;
231+ return f ;
232+ }
202233function tick ( ) {
203234 if ( ! playing ) return ;
204235 if ( cursor >= seeds . length ) { togglePlay ( ) ; return ; }
236+ // In anim mode: when crossing a frame boundary, pause briefly then continue
237+ if ( animMode && frameStarts . length > 1 && cursor > 0 ) {
238+ const nextFrameIdx = frameStarts . findIndex ( s => s === cursor ) ;
239+ if ( nextFrameIdx > 0 ) {
240+ updateUI ( ) ; redraw ( ) ;
241+ setTimeout ( tick , framePauseMs ) ; // inter-frame pause
242+ return ;
243+ }
244+ }
205245 applyBuf ( pix , bufs [ cursor ] , seeds [ cursor ] . ox , seeds [ cursor ] . oy , seeds [ cursor ] . blk ) ;
206246 active [ cursor ] = true ; cursor ++ ;
207247 updateUI ( ) ; redraw ( ) ;
@@ -210,17 +250,37 @@ <h3>seeds <span style="color:#3a3a3a;font-size:9px">click = toggle one</span></h
210250
211251// ── load ──────────────────────────────────────────────────────────────────────
212252function loadSeeds ( data , presetId ) {
213- seeds = data . seeds || data ;
253+ // Handle animation_flat format: normalize short field names
254+ animMode = data . type === 'animation_flat' || data . type === 'animation' ;
255+ frameStarts = animMode ? ( data . frame_starts || [ ] ) : [ ] ;
256+
257+ let rawSeeds = [ ] ;
258+ if ( animMode && data . type === 'animation_flat' ) {
259+ // flat animation: seeds have {f,s,ox,oy,b,n,w}
260+ rawSeeds = ( data . seeds || [ ] ) . map ( r => ( {
261+ seed :r . s , ox :r . ox , oy :r . oy , blk :r . b , and_n :r . n , warmup :r . w ,
262+ frame :r . f , label :'F' + r . f + '-AND' + r . n , step :0
263+ } ) ) ;
264+ } else if ( animMode && data . frames ) {
265+ // nested animation: flatten all frames' seeds
266+ rawSeeds = [ ] ;
267+ for ( const fr of data . frames ) for ( const s of fr . seeds ) rawSeeds . push ( s ) ;
268+ } else {
269+ rawSeeds = data . seeds || data ;
270+ }
271+
272+ seeds = rawSeeds ;
214273 bufs = seeds . map ( r => makeBuf ( r . seed , r . warmup , r . and_n ) ) ;
215274 active = new Array ( seeds . length ) . fill ( false ) ;
216275 pix = new Uint8Array ( W * H ) ; cursor = 0 ;
217276 snapshots = { 0 :pix . slice ( ) } ;
218277 let s = pix . slice ( ) ;
219278 for ( let i = 0 ; i < seeds . length ; i ++ ) {
220279 applyBuf ( s , bufs [ i ] , seeds [ i ] . ox , seeds [ i ] . oy , seeds [ i ] . blk ) ;
221- if ( ( i + 1 ) % 50 === 0 || i === seeds . length - 1 ) snapshots [ i + 1 ] = s . slice ( ) ;
280+ if ( ( i + 1 ) % 100 === 0 || i === seeds . length - 1 ) snapshots [ i + 1 ] = s . slice ( ) ;
222281 }
223282 document . getElementById ( 'scrubber' ) . max = seeds . length ;
283+ document . getElementById ( 'st-frame-box' ) . style . display = animMode ?'' :'none' ;
224284 // mark active preset
225285 document . querySelectorAll ( '.preset-btn' ) . forEach ( b => b . classList . remove ( 'active' ) ) ;
226286 if ( presetId ) document . getElementById ( 'pb-' + presetId ) ?. classList . add ( 'active' ) ;
@@ -279,6 +339,10 @@ <h3>seeds <span style="color:#3a3a3a;font-size:9px">click = toggle one</span></h
279339 document . getElementById ( 'st-and' ) . textContent = r ?'AND-' + r . and_n :'—' ;
280340 document . getElementById ( 'st-lbl' ) . textContent = r ?r . label . replace ( / L 3 p \d + - / , '' ) :'—' ;
281341 document . getElementById ( 'scrubber' ) . value = cursor ;
342+ if ( animMode && frameStarts . length ) {
343+ const fi = currentFrame ( ) ;
344+ document . getElementById ( 'st-frame' ) . textContent = ( fi + 1 ) + '/' + frameStarts . length ;
345+ }
282346 updateStats ( ) ; updateLevelBtns ( ) ;
283347 // seed list scroll
284348 const prev = document . querySelector ( '.seed-row.active' ) ;
@@ -291,11 +355,21 @@ <h3>seeds <span style="color:#3a3a3a;font-size:9px">click = toggle one</span></h
291355
292356// ── presets ───────────────────────────────────────────────────────────────────
293357const PRESETS = [
294- { id :'cascade' , label :'Cascade AND-3→7' , meta :'1171 seeds · 1.2% @1205' ,
358+ { id :'foveal' , label :'Foveal AND-3→7 ★' , meta :'939 seeds · 0.06% @1209 · CUDA 28s' ,
359+ paths :[ '../data/foveal_cascade_seeds.json' , 'data/foveal_cascade_seeds.json' ] } ,
360+ { id :'che_anim' , label :'Che animation 🎬' , meta :'25 frames · 16,114 seeds · 0.07% avg · delta' ,
361+ paths :[ '../data/che_anim_flat.json' , 'data/che_anim_flat.json' ] } ,
362+ { id :'bgt_a' , label :'Budget A · KF256/DT128' , meta :'10 frames · 1384 seeds · shrink=0.90 · 23%→14%' ,
363+ paths :[ '../data/budget_conf_a.json' , 'data/budget_conf_a.json' ] } ,
364+ { id :'bgt_b' , label :'Budget B · KF128/DT64' , meta :'10 frames · 704 seeds · shrink=0.70 · vignette' ,
365+ paths :[ '../data/budget_conf_b.json' , 'data/budget_conf_b.json' ] } ,
366+ { id :'bgt_c' , label :'Budget C · KF512/DT256' , meta :'10 frames · 1381 seeds · shrink=0.85 · 14%' ,
367+ paths :[ '../data/budget_conf_c.json' , 'data/budget_conf_c.json' ] } ,
368+ { id :'cascade' , label :'Cascade AND-3→7' , meta :'1171 seeds · 1.2% @1205' ,
295369 paths :[ '../data/cascade_seeds.json' , 'data/cascade_seeds.json' ] } ,
296- { id :'and4' , label :'AND-4 flat' , meta :'~187 seeds · 25% @213' ,
370+ { id :'and4' , label :'AND-4 flat' , meta :'~187 seeds · 25% @213' ,
297371 paths :[ '../data/flat_and4_seeds.json' , 'data/flat_and4_seeds.json' ] } ,
298- { id :'and7' , label :'AND-7 flat' , meta :'1131 seeds · 4.3% @1205' ,
372+ { id :'and7' , label :'AND-7 flat' , meta :'1131 seeds · 4.3% @1205' ,
299373 paths :[ '../data/flat_and7_seeds.json' , 'data/flat_and7_seeds.json' ] } ,
300374] ;
301375
@@ -338,7 +412,141 @@ <h3>seeds <span style="color:#3a3a3a;font-size:9px">click = toggle one</span></h
338412} ) ;
339413
340414buildPresets ( ) ;
341- fetchPreset ( PRESETS [ 0 ] ) ; // auto-load cascade
415+ fetchPreset ( PRESETS [ 0 ] ) ; // auto-load foveal
416+
417+ // ── GIF export ────────────────────────────────────────────────────────────────
418+ // Inline LZW + GIF89a encoder — no external libraries needed.
419+
420+ function lzwEncode ( data , minCodeSize ) {
421+ const clearCode = 1 << minCodeSize ;
422+ const eoi = clearCode + 1 ;
423+ let codeSize = minCodeSize + 1 ;
424+ let nextCode = eoi + 1 ;
425+ const table = new Map ( ) ;
426+ const bits = [ ] ; let buf = 0 , nbits = 0 ;
427+
428+ const initTable = ( ) => {
429+ table . clear ( ) ;
430+ for ( let i = 0 ; i < clearCode ; i ++ ) table . set ( '' + i , i ) ;
431+ codeSize = minCodeSize + 1 ; nextCode = eoi + 1 ;
432+ } ;
433+ const emit = ( code ) => {
434+ buf |= code << nbits ; nbits += codeSize ;
435+ while ( nbits >= 8 ) { bits . push ( buf & 0xFF ) ; buf >>= 8 ; nbits -= 8 ; }
436+ } ;
437+
438+ initTable ( ) ; emit ( clearCode ) ;
439+ let prefix = '' ;
440+ for ( let i = 0 ; i < data . length ; i ++ ) {
441+ const px = data [ i ] ;
442+ const key = prefix === '' ? '' + px : prefix + ',' + px ;
443+ if ( table . has ( key ) ) { prefix = key ; continue ; }
444+ emit ( table . get ( prefix === '' ? '' + px : prefix ) ) ;
445+ table . set ( key , nextCode ++ ) ;
446+ prefix = '' + px ;
447+ if ( nextCode > ( 1 << codeSize ) && codeSize < 12 ) codeSize ++ ;
448+ if ( nextCode > 4094 ) { emit ( clearCode ) ; initTable ( ) ; }
449+ }
450+ if ( prefix !== '' ) emit ( table . get ( prefix ) ) ;
451+ emit ( eoi ) ;
452+ if ( nbits > 0 ) bits . push ( buf & 0xFF ) ;
453+ return bits ;
454+ }
455+
456+ function buildGIF ( frames , delayCs , loopCount ) {
457+ // frames: array of Uint8Array(W*H) with values 0|1
458+ // delayCs: delay in centiseconds per frame
459+ const b = [ ] ;
460+ const u16le = ( v ) => [ v & 0xFF , v >> 8 ] ;
461+ const push = ( v ) => b . push ( v ) ;
462+ const seq = ( arr ) => arr . forEach ( v => b . push ( v ) ) ;
463+
464+ // Header + Logical Screen Descriptor
465+ seq ( [ 0x47 , 0x49 , 0x46 , 0x38 , 0x39 , 0x61 ] ) ; // GIF89a
466+ seq ( u16le ( W ) ) ; seq ( u16le ( H ) ) ;
467+ push ( 0x80 ) ; push ( 0 ) ; push ( 0 ) ; // GCT flag, bg=0, aspect=0
468+ // Global Color Table: index 0 = black, index 1 = white
469+ seq ( [ 0 , 0 , 0 , 255 , 255 , 255 ] ) ;
470+
471+ // Netscape loop extension
472+ seq ( [ 0x21 , 0xFF , 0x0B ] ) ;
473+ seq ( [ 78 , 69 , 84 , 83 , 67 , 65 , 80 , 69 , 50 , 46 , 48 ] ) ; // NETSCAPE2.0
474+ push ( 3 ) ; push ( 1 ) ; seq ( u16le ( loopCount ) ) ; push ( 0 ) ;
475+
476+ for ( const frame of frames ) {
477+ // Graphic Control Extension
478+ seq ( [ 0x21 , 0xF9 , 0x04 , 0x00 ] ) ; seq ( u16le ( delayCs ) ) ; push ( 0 ) ; push ( 0 ) ;
479+ // Image Descriptor
480+ push ( 0x2C ) ; seq ( u16le ( 0 ) ) ; seq ( u16le ( 0 ) ) ; seq ( u16le ( W ) ) ; seq ( u16le ( H ) ) ; push ( 0 ) ;
481+ // Image Data (LZW min code size = 2 for 2-color GIF)
482+ const lzw = lzwEncode ( frame , 2 ) ;
483+ push ( 2 ) ; // minimum code size
484+ for ( let i = 0 ; i < lzw . length ; ) {
485+ const chunk = Math . min ( 255 , lzw . length - i ) ;
486+ push ( chunk ) ;
487+ for ( let j = 0 ; j < chunk ; j ++ ) push ( lzw [ i ++ ] ) ;
488+ }
489+ push ( 0 ) ; // block terminator
490+ }
491+ push ( 0x3B ) ; // GIF trailer
492+ return new Uint8Array ( b ) ;
493+ }
494+
495+ function captureFrame ( inv ) {
496+ const f = new Uint8Array ( W * H ) ;
497+ for ( let i = 0 ; i < W * H ; i ++ ) f [ i ] = inv ? 1 - pix [ i ] : pix [ i ] ;
498+ return f ;
499+ }
500+
501+ async function startGifExport ( ) {
502+ if ( ! seeds . length ) { alert ( 'Load a preset first' ) ; return ; }
503+ const fps = Math . max ( 1 , Math . min ( 60 , + document . getElementById ( 'gif-fps' ) . value || 10 ) ) ;
504+ const step = Math . max ( 1 , Math . min ( 100 , + document . getElementById ( 'gif-step' ) . value || 5 ) ) ;
505+ const loop = Math . max ( 0 , + document . getElementById ( 'gif-loop' ) . value || 0 ) ;
506+ const inv = document . getElementById ( 'chk-inv' ) . checked ;
507+ const delayCs = Math . round ( 100 / fps ) ;
508+ const status = document . getElementById ( 'gif-status' ) ;
509+ const btn = document . getElementById ( 'gif-btn' ) ;
510+
511+ btn . disabled = true ;
512+ const frames = [ ] ;
513+ const savedCursor = cursor ;
514+
515+ // Frame 0: blank canvas
516+ const blank = new Uint8Array ( W * H ) ;
517+ frames . push ( inv ? blank . map ( v => 1 - v ) : blank . slice ( ) ) ;
518+
519+ // Step through seeds
520+ let s = new Uint8Array ( W * H ) ;
521+ for ( let i = 0 ; i < seeds . length ; i ++ ) {
522+ applyBuf ( s , bufs [ i ] , seeds [ i ] . ox , seeds [ i ] . oy , seeds [ i ] . blk ) ;
523+ if ( ( i + 1 ) % step === 0 || i === seeds . length - 1 ) {
524+ const f = new Uint8Array ( W * H ) ;
525+ for ( let j = 0 ; j < W * H ; j ++ ) f [ j ] = inv ? 1 - s [ j ] : s [ j ] ;
526+ frames . push ( f ) ;
527+ if ( frames . length % 10 === 0 ) {
528+ status . textContent = `encoding… ${ i + 1 } /${ seeds . length } steps, ${ frames . length } frames` ;
529+ await new Promise ( r => setTimeout ( r , 0 ) ) ; // yield to UI
530+ }
531+ }
532+ }
533+
534+ // Restore display
535+ jumpTo ( savedCursor ) ;
536+
537+ status . textContent = `building GIF (${ frames . length } frames)…` ;
538+ await new Promise ( r => setTimeout ( r , 0 ) ) ;
539+
540+ const gif = buildGIF ( frames , delayCs , loop ) ;
541+ const blob = new Blob ( [ gif ] , { type : 'image/gif' } ) ;
542+ const url = URL . createObjectURL ( blob ) ;
543+ const a = document . createElement ( 'a' ) ;
544+ a . href = url ; a . download = 'cascade_animation.gif' ; a . click ( ) ;
545+ URL . revokeObjectURL ( url ) ;
546+
547+ status . textContent = `done: ${ frames . length } frames, ${ ( gif . length / 1024 ) . toFixed ( 0 ) } KB` ;
548+ btn . disabled = false ;
549+ }
342550</ script >
343551</ body >
344552</ html >
0 commit comments