/** * Book Playback Timeline Module * Owns prepared WebGL book playback order: pagination, texture readiness, * reveal start, page-flip timing, and visual completion. */ 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.pagination = null; this.textureRenderer = null; this.pageCache = null; this.playbackCoordinator = null; this.sentenceQueue = null; this.activeSegment = null; this.preparedSegments = new Map(); this.timelineDiagnostics = []; this.ownsPageFlipCommit = true; this.bindMethods([ 'initialize', 'playSentence', 'prepareSentence', 'activatePreparedSegment', 'ensureAnimationTimings', 'createPreparedSegment', 'createRevealDetail', 'requiresSpreadTransition', 'requiresRightPageFlipAfterReveal', 'waitForVisualCompletion', 'waitForRevealCommit', 'requestPageFlip', 'waitForPageFlipFinished', 'prewarmSegmentTextures', 'getPageMetaForIndex', 'getVisibleSpreadIndex', 'isChoiceAwaitingPlayer', '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.sentenceQueue = this.getModule('sentence-queue'); window.BookPlaybackTimeline = this; this.reportProgress(100, 'Book playback timeline ready'); return true; } async playSentence(sentence = {}) { const segment = await this.prepareSentence(sentence, { immediate: true }); if (!segment) { return this.playbackCoordinator?.play?.(sentence); } this.activeSegment = segment; this.recordDiagnostic('segment-play:start', segment); if (this.requiresSpreadTransition(segment)) { const flipped = await this.requestPageFlip(1, { reason: 'timeline-preplay-spread-transition', targetSpread: segment.targetSpreadIndex, force: true }); if (!flipped) { this.pageCache?.recordProblem?.({ type: 'timeline-preplay-flip-failed', blockId: segment.blockId, targetSpread: segment.targetSpreadIndex }); } } await this.activatePreparedSegment(segment, sentence); const visualPromise = this.waitForVisualCompletion(segment); const playbackPromise = this.playbackCoordinator?.play?.(sentence) || Promise.resolve(); await Promise.all([playbackPromise, visualPromise]); this.recordDiagnostic('segment-play:end', segment); if (this.activeSegment?.key === segment.key) this.activeSegment = null; 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 existing = sentence.webglBookPresentation?.timelineSegment || this.preparedSegments.get(key); if (existing && options.force !== true) return existing; this.ensureAnimationTimings(sentence); const segment = await this.createPreparedSegment(sentence, options); if (!segment) return null; this.preparedSegments.set(segment.key, segment); sentence.webglBookPresentation = { ...(sentence.webglBookPresentation || {}), prepared: true, blockId: segment.blockId, spread: segment.previewSpread, timelineSegment: segment }; this.recordDiagnostic('segment-prepare:end', segment); return segment; } async createPreparedSegment(sentence = {}, options = {}) { const previewSpread = sentence.webglBookPresentation?.spread || await this.pagination.preparePendingBlock(sentence, { activate: false, publish: false, includeUnrenderedHistory: true }); if (!previewSpread) return null; const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare'); this.textureRenderer.prepareRevealBlock(revealDetail, { phase: 'prepare' }); const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0)); const currentSpreadIndex = this.getVisibleSpreadIndex(); const segment = { key: `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${sentence.blockId}`, id: sentence.id, blockId: sentence.blockId, sentence, previewSpread, targetSpreadIndex, currentSpreadIndex, requiresPreFlip: targetSpreadIndex > currentSpreadIndex, requiresRightFlip: this.requiresRightPageFlipAfterReveal(previewSpread), preparedAt: performance.now(), status: 'prepared' }; await this.prewarmSegmentTextures(segment); if (options.immediate !== true) { await new Promise(resolve => setTimeout(resolve, 0)); } return segment; } async activatePreparedSegment(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.requiresRightFlip = this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread); const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate'); this.textureRenderer.prepareRevealBlock(revealDetail); segment.status = 'activated'; this.recordDiagnostic('segment-activate:end', segment); return segment.activeSpread; } 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 }; } 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 }; } 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)); } async waitForVisualCompletion(segment = {}) { if (!segment.requiresRightFlip) return; const committed = await this.waitForRevealCommit(segment); if (!committed || this.isChoiceAwaitingPlayer()) return; await this.requestPageFlip(1, { reason: 'timeline-right-page-filled', targetSpread: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1), force: true }); } waitForRevealCommit(segment = {}) { const blockId = String(segment.blockId ?? ''); if (!blockId) return Promise.resolve(false); return new Promise(resolve => { let resolved = false; const finish = (value) => { if (resolved) return; resolved = true; clearTimeout(timeoutId); document.removeEventListener('webgl-book:reveal-committed', onCommitted); resolve(value); }; const onCommitted = (event) => { const detail = event.detail || {}; if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return; const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : []; if (!ids.includes(blockId)) return; finish(true); }; const timeoutId = setTimeout(() => { this.pageCache?.recordProblem?.({ type: 'timeline-reveal-commit-timeout', blockId: segment.blockId, targetSpread: segment.targetSpreadIndex }); finish(false); }, Math.max(2000, Number(segment.sentence?.animation?.totalDuration || 0) + 3000)); document.addEventListener('webgl-book:reveal-committed', onCommitted); }); } async requestPageFlip(direction = 1, options = {}) { if (this.isChoiceAwaitingPlayer()) return false; await this.pageCache?.prewarmNavigationWindow?.({ currentSpread: this.getVisibleSpreadIndex(), targetSpread: options.targetSpread, endSpread: Math.max(0, Number(this.pagination?.spreads?.length || 1) - 1), getPageMetaForIndex: this.getPageMetaForIndex, recordMiss: true }); 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 } })); return wait; } 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; } 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 source = spread?.pageMeta?.[side] || {}; const metrics = this.textureRenderer?.metrics || {}; 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 labSpread = window.BookLabDebug?.getBookState?.()?.spreadIndex; if (Number.isFinite(Number(labSpread))) return Math.max(0, Math.round(Number(labSpread))); 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, at: Math.round(performance.now()) }); while (this.timelineDiagnostics.length > 200) this.timelineDiagnostics.shift(); document.documentElement.dataset.webglBookTimeline = type; } getRuntimeState() { return { activeBlockId: this.activeSegment?.blockId ?? null, preparedSegmentCount: this.preparedSegments.size, ownsPageFlipCommit: this.ownsPageFlipCommit, diagnostics: this.timelineDiagnostics.slice(-20) }; } } const bookPlaybackTimeline = new BookPlaybackTimelineModule(); export { bookPlaybackTimeline as BookPlaybackTimeline }; if (window.moduleRegistry) { window.moduleRegistry.register(bookPlaybackTimeline); } window.BookPlaybackTimeline = bookPlaybackTimeline;