Rework WebGL book playback to single ownership and fix flip/reveal pipeline
Establish book-playback-timeline as the sole playback owner driving the scene through formal webgl-book:* events (not the BookLabDebug surface), with a single reveal clock in the scene render loop and webgl-page-cache as the only texture cache. Remove the legacy dual playback path and the ownsPageFlipCommit gating. Fixes: - Flip page detached/folded at the spine: restore the raw page-cap line for flip geometry (matches the prototype/pre-regression), removing normalizeFlipLineToVisiblePage which moved the pivot off the spine arc. - Flip textures: distance-based UVs (no horizontal compression), direction-aware face material (source on the camera-facing side), source meta derived from the visible spread (manual flips), prewarm shape fix. - Reveal: flash removed on the static page and the flip back surface; spanning blocks rebuild the reveal plan at activate and continue the reveal on the next spread after the fill flip. - Cache staleness is contentVersion-primary; nav clamps to spreadCount. Docs updated to describe the intended single-owner architecture. Regression checks updated to match. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,28 +1,44 @@
|
||||
/**
|
||||
* Book Playback Timeline Module
|
||||
* Owns prepared WebGL book playback order: pagination, texture readiness,
|
||||
* reveal start, page-flip timing, and visual completion.
|
||||
*
|
||||
* 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'];
|
||||
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.ownsPageFlipCommit = true;
|
||||
|
||||
this.bindMethods([
|
||||
'initialize',
|
||||
'playSentence',
|
||||
'prepareSentence',
|
||||
'commitSegmentSpread',
|
||||
'activatePreparedSegment',
|
||||
'ensureAnimationTimings',
|
||||
'calculateAnimationTiming',
|
||||
@@ -37,6 +53,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
'requiresRightPageFlipAfterReveal',
|
||||
'getBlockRevealSides',
|
||||
'waitForVisualCompletion',
|
||||
'revealContinuationSpread',
|
||||
'waitForPlannedRightReveal',
|
||||
'waitForRevealCommit',
|
||||
'requestPageFlip',
|
||||
'prepareFlipPlan',
|
||||
@@ -45,6 +63,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
'getPageMetaForIndex',
|
||||
'getVisibleSpreadIndex',
|
||||
'isChoiceAwaitingPlayer',
|
||||
'invalidatePreparedSegments',
|
||||
'rememberPreparedSegment',
|
||||
'markBenchmark',
|
||||
'timeStage',
|
||||
'recordDiagnostic',
|
||||
@@ -57,10 +77,10 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
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.markBenchmark('reveal-start', { blockId: event.detail?.blockId ?? null });
|
||||
});
|
||||
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
|
||||
this.markBenchmark('reveal-committed', {
|
||||
@@ -73,8 +93,12 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
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);
|
||||
window.BookPlaybackTimeline = this;
|
||||
this.reportProgress(100, 'Book playback timeline ready');
|
||||
return true;
|
||||
@@ -89,49 +113,62 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
}
|
||||
|
||||
this.activeSegment = segment;
|
||||
document.documentElement.dataset.webglBookPlaybackActive = 'true';
|
||||
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
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
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.preparedSegments.set(segment.key, segment);
|
||||
this.rememberPreparedSegment(segment);
|
||||
sentence.webglBookPresentation = {
|
||||
...(sentence.webglBookPresentation || {}),
|
||||
prepared: true,
|
||||
@@ -143,6 +180,21 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
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,
|
||||
@@ -165,6 +217,7 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
id: sentence.id,
|
||||
blockId: sentence.blockId,
|
||||
sentence,
|
||||
generation: this.paginationGeneration,
|
||||
previewSpread,
|
||||
targetSpreadIndex,
|
||||
currentSpreadIndex,
|
||||
@@ -191,7 +244,7 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
return segment;
|
||||
}
|
||||
|
||||
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
|
||||
async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
|
||||
if (!segment || !sentence) return null;
|
||||
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
|
||||
includeUnrenderedHistory: true
|
||||
@@ -199,19 +252,40 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
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')
|
||||
&& this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread);
|
||||
&& (segment.spansToNextSpread || this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread));
|
||||
this.recordDiagnostic('segment-commit:end', segment);
|
||||
return segment.activeSpread;
|
||||
}
|
||||
|
||||
const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate');
|
||||
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
|
||||
if (!segment || !sentence) return null;
|
||||
const spread = segment.activeSpread || segment.previewSpread;
|
||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||
// For a spanning block the prepared reveal plan was built during lookahead before
|
||||
// the continuation was committed, so it is right-only. Rebuild from the committed
|
||||
// spreads so the reveal timing spans both pages (right page no longer absorbs the
|
||||
// whole duration) and the continuation has reveal regions.
|
||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
|
||||
publishEvent: false
|
||||
publishEvent: false,
|
||||
forceRebuild: segment.spansToNextSpread === true
|
||||
});
|
||||
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;
|
||||
return spread;
|
||||
}
|
||||
|
||||
ensureAnimationTimings(sentence = {}) {
|
||||
@@ -285,31 +359,37 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
|
||||
applyTexturePlan(texturePlan = null, segment = {}, phase = 'activate') {
|
||||
if (!texturePlan) {
|
||||
throw new Error(`BookPlaybackTimeline: Missing texture plan for block ${segment.blockId ?? 'unknown'} during ${phase}`);
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-missing-texture-plan',
|
||||
blockId: segment.blockId ?? null,
|
||||
phase
|
||||
});
|
||||
return false;
|
||||
}
|
||||
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'
|
||||
});
|
||||
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: false
|
||||
publishEvent: true
|
||||
});
|
||||
if (!revealStart) {
|
||||
throw new Error(`BookPlaybackTimeline: Prepared reveal animation is missing for block ${segment.blockId}`);
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-prepared-reveal-missing',
|
||||
blockId: segment.blockId
|
||||
});
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
@@ -351,11 +431,46 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
}
|
||||
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, {
|
||||
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: Math.max(0, Number(segment.targetSpreadIndex || this.getVisibleSpreadIndex()) + 1),
|
||||
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;
|
||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||
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;
|
||||
}
|
||||
|
||||
async waitForPlannedRightReveal(segment = {}) {
|
||||
@@ -419,23 +534,25 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
|
||||
async requestPageFlip(direction = 1, options = {}) {
|
||||
if (this.isChoiceAwaitingPlayer()) return false;
|
||||
const flipPlan = await this.prepareFlipPlan(direction, options);
|
||||
// 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);
|
||||
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,
|
||||
prewarm: flipPlan.prewarm,
|
||||
flipPlan
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -525,7 +642,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
|
||||
async assertSegmentReady(segment = {}, phase = 'play') {
|
||||
if (!this.pageCache || typeof this.pageCache.ensurePageTexture !== 'function') {
|
||||
throw new Error('BookPlaybackTimeline: Page texture cache is not available');
|
||||
this.recordDiagnostic(`cache-unavailable:${phase}`, segment);
|
||||
return false;
|
||||
}
|
||||
const metas = this.collectRequiredPageMetas(segment, phase);
|
||||
const missing = [];
|
||||
@@ -536,13 +654,16 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
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)
|
||||
});
|
||||
throw new Error(`BookPlaybackTimeline: Cache readiness failed during ${phase} for pages ${missing.map(meta => meta.pageIndex).join(', ')}`);
|
||||
segment.cacheReady = false;
|
||||
segment.cacheReadyPhase = phase;
|
||||
return false;
|
||||
}
|
||||
segment.cacheReady = true;
|
||||
segment.cacheReadyPhase = phase;
|
||||
@@ -623,8 +744,9 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
}
|
||||
|
||||
getVisibleSpreadIndex() {
|
||||
const labSpread = window.BookLabDebug?.getBookState?.()?.spreadIndex;
|
||||
if (Number.isFinite(Number(labSpread))) return Math.max(0, Math.round(Number(labSpread)));
|
||||
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)));
|
||||
}
|
||||
|
||||
@@ -692,7 +814,8 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
return {
|
||||
activeBlockId: this.activeSegment?.blockId ?? null,
|
||||
preparedSegmentCount: this.preparedSegments.size,
|
||||
ownsPageFlipCommit: this.ownsPageFlipCommit,
|
||||
paginationGeneration: this.paginationGeneration,
|
||||
visibleSpreadIndex: this.visibleSpreadIndex,
|
||||
diagnostics: this.timelineDiagnostics.slice(-20),
|
||||
benchmark: this.benchmarkEntries.slice(-40)
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user