Enforce explicit WebGL book playback timeline

This commit is contained in:
2026-06-10 09:35:00 +02:00
parent 5a84923884
commit ce8147b5b1
7 changed files with 199 additions and 185 deletions
+110 -10
View File
@@ -28,6 +28,10 @@ class BookPlaybackTimelineModule extends BaseModule {
'ensureAnimationTimings',
'createPreparedSegment',
'createRevealDetail',
'applyTexturePlan',
'startRevealForSegment',
'assertSegmentReady',
'collectRequiredPageMetas',
'requiresSpreadTransition',
'requiresRightPageFlipAfterReveal',
'getBlockRevealSides',
@@ -103,6 +107,7 @@ class BookPlaybackTimelineModule extends BaseModule {
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();
@@ -146,7 +151,10 @@ class BookPlaybackTimelineModule extends BaseModule {
if (!previewSpread) return null;
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
this.textureRenderer.prepareRevealBlock(revealDetail, { phase: 'prepare' });
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, {
phase: 'prepare',
publishEvent: false
});
const targetSpreadIndex = Math.max(0, Number(previewSpread.index || 0));
const currentSpreadIndex = this.getVisibleSpreadIndex();
@@ -162,11 +170,14 @@ class BookPlaybackTimelineModule extends BaseModule {
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));
}
@@ -185,7 +196,12 @@ class BookPlaybackTimelineModule extends BaseModule {
&& this.requiresRightPageFlipAfterReveal(segment.activeSpread || segment.previewSpread);
const revealDetail = this.createRevealDetail(sentence, segment.activeSpread || segment.previewSpread, 'activate');
this.textureRenderer.prepareRevealBlock(revealDetail);
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;
@@ -210,6 +226,38 @@ 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}`);
}
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();
}
@@ -288,15 +336,20 @@ class BookPlaybackTimelineModule extends BaseModule {
getPageMetaForIndex: this.getPageMetaForIndex,
recordMiss: true
});
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
}
}));
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;
}
@@ -316,6 +369,53 @@ class BookPlaybackTimelineModule extends BaseModule {
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);