2424
2525/* global postMessage, self, performance, requestAnimationFrame, cancelAnimationFrame */
2626
27+ import { findStripsMissingLayout } from './screenmap_sync.ts' ;
28+
2729// CRITICAL FIX: Workers don't support import maps, but Three.js jsm files use bare "three" imports.
2830// Solution: Use local vendor files with patched relative imports to three.module.js
2931
@@ -99,6 +101,8 @@ async function loadThreeJSModules() {
99101 * @property {number } frameCaptureInterval - Milliseconds between frame captures
100102 * @property {number } lastFrameCaptureTime - Timestamp of last frame capture
101103 * @property {Object } screenMaps - Dictionary of screenmaps (stripId → screenmap, push-based from C++)
104+ * @property {Object } screenMapRefreshAttempts - Late screenmap re-fetch counters per strip id
105+ * @property {boolean } screenMapRecoveryReported - Whether the late-recovery notice was already emitted
102106 */
103107
104108/**
@@ -142,6 +146,13 @@ const workerState = {
142146 screenMapsDirty : false , // screenMaps changed and the main thread has not been told yet (main-thread rendering)
143147 renderOnMainThread : false , // frames are posted to the main thread instead of drawn on an OffscreenCanvas
144148
149+ // Late screenmap recovery (#250): FastLED fills in default layouts lazily on
150+ // the first exported frame (jsFillInMissingScreenMaps), which happens after
151+ // the single post-setup getScreenMapData() read in handleStart(). Strips seen
152+ // in frame data without a layout are re-fetched for a few frames.
153+ screenMapRefreshAttempts : { } , // stripId (string) -> late re-fetches performed for that strip
154+ screenMapRecoveryReported : false , // stdout notice emitted once per session
155+
145156 // Audio sample queue - samples buffered here from onmessage, flushed to WASM at frame start
146157 audioSampleQueue : [ ] ,
147158 audioSampleBufferedOnce : false ,
@@ -159,6 +170,9 @@ const performanceMonitor = {
159170
160171} ;
161172
173+ /** Maximum late getScreenMapData() re-fetches attempted per strip (#250). */
174+ const MAX_SCREENMAP_REFRESH_ATTEMPTS = 5 ;
175+
162176/**
163177 * Debug logging in worker context
164178 * @param {string } level - Log level (LOG, ERROR, TRACE)
@@ -608,6 +622,68 @@ async function initializeGraphicsManager() {
608622 }
609623}
610624
625+ /**
626+ * Reads the current screenmap dictionary out of the WASM module.
627+ * Shared by the post-setup read in handleStart() and the late refresh in
628+ * refreshScreenMapsIfIncomplete() (#250).
629+ * @returns {Object|null } Parsed screenmap dictionary, or null when unavailable
630+ */
631+ function fetchScreenMapsFromWasm ( ) {
632+ const Module = workerState . fastledModule ;
633+ if ( ! Module || ! Module . cwrap ) {
634+ return null ;
635+ }
636+
637+ // Bind WASM functions if not already bound (same binding set as extractFrameData)
638+ if ( ! workerState . wasmFunctions ) {
639+ workerState . wasmFunctions = {
640+ getFrameData : Module . cwrap ( 'getFrameData' , 'number' , [ 'number' ] ) ,
641+ getScreenMapData : Module . cwrap ( 'getScreenMapData' , 'number' , [ 'number' ] ) ,
642+ getStripPixelData : Module . cwrap ( 'getStripPixelData' , 'number' , [ 'number' , 'number' ] ) ,
643+ freeFrameData : Module . cwrap ( 'freeFrameData' , null , [ 'number' ] )
644+ } ;
645+ }
646+
647+ const screenMapSizePtr = Module . _malloc ( 4 ) ;
648+ try {
649+ const screenMapDataPtr = workerState . wasmFunctions . getScreenMapData ( screenMapSizePtr ) ;
650+ if ( ! screenMapDataPtr ) {
651+ return null ;
652+ }
653+ try {
654+ const screenMapSize = Module . getValue ( screenMapSizePtr , 'i32' ) ;
655+ const screenMapJson = Module . UTF8ToString ( screenMapDataPtr , screenMapSize ) ;
656+ return JSON . parse ( screenMapJson ) ;
657+ } finally {
658+ workerState . wasmFunctions . freeFrameData ( screenMapDataPtr ) ;
659+ }
660+ } catch ( error ) {
661+ workerLog ( 'ERROR' , 'BACKGROUND_WORKER' , 'Failed to read screenmap data from WASM' , error ) ;
662+ return null ;
663+ } finally {
664+ Module . _free ( screenMapSizePtr ) ;
665+ }
666+ }
667+
668+ /**
669+ * Caches a screenmap dictionary and pushes it to the graphics manager.
670+ * @param {Object } screenMapData - Dictionary stripId -> screenmap
671+ * @param {string } reason - Why the update happened (logging only)
672+ */
673+ function applyScreenMaps ( screenMapData , reason ) {
674+ workerState . screenMaps = screenMapData ;
675+ workerState . screenMapsDirty = true ;
676+ if ( workerState . graphicsManager && workerState . graphicsManager . updateScreenMap ) {
677+ workerState . graphicsManager . updateScreenMap ( screenMapData ) ;
678+ workerLog ( 'LOG' , 'BACKGROUND_WORKER' , 'ScreenMaps sent to graphics manager' , {
679+ reason,
680+ screenMapCount : Object . keys ( screenMapData || { } ) . length
681+ } ) ;
682+ } else {
683+ workerLog ( 'WARN' , 'BACKGROUND_WORKER' , 'Graphics manager not ready, screenMaps cached for initialization' , { reason } ) ;
684+ }
685+ }
686+
611687/**
612688 * Handles animation start request
613689 * @param {Object } _payload - Start parameters (unused)
@@ -639,47 +715,16 @@ async function handleStart(_payload) {
639715 workerState . externFunctions . externSetup ( ) ;
640716 workerLog ( 'LOG' , 'BACKGROUND_WORKER' , 'FastLED setup completed' ) ;
641717
642- // Poll for screenmap data after setup (C++ setup() has registered screenmaps)
643- // EM_JS push mechanism has linking issues, so we use polling instead
718+ // Read the screenmaps registered by C++ setup(). Layouts FastLED creates
719+ // lazily on the first exported frame are picked up later by
720+ // refreshScreenMapsIfIncomplete() (#250).
644721 try {
645- const Module = workerState . fastledModule ;
646-
647- // Bind getScreenMapData if not already bound
648- if ( ! workerState . wasmFunctions ) {
649- workerState . wasmFunctions = {
650- getFrameData : Module . cwrap ( 'getFrameData' , 'number' , [ 'number' ] ) ,
651- getScreenMapData : Module . cwrap ( 'getScreenMapData' , 'number' , [ 'number' ] ) ,
652- getStripPixelData : Module . cwrap ( 'getStripPixelData' , 'number' , [ 'number' , 'number' ] ) ,
653- freeFrameData : Module . cwrap ( 'freeFrameData' , null , [ 'number' ] )
654- } ;
655- }
656-
657- // Fetch screenmap data from C++
658- const screenMapSizePtr = Module . _malloc ( 4 ) ;
659- const screenMapDataPtr = workerState . wasmFunctions . getScreenMapData ( screenMapSizePtr ) ;
660-
661- if ( screenMapDataPtr !== 0 ) {
662- const screenMapSize = Module . getValue ( screenMapSizePtr , 'i32' ) ;
663- const screenMapJson = Module . UTF8ToString ( screenMapDataPtr , screenMapSize ) ;
664- const screenMapData = JSON . parse ( screenMapJson ) ;
665-
666- // Update worker state and notify graphics manager
667- workerState . screenMaps = screenMapData ;
668- workerState . screenMapsDirty = true ;
669- if ( workerState . graphicsManager && workerState . graphicsManager . updateScreenMap ) {
670- workerState . graphicsManager . updateScreenMap ( screenMapData ) ;
671- workerLog ( 'LOG' , 'BACKGROUND_WORKER' , 'ScreenMaps fetched and sent to graphics manager' , {
672- screenMapCount : Object . keys ( screenMapData ) . length
673- } ) ;
674- }
675-
676- // Free the allocated memory
677- workerState . wasmFunctions . freeFrameData ( screenMapDataPtr ) ;
722+ const screenMapData = fetchScreenMapsFromWasm ( ) ;
723+ if ( screenMapData ) {
724+ applyScreenMaps ( screenMapData , 'post-setup' ) ;
678725 } else {
679726 workerLog ( 'WARN' , 'BACKGROUND_WORKER' , 'No screenmap data available after setup' ) ;
680727 }
681-
682- Module . _free ( screenMapSizePtr ) ;
683728 } catch ( error ) {
684729 workerLog ( 'ERROR' , 'BACKGROUND_WORKER' , 'Failed to fetch screenmap data' , error ) ;
685730 // Non-fatal - continue with animation
@@ -977,6 +1022,89 @@ function handleScreenMapUpdate(payload) {
9771022 }
9781023}
9791024
1025+ /**
1026+ * Picks up layouts that FastLED creates after setup() (#250).
1027+ *
1028+ * FastLED fills in default screenmaps lazily when a frame is exported
1029+ * (jsFillInMissingScreenMaps in FastLED's src/platforms/wasm/js_bindings.cpp.hpp),
1030+ * which is after handleStart()'s single post-setup read. Without this, a sketch
1031+ * that never calls setScreenMap() (stock Blink) renders an empty canvas.
1032+ *
1033+ * Only fetches while a strip in the current frame still has no layout, and gives
1034+ * up on a strip after MAX_SCREENMAP_REFRESH_ATTEMPTS fetches so a genuinely
1035+ * layout-less strip cannot cost a JSON parse every frame. The push-based
1036+ * screenmap_update path (handleScreenMapUpdate) is unaffected.
1037+ *
1038+ * NOTE: this worker is mirrored in FastLED at
1039+ * src/platforms/wasm/compiler/modules/core/fastled_background_worker.ts; that
1040+ * copy needs the same change (out of scope for this repo).
1041+ *
1042+ * @param {Array } frameData - Strip data from extractFrameData()
1043+ * @returns {boolean } True when a refresh fetch was performed
1044+ */
1045+ function refreshScreenMapsIfIncomplete ( frameData ) {
1046+ if ( ! Array . isArray ( frameData ) || frameData . length === 0 ) {
1047+ return false ;
1048+ }
1049+
1050+ const missing = findStripsMissingLayout ( frameData , workerState . screenMaps ) ;
1051+ if ( missing . length === 0 ) {
1052+ return false ; // every strip already has a layout - nothing to do
1053+ }
1054+
1055+ const attempts = workerState . screenMapRefreshAttempts ;
1056+ const retryable = missing . filter ( ( stripId ) => ( attempts [ stripId ] || 0 ) < MAX_SCREENMAP_REFRESH_ATTEMPTS ) ;
1057+ if ( retryable . length === 0 ) {
1058+ return false ; // already retried these strips; stop polling
1059+ }
1060+ for ( const stripId of retryable ) {
1061+ attempts [ stripId ] = ( attempts [ stripId ] || 0 ) + 1 ;
1062+ }
1063+
1064+ const screenMapData = fetchScreenMapsFromWasm ( ) ;
1065+ if ( ! screenMapData || typeof screenMapData !== 'object' ) {
1066+ return false ;
1067+ }
1068+
1069+ // Merge rather than replace: layouts pushed earlier through the
1070+ // screenmap_update path must survive a late re-read that happens to return
1071+ // fewer entries (or an empty dictionary).
1072+ const merged = Object . assign ( { } , workerState . screenMaps , screenMapData ) ;
1073+ const stillMissing = findStripsMissingLayout ( frameData , merged ) ;
1074+ const recovered = missing . filter ( ( stripId ) => ! stillMissing . includes ( stripId ) ) ;
1075+ if ( recovered . length === 0 ) {
1076+ // Nothing new arrived; leave the cache (and its dirty flag) untouched so we
1077+ // do not re-push identical layouts to the graphics manager every frame.
1078+ if ( stillMissing . length > 0 ) {
1079+ workerLog ( 'WARN' , 'BACKGROUND_WORKER' , 'Strips still have no layout after screenmap refresh' , {
1080+ strips : stillMissing
1081+ } ) ;
1082+ }
1083+ return true ;
1084+ }
1085+
1086+ applyScreenMaps ( merged , 'late-screenmap-refresh' ) ;
1087+
1088+ if ( ! workerState . screenMapRecoveryReported ) {
1089+ workerState . screenMapRecoveryReported = true ;
1090+ workerLog ( 'LOG' , 'BACKGROUND_WORKER' , 'Late screenMaps recovered after first frame' , {
1091+ strips : recovered ,
1092+ frameNumber : workerState . frameCount
1093+ } ) ;
1094+ // Surface it on the viewer's stdout log so `--test` runs can assert it.
1095+ postMessage ( {
1096+ type : 'stdout' ,
1097+ payload : { text : `[fastled] late screenmap recovered for strips ${ recovered . join ( ',' ) } ` }
1098+ } ) ;
1099+ }
1100+ if ( stillMissing . length > 0 ) {
1101+ workerLog ( 'WARN' , 'BACKGROUND_WORKER' , 'Strips still have no layout after screenmap refresh' , {
1102+ strips : stillMissing
1103+ } ) ;
1104+ }
1105+ return true ;
1106+ }
1107+
9801108/**
9811109 * Handles audio samples from main thread and pushes them to C++ WASM ring buffer.
9821110 * AudioManager runs on the main thread (needs window/document), but Module.ccall()
@@ -1133,6 +1261,11 @@ async function executeFrameLoop(currentTime) {
11331261 const frameData = extractFrameData ( ) ;
11341262
11351263 if ( frameData ) {
1264+ // Layouts FastLED creates lazily on the first exported frame (#250) only
1265+ // become visible after getFrameData(); pick them up before rendering so
1266+ // the dirty screenmaps ride along with this frame.
1267+ refreshScreenMapsIfIncomplete ( frameData ) ;
1268+
11361269 if ( workerState . renderOnMainThread ) {
11371270 // No OffscreenCanvas here: hand the frame to the main thread to draw
11381271 postFrameToMainThread ( frameData ) ;
0 commit comments