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:
2026-06-17 15:29:50 +02:00
parent c19ebe3089
commit 8bb18fa201
9 changed files with 439 additions and 438 deletions
+194 -71
View File
@@ -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)
};
+7 -172
View File
@@ -29,14 +29,10 @@ class BookTextureRendererModule extends BaseModule {
this.currentSpread = null;
this.activeAnimations = new Map();
this.revealedBlockIds = new Set();
this.pendingRevealBlockIds = new Set();
this.revealBaseCanvases = null;
this.revealPublishBlockIds = null;
this.lastDrawSignature = null;
this.lastDrawSkipLoggedAt = 0;
this.animationFrameId = null;
this.lastAnimationFrameAt = 0;
this.targetFrameDurationMs = 1000 / 60;
this.pipelineTimings = [];
this.imageCache = new Map();
this.pageContentVersions = new Map();
@@ -76,7 +72,6 @@ class BookTextureRendererModule extends BaseModule {
'applyTextStyle',
'getPageContent',
'buildLineSegments',
'startRevealAnimation',
'prepareRevealBlock',
'preloadAdditionalRevealSpreads',
'spreadContainsBlock',
@@ -88,9 +83,6 @@ class BookTextureRendererModule extends BaseModule {
'stopAnimations',
'getBlockSides',
'getAnimatedSides',
'markPendingReveal',
'requestAnimationFrame',
'tickAnimations',
'publishSpread',
'buildPageTextureRecords',
'cachePublishedPages',
@@ -114,62 +106,9 @@ class BookTextureRendererModule extends BaseModule {
this.reportProgress(20, 'Preparing page texture canvases');
this.createPageCanvases();
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
this.addEventListener(document, 'book-pagination:spread-updated', (event) => {
const spread = event.detail?.spread || this.pagination?.getCurrentSpread?.();
const spreadIndex = Math.max(0, Number(event.detail?.spreadIndex ?? spread?.index ?? 0));
const latestBlockId = event.detail?.latestBlockId;
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
const visibility = event.detail?.visibility || 'current';
this.currentSpread = spread || { left: [], right: [] };
const timelineOwnsPlayback = window.BookPlaybackTimeline?.ownsPageFlipCommit === true;
if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) {
this.markPipelineTiming('spreadUpdate:skip-during-flip', {
spreadIndex,
visibility
});
return;
}
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const id = String(latestBlockId);
if (timelineOwnsPlayback && visibility !== 'future-ready') {
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-reveal', {
spreadIndex,
latestBlockId: id,
visibility
});
return;
}
if (visibility === 'future-ready' && !this.activeAnimations.has(id)) {
this.drawSpread(this.stripUnrenderedLines(this.currentSpread, latestRenderedBlockId), ['left', 'right'], {
phase: 'prepare',
publishEvent: !timelineOwnsPlayback
});
return;
}
if (this.activeAnimations.has(id)) {
this.revealPublishBlockIds = new Set([id]);
const visibleSpread = Math.max(0, Number(window.BookLabDebug?.getBookState?.().spreadIndex || 0));
const flipActive = document.documentElement.dataset.webglPageFlipActive === 'true';
if (!flipActive && visibility !== 'future-ready' && spreadIndex > visibleSpread) {
this.drawSpread(this.currentSpread, ['left', 'right'], { phase: 'prepare' });
return;
}
this.drawSpread(this.currentSpread, ['left', 'right']);
}
return;
}
if (timelineOwnsPlayback && visibility !== 'future-ready' && latestBlockId) {
this.markPipelineTiming('spreadUpdate:skip-timeline-owned-commit', {
spreadIndex,
latestBlockId,
latestRenderedBlockId,
visibility
});
return;
}
this.drawSpread(this.currentSpread);
});
// The renderer is a pure renderer. It does not react to pagination spread
// updates with draws or reveals — the playback owner (book-playback-timeline)
// drives every draw explicitly. See docs/webgl-3d-ui-spec.md "Single ownership".
this.addEventListener(document, 'book-texture:fast-forward', this.fastForwardAnimations);
this.addEventListener(document, 'webgl-book:reveal-committed', (event) => {
this.completeRevealBlockIds(event.detail?.blockIds || []);
@@ -185,18 +124,6 @@ class BookTextureRendererModule extends BaseModule {
return true;
}
stripUnrenderedLines(spread = {}, latestRenderedBlockId = 0) {
const latestRendered = Math.max(0, Number(latestRenderedBlockId || 0));
return {
...spread,
left: (Array.isArray(spread.left) ? spread.left : [])
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
right: (Array.isArray(spread.right) ? spread.right : [])
.filter(line => Math.max(0, Number(line?.blockId || 0)) <= latestRendered),
pageMeta: spread.pageMeta || { left: null, right: null }
};
}
markPipelineTiming(name, detail = {}) {
const entry = {
name,
@@ -916,35 +843,6 @@ class BookTextureRendererModule extends BaseModule {
return Number.isFinite(explicit) && explicit > 0 ? explicit : 2000;
}
startRevealAnimation(detail = {}) {
const blockId = detail.blockId ?? detail.id ?? null;
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
const existing = this.activeAnimations.get(String(blockId));
if (existing && existing.prepared) {
this.startPreparedRevealAnimation(blockId);
return;
}
this.activeAnimations.set(String(blockId), {
blockId,
wordTimings: detail.wordTimings,
startedAt: performance.now(),
totalDuration: Math.max(
Number(detail.totalDuration || 0),
...detail.wordTimings.map(timing => Number(timing.delay || 0) + Number(timing.duration || 0))
),
completed: false
});
this.pendingRevealBlockIds.delete(String(blockId));
this.revealPublishBlockIds = new Set([String(blockId)]);
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']);
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
detail: {
blockId
}
}));
this.requestAnimationFrame();
}
createAnimationState(blockId, wordTimings = [], detail = {}) {
return {
blockId,
@@ -972,10 +870,12 @@ class BookTextureRendererModule extends BaseModule {
wordTimingCount: wordTimings.length,
phase
});
if (phase === 'activate' && this.pageCache?.hasPreparedRevealPlan?.(id)) {
// forceRebuild: the cached plan was built before the block's continuation was
// committed (it would be right-only). Discard it and redraw from current spreads.
if (options.forceRebuild === true) this.pageCache?.takePreparedRevealPlan?.(id);
if (phase === 'activate' && options.forceRebuild !== true && this.pageCache?.hasPreparedRevealPlan?.(id)) {
const cached = this.pageCache.takePreparedRevealPlan(id);
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.publishPreparedReveal(cached, options);
this.markPipelineTiming('prepareRevealBlock:end', {
blockId: id,
@@ -990,7 +890,6 @@ class BookTextureRendererModule extends BaseModule {
}
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
this.pendingRevealBlockIds.delete(id);
this.revealPublishBlockIds = new Set([id]);
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
const sides = ['left', 'right'];
@@ -1079,7 +978,6 @@ class BookTextureRendererModule extends BaseModule {
}
}));
}
this.requestAnimationFrame();
return {
blockId: animation.blockId,
wordTimingCount: Array.isArray(animation.wordTimings) ? animation.wordTimings.length : 0
@@ -1098,7 +996,6 @@ class BookTextureRendererModule extends BaseModule {
}
});
if (changed) {
this.pendingRevealBlockIds.clear();
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
detail: {
blockIds
@@ -1115,17 +1012,11 @@ class BookTextureRendererModule extends BaseModule {
const animation = this.activeAnimations.get(id);
if (animation) animation.completed = true;
this.revealedBlockIds.add(id);
this.pendingRevealBlockIds.delete(id);
});
}
stopAnimations() {
this.activeAnimations.clear();
this.pendingRevealBlockIds.clear();
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
}
@@ -1151,62 +1042,6 @@ class BookTextureRendererModule extends BaseModule {
return sides.length ? sides : ['left', 'right'];
}
markPendingReveal(blockId) {
const id = String(blockId ?? '');
if (!id || this.activeAnimations.has(id) || this.revealedBlockIds.has(id)) return;
this.pendingRevealBlockIds.add(id);
}
requestAnimationFrame() {
if (this.animationFrameId) return;
this.animationFrameId = window.setTimeout(() => this.tickAnimations(performance.now()), this.targetFrameDurationMs);
}
isWebGLPageFlipActive() {
return document.documentElement.dataset.webglPageFlipActive === 'true';
}
tickAnimations(now) {
this.animationFrameId = null;
if (now - this.lastAnimationFrameAt < this.targetFrameDurationMs) {
this.requestAnimationFrame();
return;
}
this.lastAnimationFrameAt = now;
let hasActive = false;
const currentNow = performance.now();
if (this.isWebGLPageFlipActive()) {
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
hasActive = true;
if (animation.startedAt != null) {
animation.startedAt += this.targetFrameDurationMs;
}
});
if (hasActive) this.requestAnimationFrame();
return;
}
this.activeAnimations.forEach((animation) => {
if (animation.completed) return;
if (!Array.isArray(animation.wordTimings) || animation.wordTimings.length === 0) return;
if (animation.startedAt == null) {
hasActive = true;
return;
}
const lastTiming = animation.wordTimings.at(-1);
const total = Number(lastTiming?.delay || 0) + Number(lastTiming?.duration || 0);
if (currentNow - animation.startedAt >= total + 50) {
animation.completed = true;
this.revealedBlockIds.add(String(animation.blockId ?? ''));
} else {
hasActive = true;
}
});
if (hasActive) this.requestAnimationFrame();
}
publishSpread(sides = null, options = {}) {
const sidesToPublish = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
const phase = this.getDrawPhase(options);
+1 -3
View File
@@ -526,9 +526,7 @@ class GameLoopModule extends BaseModule {
}
getWebGLBookState() {
return window.WebGLBookPreferenceBridge?.getBookState?.()
|| window.BookLabDebug?.getBookState?.()
|| null;
return window.WebGLBookPreferenceBridge?.getBookState?.() || null;
}
applyWebGLBookState(state = null) {
+5 -19
View File
@@ -309,29 +309,15 @@ class PlaybackCoordinatorModule extends BaseModule {
}
scheduleWebGLReveal(sentence, animQueue) {
let wordTimings = Array.isArray(sentence.animation?.wordTimings)
// The book playback timeline is the single owner of reveal timing. It guarantees
// sentence.animation is populated (ensureAnimationTimings) before playback. The
// coordinator trusts those timings and never recomputes them here.
const wordTimings = Array.isArray(sentence.animation?.wordTimings)
? sentence.animation.wordTimings
: [];
let cueTimings = Array.isArray(sentence.animation?.cueTimings)
const cueTimings = Array.isArray(sentence.animation?.cueTimings)
? sentence.animation.cueTimings
: [];
const timingDuration = wordTimings.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 || sentence.animation?.totalDuration || 0);
if (wordTimings.length === 0 || (timingDuration <= 0 && ttsDuration > 0)) {
const words = String(sentence.text || '').match(/\S+/g) || [];
const calculated = this.calculateWordTimings(words, ttsDuration);
wordTimings = calculated.wordTimings;
cueTimings = [];
sentence.animation = {
...(sentence.animation || {}),
wordTimings,
cueTimings,
totalDuration: calculated.totalDuration
};
}
if (typeof sentence.webglRevealController !== 'function') {
throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller');
+118 -115
View File
@@ -275,8 +275,6 @@ let currentPageMeta = {
left: null,
right: null
};
let pendingRightPageFlip = false;
let pendingRightPageFlipAutoplay = false;
const pageRevealState = {
left: null,
right: null
@@ -655,6 +653,24 @@ window.BookLabDebug = {
}
};
// Publish the visible spread as a production accessor on the scene module so the
// playback owner can read it without touching the debug surface (window.BookLabDebug).
const webglBookSceneModule = window.moduleRegistry?.getModule?.('webgl-book-scene') || null;
if (webglBookSceneModule) {
webglBookSceneModule.getVisibleSpreadIndex = () => Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
// Production control surface for the scene host (webgl-book-scene) and save/restore.
// window.BookLabDebug remains a debug/inspection-only alias; production code uses this.
webglBookSceneModule.sceneControl = {
getBookState: () => window.BookLabDebug.getBookState(),
setReadingProgress: (value) => setReadingProgress(value),
setBookPageCount: (value) => setBookPageCount(value),
setPageReserve: (value) => setPageReserve(value),
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY)
};
}
window.addEventListener('resize', resize);
document.addEventListener('webgl-book:page-texture-records', handlePageTextureRecords);
document.addEventListener('webgl-book:page-reveal-start', (event) => {
@@ -663,27 +679,38 @@ document.addEventListener('webgl-book:page-reveal-start', (event) => {
document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
fastForwardPageReveals(event.detail?.blockIds || []);
});
document.addEventListener('webgl-book:reveal-committed', (event) => {
handleRevealCommittedForPageFlip(event.detail || {});
document.addEventListener('webgl-book:request-page-flip', (event) => {
const detail = event.detail || {};
const direction = Number(detail.direction) || (detail.targetSpread > bookPaginationState.spreadIndex ? 1 : -1);
// Let the scene own flip prewarming via prewarmFlipTextures (which draws and
// makes resident the current + target spreads). The owner's cache-warming plan
// is a different shape and must not be passed through as the flip prewarm.
startPageFlip(direction, {
force: detail.force === true,
reason: detail.reason,
targetSpread: detail.targetSpread,
deferRevealSides: Array.isArray(detail.revealSides) ? detail.revealSides : null
});
});
document.addEventListener('webgl-book:page-cache-problem', (event) => {
pageTextureStore?.recordProblem?.(event.detail || {});
});
// Pagination spread updates only carry state. The playback owner decides when the
// visible spread changes (via flips). The scene jumps directly only for non-playback
// commits such as history restore. See docs/webgl-3d-ui-spec.md "Single ownership".
document.addEventListener('book-pagination:spread-updated', (event) => {
const detail = event.detail || {};
const incomingSpreadIndex = Math.max(0, Number(detail.spreadIndex || 0));
const latestBlockId = Math.max(0, Number(detail.latestBlockId || 0));
const latestRenderedBlockId = Math.max(0, Number(detail.latestRenderedBlockId || 0));
if (
window.BookPlaybackTimeline?.ownsPageFlipCommit === true
&& detail.visibility !== 'future-ready'
&& latestBlockId > 0
) {
markPageTextureTiming('spreadUpdate:timeline-owned-state-only', {
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
const stateOnly = playbackActive
|| activeFlips.length > 0
|| detail.visibility === 'future-ready';
if (stateOnly) {
markPageTextureTiming('spreadUpdate:state-only', {
incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId,
latestRenderedBlockId
visibility: detail.visibility || 'current',
playbackActive
});
bookPaginationState = {
...bookPaginationState,
@@ -695,23 +722,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
};
growBookIfWritableLimitReached();
syncBookControls();
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
return;
}
if (
latestBlockId > latestRenderedBlockId
&& detail.visibility !== 'future-ready'
&& activeFlips.length === 0
&& incomingSpreadIndex > Math.max(0, Number(bookPaginationState.spreadIndex || 0))
) {
markPageTextureTiming('spreadUpdate:deferred-future-unrendered', {
incomingSpreadIndex,
visibleSpreadIndex: bookPaginationState.spreadIndex,
latestBlockId,
latestRenderedBlockId
});
return;
}
// Non-playback committed update (history restore, continuation reload): jump
// directly to the committed spread and paint it.
const previousPageCount = bookPageCount;
bookPaginationState = {
spreadIndex: incomingSpreadIndex,
@@ -724,8 +738,10 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
notifyBookPageCountChanged();
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
}
const spread = detail.spread || getPaginationSpread(incomingSpreadIndex);
if (spread) window.BookTextureRenderer?.drawSpread?.(spread, ['left', 'right'], { force: true });
syncBookControls();
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
markPageTextureTiming('spreadUpdate:jump', { incomingSpreadIndex });
});
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
const detail = event.detail || {};
@@ -736,11 +752,6 @@ document.addEventListener('webgl-book:page-reserve-directive', (event) => {
: Math.round(value);
setPageReserve(nextReserve);
});
document.addEventListener('ui:command', (event) => {
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
tryStartPendingRightPageFlip('continue', { force: true });
}
});
installBookControls();
installCameraControls();
resize();
@@ -1881,6 +1892,17 @@ function getCurrentPagePosition() {
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
}
// Manual navigation must not run past the spreads that actually exist (so a stale
// restored maxVisitedPagePosition cannot enable a flip into empty pages), but it must
// still reach the last existing spread. spreadCount is the real spread count; the last
// navigable spread is spreadCount - 1. (writtenPageLimit deliberately under-counts by a
// spread, so it must not be used for this.)
function getNavigablePageLimit() {
const lastSpreadIndex = Math.max(0, Math.round(Number(bookPaginationState.spreadCount || 1)) - 1);
const contentNavigable = spreadIndexToPagePosition(lastSpreadIndex);
return Math.min(maxVisitedPagePosition, getWritablePageLimit(), contentNavigable);
}
function scheduleBookRebuild(reason = 'scheduled') {
if (scheduledBookRebuildFrame !== null) return;
const scheduler = typeof window.requestIdleCallback === 'function'
@@ -2090,7 +2112,7 @@ function syncBottomNavigation() {
if (!bottomNavigation) return;
const currentPage = getCurrentPagePosition();
const writableLimit = getWritablePageLimit();
const navigableLimit = Math.min(maxVisitedPagePosition, writableLimit);
const navigableLimit = getNavigablePageLimit();
const reservedStart = Math.max(0, writableLimit);
bottomNavigation.slider.max = String(Math.max(0, bookPageCount));
bottomNavigation.slider.value = String(Math.min(currentPage, navigableLimit));
@@ -2938,12 +2960,10 @@ function startPageFlipPrepared(direction, options = {}) {
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
if (!flip) return false;
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
flip.deferRevealSides = Array.isArray(options.deferRevealSides) ? options.deferRevealSides : null;
if (!prepareStaticPageForFlip(flip, options.prewarm || null)) {
return false;
}
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
activeFlips.push(flip);
setPageFlipActiveFlag();
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-started', {
@@ -3007,8 +3027,12 @@ function startFastPageFlipPrepared(direction, options = {}) {
function createPageFlip(direction, startTime, duration) {
const sourceSide = direction > 0 ? 1 : -1;
const sourcePageSide = direction > 0 ? 'right' : 'left';
const sourceLine = normalizeFlipLineToVisiblePage(topVisibleLine(sourceSide), sourceSide);
const destinationLine = normalizeFlipLineToVisiblePage(topVisibleLine(-sourceSide), -sourceSide);
// Use the raw page-cap line (as the working prototype / pre-ef358c5 lab did). Each
// line's points[0] === its spine-arc anchor (spineCurvePoint(t)), so the flip sheet
// hinges at the spine. Rewriting the line to the "visible page width" moved the pivot
// off the spine arc and folded the inner spine-wall climb into a crease at the spine.
const sourceLine = topVisibleLine(sourceSide);
const destinationLine = topVisibleLine(-sourceSide);
if (!sourceLine || !destinationLine) return null;
return {
direction,
@@ -3024,34 +3048,11 @@ function createPageFlip(direction, startTime, duration) {
};
}
function normalizeFlipLineToVisiblePage(line, side) {
if (!line || !currentProceduralBookModel) return line;
const points = Array.isArray(line.points) ? line.points : [];
if (points.length < 2) return line;
const pageStartX = side * Math.max(0, Number(currentProceduralBookModel.spineHalf || 0));
const endpoint = points[points.length - 1];
const sourceStart = points[0];
const sourceSpan = Math.max(0.0001, side * (endpoint.x - sourceStart.x));
const normalizedPoints = points.map((point) => {
const u = THREE.MathUtils.clamp(side * (point.x - sourceStart.x) / sourceSpan, 0, 1);
return {
x: THREE.MathUtils.lerp(pageStartX, endpoint.x, u),
y: point.y
};
});
return {
...line,
anchor: normalizedPoints[0],
points: normalizedPoints,
endpoint: normalizedPoints[normalizedPoints.length - 1]
};
}
function prepareStaticPageForFlip(flip, prewarm = null) {
if (!flip) return false;
const sourceSide = flip.direction > 0 ? 'right' : 'left';
const sourceTexture = resolveCurrentFlipSourceTexture(sourceSide);
const sourcePageMeta = currentPageMeta?.[sourceSide] || getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || null;
const sourcePageMeta = getPaginationPageMeta(spreadPageIndices(bookPaginationState.spreadIndex)[sourceSide]) || currentPageMeta?.[sourceSide] || null;
const targetSpread = Number.isFinite(Number(flip.targetSpread))
? Math.max(0, Math.round(Number(flip.targetSpread)))
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
@@ -3077,10 +3078,15 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
});
return false;
}
// If the page the flip lands on will be revealed right after (a block reveals on
// that side), do not show its full text on the turning page's back face — that
// flashes the not-yet-revealed content. Show blank during the turn; the masked
// reveal lands on the static page once the flip finishes.
const backDeferred = Array.isArray(flip.deferRevealSides) && flip.deferRevealSides.includes(targetBackSide);
materials.flipPageSurface.map = sourceTexture;
materials.flipPageBackSurface.map = backTexture || getBlankPageTexture();
materials.flipPageBackSurface.map = backDeferred ? getBlankPageTexture() : (backTexture || getBlankPageTexture());
materials.flipPageSurface.userData.sourceRevealSide = revealStateMatchesPage(sourceSide, sourcePageMeta) ? sourceSide : null;
materials.flipPageBackSurface.userData.sourceRevealSide = revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null;
materials.flipPageBackSurface.userData.sourceRevealSide = backDeferred ? null : (revealStateMatchesPage(targetBackSide, targetBackPageMeta) ? targetBackSide : null);
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
@@ -3131,7 +3137,11 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
}
function resolveCurrentFlipSourceTexture(side) {
const pageMeta = currentPageMeta?.[side] || null;
// Derive the source page meta from the actually-visible spread. currentPageMeta is
// only refreshed by the activate pipeline, so it is stale after manual navigation —
// using it here resolved the wrong source texture for the next flip.
const visiblePageIndex = spreadPageIndices(bookPaginationState.spreadIndex)[side];
const pageMeta = getPaginationPageMeta(visiblePageIndex) || currentPageMeta?.[side] || null;
if (pageMeta?.kind === 'blank') return getBlankPageTexture();
const material = side === 'left' ? materials.leftPage : materials.rightPage;
if (revealStateMatchesPage(side, pageMeta)) return material?.map || null;
@@ -3149,53 +3159,16 @@ function resolveFlipBackTexture(pageMeta = null, prewarmedTexture = null) {
function canPageFlip(direction) {
if (!currentProceduralBookModel) return false;
const currentPage = getCurrentPagePosition();
const maxNavigablePage = Math.min(maxVisitedPagePosition, getWritablePageLimit());
if (direction > 0) return currentPage < maxNavigablePage;
if (direction > 0) return currentPage < getNavigablePageLimit();
return currentPage > 0;
}
function handleRevealCommittedForPageFlip(detail = {}) {
if (window.BookPlaybackTimeline?.ownsPageFlipCommit === true) return;
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
if (activeFlips.length > 0 || pendingRightPageFlip) return;
if (isChoiceAwaitingPlayer()) return;
const autoplayFlip = isTtsPlaybackActive();
pendingRightPageFlip = true;
pendingRightPageFlipAutoplay = autoplayFlip;
document.documentElement.dataset.webglPendingPageFlip = 'right';
if (autoplayFlip) {
tryStartPendingRightPageFlip('tts-active');
}
}
async function tryStartPendingRightPageFlip(reason = 'pending', options = {}) {
if (!pendingRightPageFlip || activeFlips.length > 0 || isChoiceAwaitingPlayer()) return false;
if (!options.force && !pendingRightPageFlipAutoplay) return false;
const targetSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + 1);
const flipped = await startPageFlip(1, {
force: options.force === true || pendingRightPageFlipAutoplay,
reason,
targetSpread
});
if (flipped) {
pendingRightPageFlip = false;
pendingRightPageFlipAutoplay = false;
delete document.documentElement.dataset.webglPendingPageFlip;
}
return flipped;
}
function isChoiceAwaitingPlayer() {
return document.documentElement.dataset.choiceAwaiting === 'true'
|| document.body?.dataset?.choiceAwaiting === 'true'
|| Boolean(document.querySelector('#choice_menu:not([hidden]) .choice, #choice_menu.visible .choice'));
}
function isTtsPlaybackActive() {
const coordinator = window.moduleRegistry?.getModule?.('playback-coordinator') || window.PlaybackCoordinator || null;
return Boolean(coordinator?.isPlaying || coordinator?.state === 'playing' || document.documentElement.dataset.ttsPlaying === 'true');
}
function topVisibleLine(side) {
const sideLines = currentProceduralBookModel.lines
.filter((line) => line.side === side)
@@ -3205,9 +3178,15 @@ function topVisibleLine(side) {
function updateActiveFlips(now) {
if (!activeFlips.length || !currentProceduralBookModel) return;
// Debug/isolation hook: when window.__debugFlipFreezeT is a finite number in [0,1],
// hold every active flip at that progress so a single frame can be inspected.
const freezeT = Number(window.__debugFlipFreezeT);
const frozen = Number.isFinite(freezeT);
const completed = [];
activeFlips.forEach((flip) => {
const elapsed = (now - flip.startTime) / flip.duration;
const elapsed = frozen
? THREE.MathUtils.clamp(freezeT, 0, 1)
: (now - flip.startTime) / flip.duration;
if (elapsed < 0) return;
const t = THREE.MathUtils.clamp(elapsed, 0, 1);
const surface = buildFlippingPageSurface(flip.sourceLine, flip.destinationLine, flip.direction, easeInOutCubic(t), flip.pageOffset);
@@ -3218,7 +3197,10 @@ function updateActiveFlips(now) {
? Math.max(0, Math.round(Number(flip.targetSpread)))
: null;
if (targetSpread !== null && !hasActivePageReveal()) {
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end');
// Skip the revealing side(s): the timeline's activate lands the masked
// reveal for them right after the flip. Showing the full resident texture
// here would flash the not-yet-revealed block.
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end', { skipSides: flip.deferRevealSides });
}
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
detail: {
@@ -3242,9 +3224,11 @@ function hasActivePageReveal() {
});
}
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread') {
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
const pageIndices = spreadPageIndices(spreadIndex);
['left', 'right'].forEach((side) => {
if (skipSides.includes(side)) return;
const pageIndex = pageIndices[side];
const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex);
const texture = pageMeta.kind === 'blank'
@@ -3389,8 +3373,9 @@ function lineYAtX(points, x) {
}
function setActivePageGeometry(flip, surface) {
const widthRatios = flipWidthRatios(flip.sourceLine?.points);
if (!flip.mesh) {
const geometry = createFlippingPageGeometry(surface, flip.direction);
const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
flip.mesh = new THREE.Mesh(geometry, [
materials.flipPageSurface,
materials.flipPageBackSurface,
@@ -3404,13 +3389,25 @@ function setActivePageGeometry(flip, surface) {
return;
}
if (!updateFlippingPageGeometry(flip.mesh.geometry, surface)) {
const geometry = createFlippingPageGeometry(surface, flip.direction);
const geometry = createFlippingPageGeometry(surface, flip.direction, widthRatios);
flip.mesh.geometry.dispose();
flip.mesh.geometry = geometry;
}
}
function createFlippingPageGeometry(surface, direction = 1) {
// Texture U coordinates must follow physical page width (the spline uses short
// segments near the spine and long segments near the fore-edge), not the uniform
// vertex index, otherwise the flip texture is horizontally compressed relative to
// the static stack cap.
function flipWidthRatios(points) {
if (!Array.isArray(points) || points.length < 2) return null;
const lengths = cumulativeLineLengths(points);
const total = lengths[lengths.length - 1];
if (!(total > 0)) return null;
return lengths.map(length => length / total);
}
function createFlippingPageGeometry(surface, direction = 1, widthRatios = null) {
const positions = [];
const uvs = [];
const indices = [];
@@ -3426,8 +3423,12 @@ function createFlippingPageGeometry(surface, direction = 1) {
const targetSide = -sourceSide;
const topPageSide = direction > 0 ? targetSide : sourceSide;
const bottomPageSide = direction > 0 ? sourceSide : targetSide;
const topMaterialIndex = 0;
const bottomMaterialIndex = 1;
// The page's width index runs spine->right for forward flips and spine->left for
// backward flips, which inverts the computed face normals. Assign the source
// texture to the face that actually points at the camera at the start of the turn
// so the lifting page shows the page it came from (not the page it lands on).
const topMaterialIndex = direction > 0 ? 1 : 0;
const bottomMaterialIndex = direction > 0 ? 0 : 1;
const push = (point, yOffset, uv) => {
const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z);
@@ -3438,7 +3439,9 @@ function createFlippingPageGeometry(surface, direction = 1) {
surface.forEach((rowPoints, widthIndex) => {
const topRow = [];
const bottomRow = [];
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
const u = Array.isArray(widthRatios) && widthRatios.length === surface.length
? widthRatios[widthIndex]
: (widthSegments <= 0 ? 0 : widthIndex / widthSegments);
rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v)));
+14 -10
View File
@@ -17,6 +17,10 @@ class WebGLBookSceneModule extends BaseModule {
this.gameConfig = null;
this.mode = '2d';
this.is3dSupported = false;
// Production control surface + visible-spread accessor for the dynamically
// imported webgl-book-lab. Populated by the lab once the scene is built.
this.sceneControl = null;
this.getVisibleSpreadIndex = null;
this.labImportPromise = null;
this.textureRefreshTimer = null;
this.textureRefreshAnimationId = null;
@@ -330,7 +334,7 @@ class WebGLBookSceneModule extends BaseModule {
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', value);
this.preferenceWriteGuard = false;
},
getBookState: () => window.BookLabDebug?.getBookState?.() || {
getBookState: () => this.sceneControl?.getBookState?.() || {
pageCount: this.persistenceManager?.getPreference?.('webgl', 'bookPageCount', DEFAULT_BOOK_PAGE_COUNT) ?? DEFAULT_BOOK_PAGE_COUNT,
pageReserve: this.persistenceManager?.getPreference?.('webgl', 'pageReserve', DEFAULT_PAGE_RESERVE) ?? DEFAULT_PAGE_RESERVE,
progress: this.persistenceManager?.getPreference?.('webgl', 'bookProgress', DEFAULT_BOOK_PROGRESS) ?? DEFAULT_BOOK_PROGRESS
@@ -341,19 +345,19 @@ class WebGLBookSceneModule extends BaseModule {
const progress = Number(state.progress);
if (Number.isFinite(pageCount)) {
this.persistenceManager?.updatePreference?.('webgl', 'bookPageCount', pageCount);
window.BookLabDebug?.setBookPageCount?.(pageCount);
this.sceneControl?.setBookPageCount?.(pageCount);
}
if (Number.isFinite(pageReserve)) {
this.persistenceManager?.updatePreference?.('webgl', 'pageReserve', pageReserve);
window.BookLabDebug?.setPageReserve?.(pageReserve);
this.sceneControl?.setPageReserve?.(pageReserve);
}
if (Number.isFinite(progress)) {
this.persistenceManager?.updatePreference?.('webgl', 'bookProgress', progress);
window.BookLabDebug?.setReadingProgress?.(progress);
this.sceneControl?.setReadingProgress?.(progress);
}
const maxVisitedPagePosition = Number(state.maxVisitedPagePosition ?? state.pagePosition);
if (Number.isFinite(maxVisitedPagePosition)) {
window.BookLabDebug?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
this.sceneControl?.setMaxVisitedPagePosition?.(maxVisitedPagePosition);
}
}
};
@@ -456,7 +460,7 @@ class WebGLBookSceneModule extends BaseModule {
}
projectCanvasEventTarget(event) {
const projection = window.BookLabDebug?.projectPointerToPage?.(event.clientX, event.clientY);
const projection = this.sceneControl?.projectPointerToPage?.(event.clientX, event.clientY);
if (!projection) {
document.documentElement.dataset.webglLastProjection = JSON.stringify({
hit: false,
@@ -531,11 +535,11 @@ class WebGLBookSceneModule extends BaseModule {
this.initializeScene();
}
} else if (key === 'bookProgress' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setReadingProgress?.(value);
this.sceneControl?.setReadingProgress?.(value);
} else if (key === 'bookPageCount' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setBookPageCount?.(value);
this.sceneControl?.setBookPageCount?.(value);
} else if (key === 'pageReserve' && !this.preferenceWriteGuard) {
window.BookLabDebug?.setPageReserve?.(value);
this.sceneControl?.setPageReserve?.(value);
}
}
@@ -556,7 +560,7 @@ class WebGLBookSceneModule extends BaseModule {
triggerTextureRefresh() {
clearTimeout(this.textureRefreshTimer);
this.textureRefreshTimer = setTimeout(() => {
window.BookLabDebug?.redrawPageTextures?.();
this.sceneControl?.redrawPageTextures?.();
}, 60);
}
+11 -10
View File
@@ -552,26 +552,27 @@ class WebGLPageCacheModule extends BaseModule {
}
}
// contentVersion is the monotonic authority: a higher version is always newer and
// wins, even when the re-typeset page legitimately has fewer lines (lower
// completeness). completenessScore only breaks ties when versions are equal/absent.
isOlderPageEntry(pageMeta = {}, oldEntry = null) {
if (!oldEntry) return false;
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(pageMeta.contentVersion || 0));
const existingVersion = Math.max(0, Number(oldEntry.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
const incomingCompleteness = Math.max(0, Number(pageMeta.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(oldEntry.completenessScore || 0));
return incomingCompleteness < existingCompleteness;
}
isOlderPageMeta(incoming = {}, existing = null) {
if (!existing) return false;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
if (incomingCompleteness < existingCompleteness) return true;
if (incomingCompleteness > existingCompleteness) return false;
const incomingVersion = Math.max(0, Number(incoming?.contentVersion || 0));
const existingVersion = Math.max(0, Number(existing?.contentVersion || 0));
return incomingVersion > 0 && existingVersion > incomingVersion;
if (incomingVersion !== existingVersion) return incomingVersion < existingVersion;
const incomingCompleteness = Math.max(0, Number(incoming?.completenessScore || 0));
const existingCompleteness = Math.max(0, Number(existing?.completenessScore || 0));
return incomingCompleteness < existingCompleteness;
}
recordProblem(detail = {}) {