From 623b42caf9dcd505760e666516cb34f76ec2b951 Mon Sep 17 00:00:00 2001 From: Georg Tomitsch Date: Wed, 10 Jun 2026 10:04:06 +0200 Subject: [PATCH] Fix WebGL timeline startup ordering --- public/js/book-playback-timeline-module.js | 51 +++++++++++++++++++--- public/js/sentence-queue-module.js | 2 +- public/js/text-processor-module.js | 31 +++++++++++++ scripts/check-webgl-book-lab.js | 1 + 4 files changed, 79 insertions(+), 6 deletions(-) diff --git a/public/js/book-playback-timeline-module.js b/public/js/book-playback-timeline-module.js index dacaab0..b5595e0 100644 --- a/public/js/book-playback-timeline-module.js +++ b/public/js/book-playback-timeline-module.js @@ -8,12 +8,11 @@ import { BaseModule } from './base-module.js'; class BookPlaybackTimelineModule extends BaseModule { constructor() { super('book-playback-timeline', 'Book Playback Timeline'); - this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator', 'sentence-queue']; + this.dependencies = ['book-pagination', 'book-texture-renderer', 'webgl-page-cache', 'playback-coordinator']; this.pagination = null; this.textureRenderer = null; this.pageCache = null; this.playbackCoordinator = null; - this.sentenceQueue = null; this.activeSegment = null; this.preparedSegments = new Map(); this.timelineDiagnostics = []; @@ -26,6 +25,7 @@ class BookPlaybackTimelineModule extends BaseModule { 'prepareSentence', 'activatePreparedSegment', 'ensureAnimationTimings', + 'calculateAnimationTiming', 'createPreparedSegment', 'createRevealDetail', 'applyTexturePlan', @@ -55,7 +55,6 @@ class BookPlaybackTimelineModule extends BaseModule { this.textureRenderer = this.getModule('book-texture-renderer'); this.pageCache = this.getModule('webgl-page-cache'); this.playbackCoordinator = this.getModule('playback-coordinator'); - this.sentenceQueue = this.getModule('sentence-queue'); this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => { this.markBenchmark('reveal-start', { blockId: event.detail?.blockId ?? null @@ -210,8 +209,50 @@ class BookPlaybackTimelineModule extends BaseModule { ensureAnimationTimings(sentence = {}) { if (Array.isArray(sentence.animation?.wordTimings) && sentence.animation.wordTimings.length > 0) return; const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || []; - sentence.animation = this.sentenceQueue?.calculateAnimationTiming?.(words, sentence.tts?.duration || 0, sentence.cueMarkers || []) - || { wordTimings: [], cueTimings: [], totalDuration: 0 }; + sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []); + } + + calculateAnimationTiming(words = [], totalDuration = 0, cueMarkers = []) { + if (!Array.isArray(words) || words.length === 0) { + return { + wordTimings: [], + cueTimings: [], + totalDuration: 0 + }; + } + const totalChars = words.reduce((sum, word) => sum + String(word || '').length, 0); + if (totalChars === 0) { + return { + wordTimings: words.map(word => ({ word, delay: 0, duration: 0 })), + cueTimings: [], + totalDuration: 0 + }; + } + const msPerChar = Number(totalDuration || 0) / totalChars; + let currentDelay = 0; + const wordTimings = words.map(word => { + const duration = String(word || '').length * msPerChar; + const timing = { + word, + delay: currentDelay, + duration + }; + currentDelay += duration; + return timing; + }); + const cueTimings = (cueMarkers || []).map(cue => { + const wordIndex = Math.max(0, Math.min(cue.wordIndex || 0, wordTimings.length - 1)); + const timing = wordTimings[wordIndex] || { delay: currentDelay }; + return { + ...cue, + delay: timing.delay + }; + }); + return { + wordTimings, + cueTimings, + totalDuration: Math.round(currentDelay) + }; } createRevealDetail(sentence = {}, spread = null, phase = 'activate') { diff --git a/public/js/sentence-queue-module.js b/public/js/sentence-queue-module.js index 1f614ef..be18ddc 100644 --- a/public/js/sentence-queue-module.js +++ b/public/js/sentence-queue-module.js @@ -13,7 +13,7 @@ class SentenceQueueModule extends BaseModule { super('sentence-queue', 'Sentence Queue'); // Dependencies - this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager']; + this.dependencies = ['text-buffer', 'tts-factory', 'paragraph-layout', 'audio-manager', 'persistence-manager', 'book-playback-timeline']; // Queue state this.sentenceQueue = []; diff --git a/public/js/text-processor-module.js b/public/js/text-processor-module.js index e717df9..3f23a8c 100644 --- a/public/js/text-processor-module.js +++ b/public/js/text-processor-module.js @@ -24,6 +24,7 @@ class TextProcessorModule extends BaseModule { 'hyphenate', 'setLocale', 'loadHyphenopolyLoader', + 'ensureHyphenopolySeedElements', 'normalizeHyphenationLocale', 'applyLocaleTypography', 'getTypographyLocale', @@ -162,6 +163,7 @@ class TextProcessorModule extends BaseModule { this.hyphenatorReady = false; await this.loadHyphenopolyLoader(); + this.ensureHyphenopolySeedElements(locale); window.Hyphenopoly.config({ require: { @@ -203,6 +205,35 @@ class TextProcessorModule extends BaseModule { } } + ensureHyphenopolySeedElements(locale = 'en-us') { + const normalizedLocale = this.normalizeHyphenationLocale(locale); + let container = document.getElementById('hyphenopoly_seed_elements'); + if (!container) { + container = document.createElement('div'); + container.id = 'hyphenopoly_seed_elements'; + container.setAttribute('aria-hidden', 'true'); + Object.assign(container.style, { + position: 'absolute', + width: '1px', + height: '1px', + overflow: 'hidden', + opacity: '0', + pointerEvents: 'none', + left: '-9999px', + top: '-9999px' + }); + document.body.appendChild(container); + } + container.innerHTML = ''; + ['hyphenate', 'hyphenatePipe'].forEach((className) => { + const seed = document.createElement('span'); + seed.className = className; + seed.lang = normalizedLocale; + seed.textContent = normalizedLocale.startsWith('de') ? 'Silbentrennung' : 'hyphenation'; + container.appendChild(seed); + }); + } + loadHyphenopolyLoader() { return new Promise((resolve, reject) => { if (window.Hyphenopoly && typeof window.Hyphenopoly.config === 'function') { diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index fe13b3d..89e4c94 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -213,6 +213,7 @@ const checks = [ ['webgl visible spread state ignores future prepared publishes before flip', /spreadUpdate:deferred-future-unrendered/.test(source) && /incomingSpreadIndex > Math\.max\(0, Number\(bookPaginationState\.spreadIndex/.test(source) && /this\.drawSpread\(this\.currentSpread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)], ['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(revealDetail, \{[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)], ['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)], + ['book playback timeline initializes before sentence queue without a dependency cycle', /this\.dependencies = \[[^\]]*'book-playback-timeline'[^\]]*\]/.test(sentenceQueueSource) && !/this\.dependencies = \[[^\]]*'sentence-queue'[^\]]*\]/.test(bookPlaybackTimelineSource) && /calculateAnimationTiming\(words = \[\]/.test(bookPlaybackTimelineSource)], ['3D display playback is owned by book playback timeline', /book-playback-timeline/.test(uiDisplayHandlerSource) && /playWebGLBookSentence/.test(uiDisplayHandlerSource) && /timeline\.playSentence\(sentence\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)], ['sentence queue lookahead prepares 3D book timeline segments', /book-playback-timeline/.test(sentenceQueueSource) && /bookPlaybackTimeline\.prepareSentence\(sentence/.test(sentenceQueueSource) && /timelineSegment: segment/.test(sentenceQueueSource)], ['book playback timeline prewarms texture window before prepared playback and flips', /prewarmSegmentTextures/.test(bookPlaybackTimelineSource) && /pageCache\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /await this\.pageCache\?\.prewarmNavigationWindow/.test(bookPlaybackTimelineSource)],