/** * 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']; this.pagination = null; this.textureRenderer = null; this.pageCache = null; this.playbackCoordinator = null; this.activeSegment = null; this.preparedSegments = new Map(); this.timelineDiagnostics = []; this.benchmarkEntries = []; this.ownsPageFlipCommit = true; this.bindMethods([ 'initialize', 'playSentence', 'prepareSentence', 'activatePreparedSegment', 'ensureAnimationTimings', 'calculateAnimationTiming', 'createPreparedSegment', 'createRevealDetail', 'applyTexturePlan', 'startRevealForSegment', 'assertSegmentReady', 'collectRequiredPageMetas', 'collectTexturePlanPageMetas', 'requiresSpreadTransition', 'requiresRightPageFlipAfterReveal', 'getBlockRevealSides', 'waitForVisualCompletion', 'waitForRevealCommit', 'requestPageFlip', 'waitForPageFlipFinished', 'prewarmSegmentTextures', 'getPageMetaForIndex', 'getVisibleSpreadIndex', 'isChoiceAwaitingPlayer', '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.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) => { this.markBenchmark('flip-finished', event.detail || {}); }); 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; this.recordDiagnostic('segment-play:start', segment); if (this.requiresSpreadTransition(segment)) { const flipped = await this.timeStage('preplay-flip', segment, () => 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.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]); 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.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.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'); const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { phase: 'prepare', publishEvent: false }); 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, previewSpread, targetSpreadIndex, currentSpreadIndex, revealSides, requiresPreFlip: targetSpreadIndex > currentSpreadIndex, requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread), 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 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.revealSides = this.getBlockRevealSides(segment.activeSpread || segment.previewSpread, sentence.blockId); segment.requiresRightFlip = segment.revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread); const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate'); const texturePlan = 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 segment.activeSpread; } 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) { throw new Error(`BookPlaybackTimeline: Missing texture plan for block ${segment.blockId ?? 'unknown'} during ${phase}`); } if (typeof window.BookLabDebug?.applyPageTextureRecords !== 'function') { throw new Error('BookPlaybackTimeline: WebGL book lab cannot apply prepared texture plans'); } window.BookLabDebug.applyPageTextureRecords({ ...texturePlan, phase: phase === 'prepare' ? 'prepare' : 'activate' }); this.recordDiagnostic(`texture-plan-applied:${phase}`, segment); return true; } startRevealForSegment(segment = {}) { if (!segment?.blockId) return false; const revealStart = this.textureRenderer?.startPreparedRevealAnimation?.(segment.blockId, { publishEvent: false }); if (!revealStart) { throw new Error(`BookPlaybackTimeline: Prepared reveal animation is missing for block ${segment.blockId}`); } if (typeof window.BookLabDebug?.startRevealForBlock !== 'function') { throw new Error('BookPlaybackTimeline: WebGL book lab cannot start prepared reveals explicitly'); } window.BookLabDebug.startRevealForBlock(segment.blockId); 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; await this.timeStage('right-page-flip', segment, () => this.requestPageFlip(1, { reason: 'timeline-right-page-filled', targetSpread: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1), force: true })); } async waitForPlannedRightReveal(segment = {}) { const startedAt = Number(segment.revealStartedAt) || await (segment.revealStartedPromise || Promise.resolve(performance.now())); const duration = this.getRightRevealDurationMs(segment); const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now())); const remaining = Math.max(0, duration - elapsed); const planned = new Promise(resolve => { setTimeout(() => resolve(true), remaining); }); return Promise.race([ planned, this.waitForRevealCommit(segment) ]); } 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)); } 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: false }); await this.assertSegmentReady({ blockId: options.blockId ?? null, targetSpreadIndex: options.targetSpread, revealSides: [] }, 'flip'); const wait = this.waitForPageFlipFinished(options.targetSpread); if (typeof window.BookLabDebug?.requestPageFlip !== 'function') { throw new Error('BookPlaybackTimeline: WebGL book lab cannot execute prepared flip plans'); } window.BookLabDebug.requestPageFlip(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; } 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') { throw new Error('BookPlaybackTimeline: Page texture cache is not available'); } 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) { this.pageCache.recordProblem?.({ type: 'timeline-cache-readiness-failed', phase, blockId: segment.blockId ?? null, missingPages: missing.map(meta => meta.pageIndex ?? null) }); throw new Error(`BookPlaybackTimeline: Cache readiness failed during ${phase} for pages ${missing.map(meta => meta.pageIndex).join(', ')}`); } 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 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, revealSides: Array.isArray(segment.revealSides) ? segment.revealSides : [], 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, ownsPageFlipCommit: this.ownsPageFlipCommit, 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;