Files
ai.interactive.fiction/public/js/book-playback-timeline-module.js
T

604 lines
26 KiB
JavaScript

/**
* 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',
'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 visualPromise = this.waitForVisualCompletion(segment);
const playbackPromise = this.timeStage('playback', segment, () => {
return 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.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(),
status: 'prepared'
};
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 = {}) {
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.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') {
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);
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.waitForRevealCommit(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
}));
}
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
});
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 = {}) {
const spreads = new Set();
const currentSpread = this.getVisibleSpreadIndex();
const targetSpread = Number.isFinite(Number(segment.targetSpreadIndex))
? Math.max(0, Math.round(Number(segment.targetSpreadIndex)))
: currentSpread;
spreads.add(0);
spreads.add(currentSpread);
spreads.add(Math.max(0, currentSpread - 1));
spreads.add(currentSpread + 1);
spreads.add(targetSpread);
if (segment.requiresRightFlip) spreads.add(targetSpread + 1);
return Array.from(spreads)
.filter(spread => spread >= 0)
.flatMap(spread => [
this.getPageMetaForIndex(spread * 2),
this.getPageMetaForIndex(spread * 2 + 1)
]);
}
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);
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 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,
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;