1e8defbb55
For a block that overflows onto the next spread, the plan is now prepared spanning-aware during the background lookahead — the start spread's reveal timing is derived across both preview spreads, and the continuation spread's plan is prepared and cached at the same time. playback then follows a single path: - activate reuses the prepared start plan (removed the synchronous forceRebuild rebuild). - revealContinuationSpread reuses the prepared continuation plan (removed the redraw fallback); a missing plan is surfaced as a problem, not silently redrawn. This removes the parallel/immediate prepare distinction and the two fallbacks, leaving one intended path, and moves the spanning draw work off the critical path. Verified live on a real spanning block: right line reveals at its area share (~3.3s), the flip fires, and the continuation appears ~0.3s after the flip (was ~2.7s) and animates progressively across the next spread over the full TTS — no pop-in, no fast-forward, no timeline-reveal-continuation-missing. Static suite passes (165). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
861 lines
39 KiB
JavaScript
861 lines
39 KiB
JavaScript
/**
|
|
* 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 = this.textureRenderer.prepareRevealBlock(
|
|
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
|
|
{ phase: 'prepare', publishEvent: false }
|
|
);
|
|
if (continuationSpread) {
|
|
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 = 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;
|