/** * Book Playback Timeline Module * * The single owner of WebGL book playback. It sequences the full content * lifecycle for story text: * * prepare (pagination + textures + prewarm) * -> commit (resolve the authoritative target spread) * -> flip (animate a page turn when a spread boundary is crossed) * -> activate (upload the visible textures for the target spread) * -> reveal (animate the new block's text in) * * It drives the scene exclusively through the formal `webgl-book:*` events and * the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug` * (debug-only) and never throws out of the live playback path: a transient cache * miss is surfaced as a problem state and playback degrades gracefully. */ 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', 'webgl-book-scene']; this.pagination = null; this.textureRenderer = null; this.pageCache = null; this.playbackCoordinator = null; this.scene = null; this.activeSegment = null; this.preparedSegments = new Map(); this.maxPreparedSegments = 48; this.paginationGeneration = 0; this.visibleSpreadIndex = 0; this.timelineDiagnostics = []; this.benchmarkEntries = []; this.bindMethods([ 'initialize', 'playSentence', 'prepareSentence', 'commitSegmentSpread', 'activatePreparedSegment', 'ensureAnimationTimings', 'calculateAnimationTiming', 'createPreparedSegment', 'createRevealDetail', 'applyTexturePlan', 'startRevealForSegment', 'assertSegmentReady', 'collectRequiredPageMetas', 'collectTexturePlanPageMetas', 'requiresSpreadTransition', 'requiresRightPageFlipAfterReveal', 'getBlockRevealSides', 'waitForVisualCompletion', 'revealContinuationSpread', 'waitForPlannedRightReveal', 'requestPageFlip', 'prepareFlipPlan', 'waitForPageFlipFinished', 'prewarmSegmentTextures', 'getPageMetaForIndex', 'getVisibleSpreadIndex', 'isChoiceAwaitingPlayer', 'invalidatePreparedSegments', 'rememberPreparedSegment', 'markBenchmark', 'timeStage', 'recordDiagnostic', 'getRuntimeState' ]); } async initialize() { this.pagination = this.getModule('book-pagination'); this.textureRenderer = this.getModule('book-texture-renderer'); this.pageCache = this.getModule('webgl-page-cache'); this.playbackCoordinator = this.getModule('playback-coordinator'); this.scene = this.getModule('webgl-book-scene'); this.visibleSpreadIndex = Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0))); this.addEventListener(document, 'webgl-book:page-reveal-start', (event) => { this.markBenchmark('reveal-start', { blockId: event.detail?.blockId ?? null }); }); this.addEventListener(document, 'webgl-book:reveal-committed', (event) => { this.markBenchmark('reveal-committed', { blockId: event.detail?.blockIds?.[0] ?? null, side: event.detail?.side || null, pageFlipAfterReveal: event.detail?.pageFlipAfterReveal === true }); }); this.addEventListener(document, 'webgl-book:page-flip-started', (event) => { this.markBenchmark('flip-started', event.detail || {}); }); this.addEventListener(document, 'webgl-book:page-flip-finished', (event) => { const targetSpread = Number(event.detail?.targetSpread); if (Number.isFinite(targetSpread)) this.visibleSpreadIndex = Math.max(0, Math.round(targetSpread)); this.markBenchmark('flip-finished', event.detail || {}); }); this.addEventListener(document, 'webgl-book:page-count-changed', this.invalidatePreparedSegments); this.addEventListener(document, 'story:history-restoring', this.invalidatePreparedSegments); this.addEventListener(document, 'story:client-reset', () => { this.invalidatePreparedSegments(); this.activeSegment = null; }); window.BookPlaybackTimeline = this; this.reportProgress(100, 'Book playback timeline ready'); return true; } async playSentence(sentence = {}) { const segment = await this.timeStage('prepare-current', { blockId: sentence.blockId ?? null }, () => { return this.prepareSentence(sentence, { immediate: true }); }); if (!segment) { return this.playbackCoordinator?.play?.(sentence); } this.activeSegment = segment; document.documentElement.dataset.webglBookPlaybackActive = 'true'; this.recordDiagnostic('segment-play:start', segment); try { // Commit pagination first so the flip targets the authoritative spread, // not the predicted preview spread. await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence)); if (this.requiresSpreadTransition(segment)) { const flipped = await this.timeStage('preplay-flip', segment, () => this.requestPageFlip(1, { reason: 'timeline-preplay-spread-transition', targetSpread: segment.targetSpreadIndex, // The block reveals on these sides right after the flip; the scene must // not flash their full (unmasked) content during the flip's near-end // texture swap — activate will land the masked reveal instead. revealSides: segment.revealSides, force: true })); if (!flipped) { this.pageCache?.recordProblem?.({ type: 'timeline-preplay-flip-failed', blockId: segment.blockId, targetSpread: segment.targetSpreadIndex }); } } await this.timeStage('activate', segment, () => this.activatePreparedSegment(segment, sentence)); sentence.webglRevealController = () => this.startRevealForSegment(segment); const playbackPromise = this.timeStage('playback', segment, () => { return this.playbackCoordinator?.play?.(sentence) || Promise.resolve(); }); const visualPromise = this.waitForVisualCompletion(segment); await Promise.all([playbackPromise, visualPromise]); } finally { this.recordDiagnostic('segment-play:end', segment); if (this.activeSegment?.key === segment.key) this.activeSegment = null; delete document.documentElement.dataset.webglBookPlaybackActive; } return segment; } async prepareSentence(sentence = {}, options = {}) { if (!sentence || sentence.blockId == null || !this.pagination || !this.textureRenderer) return null; const key = `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`; const cached = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key); const reusable = cached && cached.generation === this.paginationGeneration; if (reusable && options.force !== true) return cached; this.ensureAnimationTimings(sentence); const segment = await this.timeStage(options.immediate === true ? 'segment-prepare-immediate' : 'segment-prepare-lookahead', { blockId: sentence.blockId, id: sentence.id }, () => this.createPreparedSegment(sentence, options)); if (!segment) return null; this.rememberPreparedSegment(segment); sentence.webglBookPresentation = { ...(sentence.webglBookPresentation || {}), prepared: true, blockId: segment.blockId, spread: segment.previewSpread, timelineSegment: segment }; this.recordDiagnostic('segment-prepare:end', segment); return segment; } rememberPreparedSegment(segment = {}) { if (!segment?.key) return; this.preparedSegments.delete(segment.key); this.preparedSegments.set(segment.key, segment); while (this.preparedSegments.size > this.maxPreparedSegments) { const oldestKey = this.preparedSegments.keys().next().value; this.preparedSegments.delete(oldestKey); } } invalidatePreparedSegments() { this.paginationGeneration += 1; this.preparedSegments.clear(); } async createPreparedSegment(sentence = {}, options = {}) { const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, { activate: false, publish: false, includeUnrenderedHistory: true }); if (!previewSpread) return null; // Every block is prepared once, spanning-aware. The preview layout (attached to the // preview spread by pagination) tells us whether the block overflows onto the next // spread; if so we derive the start spread's timing across both spreads and prepare the // continuation spread now. activate and revealContinuationSpread then reuse these — one // prepare path, no synchronous rebuild or redraw on the critical path. const previewSpreads = Array.isArray(previewSpread.previewSpreads) ? previewSpread.previewSpreads : null; const startIndex = Math.max(0, Number(previewSpread.index || 0)); const continuationSpread = previewSpreads ? (previewSpreads .filter(spread => spread && Number(spread.index) > startIndex && this.getBlockRevealSides(spread, sentence.blockId).length > 0) .sort((a, b) => Number(a.index) - Number(b.index))[0] || null) : null; const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare'); const texturePlan = await this.textureRenderer.prepareRevealBlock( continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail, { phase: 'prepare', publishEvent: false } ); if (continuationSpread) { await this.textureRenderer.prepareContinuationRevealPlan({ ...revealDetail, previewSpreads, continuationSpread }); } const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0)); const currentSpreadIndex = this.getVisibleSpreadIndex(); const revealSides = this.getBlockRevealSides(previewSpread, sentence.blockId); const segment = { key: `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`, id: sentence.id, blockId: sentence.blockId, sentence, generation: this.paginationGeneration, previewSpread, targetSpreadIndex, currentSpreadIndex, revealSides, requiresPreFlip: targetSpreadIndex > currentSpreadIndex, requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread), // Snapshot the reveal timings now. A reused lookahead segment can be played by // a sentence instance whose animation timings were lost; without them the // reveal can't be word-paced and stretches across the whole TTS. preparedAnimation: { wordTimings: Array.isArray(revealDetail.wordTimings) ? revealDetail.wordTimings : [], cueTimings: Array.isArray(revealDetail.cueTimings) ? revealDetail.cueTimings : [], totalDuration: Number(revealDetail.totalDuration || 0) }, preparedTexturePlan: texturePlan, preparedAt: performance.now(), revealStartedAt: null, revealStartedPromise: null, resolveRevealStarted: null, status: 'prepared' }; segment.revealStartedPromise = new Promise(resolve => { segment.resolveRevealStarted = resolve; }); this.applyTexturePlan(texturePlan, segment, 'prepare'); await this.timeStage('texture-prewarm', segment, () => this.prewarmSegmentTextures(segment)); await this.assertSegmentReady(segment, 'prepare'); if (options.immediate !== true) { await new Promise(resolve => setTimeout(resolve, 0)); } return segment; } async commitSegmentSpread(segment = {}, sentence = segment.sentence) { if (!segment || !sentence) return null; const activeSpread = await this.pagination.preparePendingBlock(sentence, { includeUnrenderedHistory: true }); segment.activeSpread = activeSpread || segment.previewSpread; segment.targetSpreadIndex = Math.max(0, Number(segment.activeSpread?.index ?? segment.targetSpreadIndex ?? 0)); segment.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId); // Does the block overflow onto the next spread? The committed pagination now knows // this (during lookahead it was not yet committed), so detect it here. const nextSpread = typeof this.pagination?.getSpread === 'function' ? this.pagination.getSpread(segment.targetSpreadIndex + 1) : this.pagination?.spreads?.[segment.targetSpreadIndex + 1]; segment.spansToNextSpread = Boolean(nextSpread) && this.getBlockRevealSides(nextSpread, sentence.blockId).length > 0; // A spanning block, or one that fills the right page, must flip to keep revealing // its continuation rather than leaving the right page's last line to absorb the // whole TTS while the rest pops in complete after the flip. segment.requiresRightFlip = segment.revealSides.includes('right') && (segment.spansToNextSpread || this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread)); this.recordDiagnostic('segment-commit:end', segment); return segment.activeSpread; } async activatePreparedSegment(segment = {}, sentence = segment.sentence) { if (!segment || !sentence) return null; // Restore the reveal timings captured at prepare if the live sentence lost them, // otherwise the reveal degrades to an area estimate spanning the whole TTS. if (segment.preparedAnimation?.wordTimings?.length && !(sentence.animation?.wordTimings?.length)) { sentence.animation = { ...(sentence.animation || {}), wordTimings: segment.preparedAnimation.wordTimings, cueTimings: segment.preparedAnimation.cueTimings, totalDuration: segment.preparedAnimation.totalDuration }; } const spread = segment.activeSpread || segment.previewSpread; const revealDetail = this.createRevealDetail(sentence, spread, 'activate'); // Reuse the spanning-aware plan prepared during lookahead — its timing already spans // both pages. No synchronous redraw on the critical path. const texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false }); segment.activeTexturePlan = texturePlan; this.applyTexturePlan(texturePlan, segment, 'activate'); await this.assertSegmentReady(segment, 'activate'); segment.status = 'activated'; this.recordDiagnostic('segment-activate:end', segment); return spread; } ensureAnimationTimings(sentence = {}) { const existingTimings = Array.isArray(sentence.animation?.wordTimings) ? sentence.animation.wordTimings : []; const existingDuration = existingTimings.reduce((max, timing) => Math.max( max, Number(timing?.delay || 0) + Number(timing?.duration || 0) ), Number(sentence.animation?.totalDuration || 0)); const ttsDuration = Number(sentence.tts?.duration || 0); if (existingTimings.length > 0 && (existingDuration > 0 || ttsDuration <= 0)) return; const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || []; sentence.animation = this.calculateAnimationTiming(words, ttsDuration, 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') { return { id: sentence.id, blockId: sentence.blockId, wordTimings: sentence.animation?.wordTimings || [], cueTimings: sentence.animation?.cueTimings || [], totalDuration: sentence.animation?.totalDuration || 0, spread, phase }; } applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') { if (!texturePlan) { this.pageCache?.recordProblem?.({ type: 'timeline-missing-texture-plan', blockId: segment.blockId ?? null, phase }); return false; } document.dispatchEvent(new CustomEvent('webgl-book:page-texture-records', { detail: { ...texturePlan, phase: phase === 'prepare' ? 'prepare' : 'activate' } })); this.recordDiagnostic(`texture-plan-applied:${phase}`, segment); return true; } startRevealForSegment(segment = {}) { if (!segment?.blockId) return false; // Mark the renderer animation as started, then let the scene render loop — // the single reveal clock — drive timing via the dispatched reveal-start event. const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, { publishEvent: true }); if (!revealStart) { this.pageCache?.recordProblem?.({ type: 'timeline-prepared-reveal-missing', blockId: segment.blockId }); return false; } segment.revealStartedAt = performance.now(); if (typeof segment.resolveRevealStarted === 'function') { segment.resolveRevealStarted(segment.revealStartedAt); segment.resolveRevealStarted = null; } this.markBenchmark('reveal-start', segment); this.recordDiagnostic('reveal-started', segment); return true; } requiresSpreadTransition(segment = {}) { return Math.max(0, Number(segment.targetSpreadIndex || 0)) > this.getVisibleSpreadIndex(); } requiresRightPageFlipAfterReveal(spread = {}) { const meta = spread?.pageMeta?.right || null; if (!meta || meta.section !== 'body' || meta.kind === 'blank' || meta.kind === 'title') return false; const rightLines = Array.isArray(spread?.right) ? spread.right : []; const maxLine = rightLines.reduce((max, line) => Math.max( max, Number(line?.pageLine || 0) + Math.max(1, Number(line?.lineCount || 1)) ), 0); return maxLine >= Math.max(1, Number(meta.linesPerPage || 25)); } getBlockRevealSides(spread = {}, blockId = null) { const id = String(blockId ?? ''); if (!id) return []; return ['left', 'right'].filter((side) => { const lines = Array.isArray(spread?.[side]) ? spread[side] : []; return lines.some(line => String(line?.blockId ?? '') === id); }); } async waitForVisualCompletion(segment = {}) { if (!segment.requiresRightFlip || !Array.isArray(segment.revealSides) || !segment.revealSides.includes('right')) { this.recordDiagnostic('visual-completion:no-right-flip-wait', segment); return; } const committed = await this.timeStage('wait-right-reveal-commit', segment, () => this.waitForPlannedRightReveal(segment)); if (!committed || this.isChoiceAwaitingPlayer()) return; const continuationSpreadIndex = Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1); const continuationSpread = typeof this.pagination?.getSpread === 'function' ? this.pagination.getSpread(continuationSpreadIndex) : this.pagination?.spreads?.[continuationSpreadIndex]; // If the block continues onto the next spread, that page must keep revealing the // carried-over lines after the flip instead of appearing already complete. const continuationSides = continuationSpread ? this.getBlockRevealSides(continuationSpread, segment.blockId) : []; const flipped = await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, { reason: 'timeline-right-page-filled', targetSpread: continuationSpreadIndex, revealSides: continuationSides, force: true })); if (flipped && continuationSides.length > 0) { await this.timeStage('reveal-continuation', segment, () => this.revealContinuationSpread(segment, continuationSpread)); } } // Re-apply the active block's reveal on the spread it continues onto. The renderer // already produces reveal regions for that spread with global (continuous) timing; // the scene resumes the same reveal clock (the block's original start persists), so // the carried-over lines animate in instead of popping in fully revealed. async revealContinuationSpread(segment = {}, spread = null) { const sentence = segment.sentence; if (!sentence || !spread) return false; // Reuse the continuation plan prepared during lookahead. It is always prepared when a // block spans (createPreparedSegment), so a miss is a real bug, not a redraw cue. const texturePlan = this.textureRenderer.takeContinuationRevealPlan(segment.blockId, spread.index); if (!texturePlan) { this.pageCache?.recordProblem?.({ type: 'timeline-reveal-continuation-missing', blockId: segment.blockId, spreadIndex: Number(spread.index ?? null) }); return false; } segment.activeTexturePlan = texturePlan; this.applyTexturePlan(texturePlan, segment, 'activate'); await this.assertSegmentReady(segment, 'activate'); this.recordDiagnostic('reveal-continuation:applied', segment); return true; } // Resolve when the right page's own portion of the reveal is done — its computed // duration elapses, the reveal commits, or the player fast-forwards — whichever comes // first. Single timer + listeners with full cleanup, so no stray commit-timeout fires. async waitForPlannedRightReveal(segment = {}) { const startedAt = Number(segment.revealStartedAt) || await (segment.revealStartedPromise || Promise.resolve(performance.now())); const duration = this.getRightRevealDurationMs(segment); segment.plannedRightRevealDurationMs = duration; this.recordDiagnostic('wait-right-reveal-planned', { ...segment, plannedRightRevealDurationMs: duration }); const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now())); const remaining = Math.max(0, duration - elapsed); const blockId = String(segment.blockId ?? ''); return new Promise((resolve) => { let done = false; const finish = (value) => { if (done) return; done = true; clearTimeout(timer); document.removeEventListener('webgl-book:reveal-committed', onCommit); document.removeEventListener('webgl-book:page-reveal-fast-forward', onFastForward); resolve(value); }; const onCommit = (event) => { const detail = event.detail || {}; if (detail.side !== 'right') return; const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : []; if (blockId && ids.length && !ids.includes(blockId)) return; finish(true); }; const onFastForward = () => finish(true); const timer = setTimeout(() => finish(true), remaining); document.addEventListener('webgl-book:reveal-committed', onCommit); document.addEventListener('webgl-book:page-reveal-fast-forward', onFastForward); }); } getRightRevealDurationMs(segment = {}) { const duration = Number(segment.activeTexturePlan?.reveal?.right?.durationMs ?? segment.preparedTexturePlan?.reveal?.right?.durationMs ?? 0); if (Number.isFinite(duration) && duration > 0) return duration; return Math.max(1, Number(segment.sentence?.animation?.totalDuration || 1)); } async requestPageFlip(direction = 1, options = {}) { if (this.isChoiceAwaitingPlayer()) return false; // Warm the texture cache for the navigation window and verify the target pages // are resident before asking the scene to flip. The scene performs its own // flip-specific prewarm (drawing the spreads), so we do not pass this through. await this.prepareFlipPlan(direction, options); await this.assertSegmentReady({ blockId: options.blockId ?? null, targetSpreadIndex: options.targetSpread, revealSides: [] }, 'flip'); const wait = this.waitForPageFlipFinished(options.targetSpread); document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', { detail: { direction, force: options.force === true, reason: options.reason || 'timeline', targetSpread: options.targetSpread, revealSides: Array.isArray(options.revealSides) ? options.revealSides : null } })); return wait; } async prepareFlipPlan(direction = 1, options = {}) { const currentSpread = this.getVisibleSpreadIndex(); const targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : Math.max(0, currentSpread + Math.sign(Number(direction || 0))); const prewarm = await this.pageCache?.prewarmNavigationWindow?.({ currentSpread, targetSpread, endSpread: Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1), getPageMetaForIndex: this.getPageMetaForIndex, recordMiss: false }); const sourceSide = direction > 0 ? 'right' : 'left'; const backSide = direction > 0 ? 'left' : 'right'; const sourcePageIndex = currentSpread * 2 + (sourceSide === 'right' ? 1 : 0); const backPageIndex = targetSpread * 2 + (backSide === 'right' ? 1 : 0); const plan = { direction, currentSpread, targetSpread, sourceSide, backSide, sourcePageMeta: this.getPageMetaForIndex(sourcePageIndex), backPageMeta: this.getPageMetaForIndex(backPageIndex), prewarm, createdAt: performance.now() }; this.markBenchmark('flip-plan-ready', plan); this.recordDiagnostic('flip-plan-ready', { ...plan, targetSpreadIndex: targetSpread }); return plan; } async prewarmSegmentTextures(segment = {}) { if (!this.pageCache || typeof this.pageCache.prewarmNavigationWindow !== 'function') return null; const targetSpread = Math.max(0, Number(segment.targetSpreadIndex || 0)); const endSpread = Math.max(targetSpread, Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1)); const result = await this.pageCache.prewarmNavigationWindow({ currentSpread: this.getVisibleSpreadIndex(), targetSpread, endSpread, getPageMetaForIndex: this.getPageMetaForIndex, recordMiss: false }); segment.textureWindowReady = true; segment.textureWindowSpreadCount = result ? Object.keys(result).length : 0; return result; } collectRequiredPageMetas(segment = {}, phase = 'play') { if (phase === 'prepare') { return this.collectTexturePlanPageMetas(segment.preparedTexturePlan); } if (phase === 'activate' || phase === 'play') { return this.collectTexturePlanPageMetas(segment.activeTexturePlan || segment.preparedTexturePlan); } const currentSpread = this.getVisibleSpreadIndex(); const targetSpread = Number.isFinite(Number(segment.targetSpreadIndex)) ? Math.max(0, Math.round(Number(segment.targetSpreadIndex))) : currentSpread; return Array.from(new Set([currentSpread, targetSpread])) .flatMap(spread => [ this.getPageMetaForIndex(spread * 2), this.getPageMetaForIndex(spread * 2 + 1) ]); } collectTexturePlanPageMetas(texturePlan = null) { const pageMeta = texturePlan?.pageMeta || {}; const records = Array.isArray(texturePlan?.records) ? texturePlan.records : []; const metas = records .map(record => record?.pageMeta || pageMeta?.[record?.side]) .filter(meta => meta && Number.isFinite(Number(meta.pageIndex))); ['left', 'right'].forEach((side) => { const meta = pageMeta?.[side]; if (!meta || !Number.isFinite(Number(meta.pageIndex))) return; if (metas.some(existing => Number(existing.pageIndex) === Number(meta.pageIndex))) return; metas.push(meta); }); return metas; } async assertSegmentReady(segment = {}, phase = 'play') { if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') { this.recordDiagnostic(`cache-unavailable:${phase}`, segment); return false; } const metas = this.collectRequiredPageMetas(segment, phase); const missing = []; await Promise.all(metas.map(async (meta) => { const texture = await this.pageCache.ensurePageTexture(meta, { recordMiss: true }); if (!texture) missing.push(meta); })); if (missing.length > 0) { // Surface the problem but do not throw out of the live playback path. this.pageCache.recordProblem?.({ type: 'timeline-cache-readiness-failed', phase, blockId: segment.blockId ?? null, missingPages: missing.map(meta => meta.pageIndex ?? null) }); segment.cacheReady = false; segment.cacheReadyPhase = phase; return false; } segment.cacheReady = true; segment.cacheReadyPhase = phase; this.recordDiagnostic(`cache-ready:${phase}`, segment); return true; } getPageMetaForIndex(pageIndex = 0) { const index = Math.max(0, Math.round(Number(pageIndex || 0))); const spreadIndex = Math.floor(index / 2); const side = index % 2 === 0 ? 'left' : 'right'; const spread = typeof this.pagination?.getSpread === 'function' ? this.pagination.getSpread(spreadIndex) : this.pagination?.spreads?.[spreadIndex]; const metrics = this.textureRenderer?.metrics || {}; if (!spread) { return { pageIndex: index, width: metrics.width, height: metrics.height, kind: 'blank', section: index < 3 ? 'frontmatter' : 'body', pageNumber: null, omitPageNumber: true }; } const source = spread?.pageMeta?.[side] || {}; return { ...source, pageIndex: index, width: metrics.width, height: metrics.height, kind: source.kind || (index < 3 ? 'blank' : 'content'), section: source.section || (index < 3 ? 'frontmatter' : 'body') }; } waitForPageFlipFinished(targetSpread = null) { return new Promise(resolve => { let started = false; let resolved = false; const expectedSpread = Number.isFinite(Number(targetSpread)) ? Math.max(0, Math.round(Number(targetSpread))) : null; const cleanup = () => { document.removeEventListener('webgl-book:page-flip-started', onStarted); document.removeEventListener('webgl-book:page-flip-finished', onFinished); clearTimeout(timeoutId); }; const finish = (value) => { if (resolved) return; resolved = true; cleanup(); resolve(value); }; const matches = (detail = {}) => { if (expectedSpread === null) return true; const spread = Number(detail.targetSpread); return Number.isFinite(spread) && Math.max(0, Math.round(spread)) === expectedSpread; }; const onStarted = (event) => { if (matches(event.detail || {})) started = true; }; const onFinished = (event) => { if (matches(event.detail || {})) finish(true); }; const timeoutId = setTimeout(() => { this.pageCache?.recordProblem?.({ type: 'timeline-page-flip-timeout', targetSpread: expectedSpread, started }); finish(false); }, 2600); document.addEventListener('webgl-book:page-flip-started', onStarted); document.addEventListener('webgl-book:page-flip-finished', onFinished); }); } getVisibleSpreadIndex() { const sceneSpread = this.scene?.getVisibleSpreadIndex?.(); if (Number.isFinite(Number(sceneSpread))) return Math.max(0, Math.round(Number(sceneSpread))); if (Number.isFinite(Number(this.visibleSpreadIndex))) return Math.max(0, Math.round(Number(this.visibleSpreadIndex))); return Math.max(0, Math.round(Number(this.pagination?.currentSpreadIndex || 0))); } isChoiceAwaitingPlayer() { return document.documentElement.dataset.choiceAwaiting === 'true' || document.body?.dataset?.choiceAwaiting === 'true' || Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice')); } recordDiagnostic(type, segment = {}) { this.timelineDiagnostics.push({ type, blockId: segment.blockId ?? null, spreadIndex: segment.targetSpreadIndex ?? null, status: segment.status || null, revealSides: Array.isArray(segment.revealSides) ? segment.revealSides : [], plannedRightRevealDurationMs: Number.isFinite(Number(segment.plannedRightRevealDurationMs)) ? Math.round(Number(segment.plannedRightRevealDurationMs)) : undefined, at: Math.round(performance.now()) }); while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift(); document.documentElement.dataset.webglBookTimeline = type; } markBenchmark(stage, detail = {}, startedAt = null) { const now = performance.now(); const entry = { stage, blockId: detail.blockId ?? null, spreadIndex: detail.targetSpreadIndex ?? detail.spreadIndex ?? detail.targetSpread ?? null, durationMs: Number.isFinite(Number(startedAt)) ? Math.round((now - Number(startedAt)) * 100) / 100 : null, at: Math.round(now), detail: { status: detail.status || null, revealSides: Array.isArray(detail.revealSides) ? detail.revealSides : undefined, reason: detail.reason || null, side: detail.side || null, pageFlipAfterReveal: detail.pageFlipAfterReveal === true } }; this.benchmarkEntries.push(entry); while (this.benchmarkEntries.length > 240) this.benchmarkEntries.shift(); document.documentElement.dataset.webglBookBenchmark = JSON.stringify(this.benchmarkEntries.slice(-40)); return entry; } async timeStage(stage, detail = {}, callback = null) { const startedAt = performance.now(); this.markBenchmark(`${stage}:start`, detail); try { const result = await callback?.(); this.markBenchmark(`${stage}:end`, detail, startedAt); return result; } catch (error) { this.markBenchmark(`${stage}:error`, { ...detail, reason: error?.message || String(error) }, startedAt); throw error; } } getRuntimeState() { return { activeBlockId: this.activeSegment?.blockId ?? null, preparedSegmentCount: this.preparedSegments.size, paginationGeneration: this.paginationGeneration, visibleSpreadIndex: this.visibleSpreadIndex, diagnostics: this.timelineDiagnostics.slice(-20), benchmark: this.benchmarkEntries.slice(-40) }; } } const bookPlaybackTimeline = new BookPlaybackTimelineModule(); export { bookPlaybackTimeline as BookPlaybackTimeline }; if (window.moduleRegistry) { window.moduleRegistry.register(bookPlaybackTimeline); } window.BookPlaybackTimeline = bookPlaybackTimeline;