378 lines
16 KiB
JavaScript
378 lines
16 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', '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;
|