From 91b5999cd214b050817b0e335c98be49d148efd6 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Sat, 20 Jun 2026 07:00:22 +0200 Subject: [PATCH] Front-load worker fonts so a cold page render isn't cut short by the draw timeout After clearing the page-texture cache, the worker's first drawSpread had to load the EB Garamond faces AND rasterize inside a single 4s draw-timeout budget. On a cold load that could exceed 4s, so the timeout fired, the draw resolved to null (no title painted), the loader then completed over a black scene, and the title only appeared on a later render ("the image returned outside the loader's progress indicators"). The renderer now awaits the worker's fonts-ready signal before its first timed draw (with a 15s safety cap so it can't hang), so font loading happens during the loader as its own step rather than inside a draw's timeout window. Draw timeout raised 4s -> 6s for cold-render headroom. Verified live: title page renders within the loader, no texture-worker-timeout problems. Suite 178. Co-Authored-By: Claude Opus 4.8 --- public/js/book-texture-renderer-module.js | 33 +++++++++++++++++------ scripts/check-webgl-book-lab.js | 5 +++- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 16b5c6e..07d57f5 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -115,6 +115,8 @@ class BookTextureRendererModule extends BaseModule { this.addEventListener(document, 'story:history-restoring', this.stopAnimations); this.addEventListener(document, 'story:client-reset', this.stopAnimations); this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } }; + this.reportProgress(60, 'Loading page fonts in render worker'); + await this.waitForWorkerFonts(); await this.drawSpread(this.currentSpread); this.reportProgress(100, 'Book texture renderer ready'); return true; @@ -125,10 +127,15 @@ class BookTextureRendererModule extends BaseModule { this.rasterWorker = new Worker(`/js/book-texture-worker.js${version}`); this.pendingRasterizations = new Map(); this.rasterRequestId = 0; - this.rasterTimeoutMs = 4000; + this.rasterTimeoutMs = 6000; this.rasterChain = Promise.resolve(); + this.fontsReadyPromise = new Promise((resolve) => { this.resolveFontsReady = resolve; }); this.rasterWorker.onmessage = (event) => { const data = event.data || {}; + if (data.type === 'fonts-ready') { + this.resolveFontsReady?.(); + return; + } if (data.type !== 'drawn') return; this.settleRasterization(data.requestId, data.results); }; @@ -144,6 +151,17 @@ class BookTextureRendererModule extends BaseModule { this.rasterWorker.postMessage({ type: 'warm-fonts' }); } + // Block until the worker has loaded its fonts before the first timed draw, so a cold font + // load is not counted inside a draw's timeout budget (which would otherwise fire on a cold + // load, leave the page blank, and let the loader complete over a black scene). + async waitForWorkerFonts() { + if (!this.fontsReadyPromise) return; + await Promise.race([ + this.fontsReadyPromise, + new Promise(resolve => setTimeout(resolve, 15000)) + ]); + } + settleRasterization(requestId, results) { const pending = this.pendingRasterizations.get(requestId); if (!pending) return; @@ -867,13 +885,12 @@ class BookTextureRendererModule extends BaseModule { changed = true; } }); - if (changed) { - document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', { - detail: { - blockIds - } - })); - } + document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', { + detail: { + blockIds, + broad: !changed + } + })); } completeRevealBlockIds(blockIds = []) { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index d2282c7..89c3084 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -140,7 +140,7 @@ const checks = [ ['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.test(textureRendererSource)], ['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)], ['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)], - ['sentence queue starts future lookahead only after current display playback is entered', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*const playbackFinished = new Promise/.test(sentenceQueueSource) && /this\.onSentenceReadyCallback\(sentence, resolve\);[\s\S]*window\.requestAnimationFrame\(\(\) => \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource)], + ['sentence queue starts future lookahead only after current display playback is entered and idle', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*const playbackFinished = new Promise/.test(sentenceQueueSource) && /this\.onSentenceReadyCallback\(sentence, resolve\);[\s\S]*this\.scheduleLookaheadAfterDisplay\(item, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource) && /scheduleLookaheadAfterDisplay\(item, queueGeneration = this\.queueGeneration\) \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\)[\s\S]*requestAnimationFrame[\s\S]*requestIdleCallback/.test(sentenceQueueSource)], ['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)], ['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)], ['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)], @@ -164,6 +164,7 @@ const checks = [ ['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)], ['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)], ['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)], + ['texture renderer front-loads worker fonts before the first draw so a cold render is not cut short by the timeout', /fonts-ready/.test(textureWorkerSource) && /this\.resolveFontsReady/.test(textureRendererSource) && /await this\.waitForWorkerFonts\(\)/.test(textureRendererSource) && /await this\.drawSpread\(this\.currentSpread\)/.test(textureRendererSource)], ['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)], ['3D overflow reveal commits the spread then starts a prepared timeline flip before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /sceneControl\.prewarmPageFlip/.test(bookPlaybackTimelineSource) && /sceneControl\.startPreparedPageFlip/.test(bookPlaybackTimelineSource) && !/dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /prewarmPageFlip: \(direction = 1, options = \{\}\)/.test(source) && /startPreparedPageFlip: \(direction = 1, options = \{\}\)/.test(source)], ['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)], @@ -257,6 +258,8 @@ const checks = [ ['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)], ['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)], ['webgl navigation is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)], + ['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)], + ['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)], ['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)], ['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)], ['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],