Fix WebGL page cache and flip sequencing
This commit is contained in:
@@ -54,6 +54,7 @@ class BookPaginationModule extends BaseModule {
|
|||||||
'countLineWords',
|
'countLineWords',
|
||||||
'getLineGeometry',
|
'getLineGeometry',
|
||||||
'getSpread',
|
'getSpread',
|
||||||
|
'findSpreadIndexForBlock',
|
||||||
'getCurrentSpread',
|
'getCurrentSpread',
|
||||||
'setCurrentSpread',
|
'setCurrentSpread',
|
||||||
'handlePageCountChanged',
|
'handlePageCountChanged',
|
||||||
@@ -99,11 +100,17 @@ class BookPaginationModule extends BaseModule {
|
|||||||
const token = ++this.refreshToken;
|
const token = ++this.refreshToken;
|
||||||
const detail = event?.detail || {};
|
const detail = event?.detail || {};
|
||||||
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
|
const gameId = detail.gameId || this.storyHistory?.currentGameId || null;
|
||||||
|
const latestRenderedBlockId = Math.max(
|
||||||
|
0,
|
||||||
|
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
|
||||||
|
);
|
||||||
const latestBlockId = Math.max(
|
const latestBlockId = Math.max(
|
||||||
0,
|
0,
|
||||||
Number(detail.latestRenderedBlockId || detail.latestBlockId || this.storyHistory?.latestRenderedBlockId || (this.storyHistory?.nextBlockId || 1) - 1)
|
Number(detail.latestBlockId || (this.storyHistory?.nextBlockId || 1) - 1 || latestRenderedBlockId)
|
||||||
);
|
);
|
||||||
if (!gameId || latestBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
|
const continuationBlockId = this.getContinuationBlockId(latestBlockId, latestRenderedBlockId);
|
||||||
|
const paginationEndBlockId = Math.max(latestRenderedBlockId, continuationBlockId);
|
||||||
|
if (!gameId || paginationEndBlockId <= 0 || typeof this.storyHistory?.getBlocksRange !== 'function') {
|
||||||
this.pages = this.buildPages([]);
|
this.pages = this.buildPages([]);
|
||||||
this.spreads = this.buildSpreadsFromPages(this.pages);
|
this.spreads = this.buildSpreadsFromPages(this.pages);
|
||||||
this.latestBlockId = 0;
|
this.latestBlockId = 0;
|
||||||
@@ -114,20 +121,31 @@ class BookPaginationModule extends BaseModule {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = await this.storyHistory.getBlocksRange(gameId, 1, latestBlockId);
|
const blocks = await this.storyHistory.getBlocksRange(gameId, 1, paginationEndBlockId);
|
||||||
if (token !== this.refreshToken) return;
|
if (token !== this.refreshToken) return;
|
||||||
this.latestBlockId = latestBlockId;
|
this.latestBlockId = latestBlockId;
|
||||||
this.latestRenderedBlockId = Math.max(
|
this.latestRenderedBlockId = latestRenderedBlockId;
|
||||||
0,
|
|
||||||
Number(detail.latestRenderedBlockId || this.storyHistory?.latestRenderedBlockId || 0)
|
|
||||||
);
|
|
||||||
this.pages = this.buildPages(blocks);
|
this.pages = this.buildPages(blocks);
|
||||||
this.spreads = this.buildSpreadsFromPages(this.pages);
|
this.spreads = this.buildSpreadsFromPages(this.pages);
|
||||||
this.persistPaginationMetrics(this.pages);
|
this.persistPaginationMetrics(this.pages);
|
||||||
this.currentSpreadIndex = Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
const continuationSpreadIndex = this.findSpreadIndexForBlock(continuationBlockId);
|
||||||
|
const renderedSpreadIndex = this.findSpreadIndexForBlock(latestRenderedBlockId);
|
||||||
|
this.currentSpreadIndex = continuationSpreadIndex >= 0
|
||||||
|
? continuationSpreadIndex
|
||||||
|
: renderedSpreadIndex >= 0
|
||||||
|
? renderedSpreadIndex
|
||||||
|
: Math.max(0, Math.min(this.currentSpreadIndex, Math.max(0, this.spreads.length - 1)));
|
||||||
this.publish();
|
this.publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getContinuationBlockId(latestBlockId = 0, latestRenderedBlockId = 0) {
|
||||||
|
const latest = Math.max(0, Number(latestBlockId || 0));
|
||||||
|
const rendered = Math.max(0, Number(latestRenderedBlockId || 0));
|
||||||
|
if (latest <= 0) return 0;
|
||||||
|
if (rendered <= 0) return 1;
|
||||||
|
return rendered < latest ? rendered + 1 : latest;
|
||||||
|
}
|
||||||
|
|
||||||
async preparePendingBlock(block = {}, options = {}) {
|
async preparePendingBlock(block = {}, options = {}) {
|
||||||
const token = options.activate === false ? this.refreshToken : ++this.refreshToken;
|
const token = options.activate === false ? this.refreshToken : ++this.refreshToken;
|
||||||
const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null;
|
const gameId = block.gameId || block.metadata?.gameId || this.storyHistory?.currentGameId || null;
|
||||||
@@ -857,6 +875,16 @@ class BookPaginationModule extends BaseModule {
|
|||||||
return this.spreads[Math.max(0, Number(index || 0))] || { index: 0, left: [], right: [] };
|
return this.spreads[Math.max(0, Number(index || 0))] || { index: 0, left: [], right: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findSpreadIndexForBlock(blockId) {
|
||||||
|
const id = Math.max(0, Number(blockId || 0));
|
||||||
|
if (id <= 0) return -1;
|
||||||
|
const spread = this.spreads.find(entry => ['left', 'right'].some((side) => {
|
||||||
|
const lines = Array.isArray(entry?.[side]) ? entry[side] : [];
|
||||||
|
return lines.some(line => Number(line?.blockId || 0) === id);
|
||||||
|
}));
|
||||||
|
return Number.isFinite(Number(spread?.index)) ? Math.max(0, Math.round(Number(spread.index))) : -1;
|
||||||
|
}
|
||||||
|
|
||||||
getCurrentSpread() {
|
getCurrentSpread() {
|
||||||
return this.getSpread(this.currentSpreadIndex);
|
return this.getSpread(this.currentSpreadIndex);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,11 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.lastDrawSkipLoggedAt = 0;
|
this.lastDrawSkipLoggedAt = 0;
|
||||||
this.animationFrameId = null;
|
this.animationFrameId = null;
|
||||||
this.lastAnimationFrameAt = 0;
|
this.lastAnimationFrameAt = 0;
|
||||||
this.targetFrameDurationMs = 1000 / 30;
|
this.targetFrameDurationMs = 1000 / 60;
|
||||||
this.pipelineTimings = [];
|
this.pipelineTimings = [];
|
||||||
this.imageCache = new Map();
|
this.imageCache = new Map();
|
||||||
|
this.pendingPageCacheWrites = new Map();
|
||||||
|
this.pageContentVersions = new Map();
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
@@ -84,6 +86,8 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'tickAnimations',
|
'tickAnimations',
|
||||||
'publishSpread',
|
'publishSpread',
|
||||||
'cachePublishedPages',
|
'cachePublishedPages',
|
||||||
|
'getPageCacheWriteKey',
|
||||||
|
'isOlderPageMeta',
|
||||||
'schedulePageCacheWrite',
|
'schedulePageCacheWrite',
|
||||||
'getPageCanvas',
|
'getPageCanvas',
|
||||||
'getHitMap',
|
'getHitMap',
|
||||||
@@ -641,7 +645,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
this.pendingRevealBlockIds.delete(String(blockId));
|
this.pendingRevealBlockIds.delete(String(blockId));
|
||||||
this.revealPublishBlockIds = new Set([String(blockId)]);
|
this.revealPublishBlockIds = new Set([String(blockId)]);
|
||||||
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), this.getBlockSides(blockId));
|
this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.(), ['left', 'right']);
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-start', {
|
||||||
detail: {
|
detail: {
|
||||||
blockId
|
blockId
|
||||||
@@ -692,7 +696,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.pendingRevealBlockIds.delete(id);
|
this.pendingRevealBlockIds.delete(id);
|
||||||
this.revealPublishBlockIds = new Set([id]);
|
this.revealPublishBlockIds = new Set([id]);
|
||||||
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
const spread = detail.spread || this.currentSpread || this.pagination?.getCurrentSpread?.();
|
||||||
const sides = this.getBlockSides(blockId);
|
const sides = ['left', 'right'];
|
||||||
const published = this.drawSpread(spread, sides, { preloadOnly });
|
const published = this.drawSpread(spread, sides, { preloadOnly });
|
||||||
if (preloadOnly && published) {
|
if (preloadOnly && published) {
|
||||||
this.preparedRevealCache.set(id, {
|
this.preparedRevealCache.set(id, {
|
||||||
@@ -728,6 +732,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
left: prepared.left || null,
|
left: prepared.left || null,
|
||||||
right: prepared.right || null,
|
right: prepared.right || null,
|
||||||
reveal: prepared.reveal || {},
|
reveal: prepared.reveal || {},
|
||||||
|
pageMeta: prepared.pageMeta || {},
|
||||||
preparedFromCache: true
|
preparedFromCache: true
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -856,7 +861,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
metrics: this.metrics,
|
metrics: this.metrics,
|
||||||
hitMaps: this.hitMaps,
|
hitMaps: this.hitMaps,
|
||||||
sides: sidesToPublish,
|
sides: sidesToPublish,
|
||||||
pageMeta: this.currentSpread?.pageMeta || {}
|
pageMeta: this.buildPublishPageMeta(sidesToPublish)
|
||||||
};
|
};
|
||||||
if (options.preloadOnly) detail.preloadOnly = true;
|
if (options.preloadOnly) detail.preloadOnly = true;
|
||||||
if (sidesToPublish.includes('left')) {
|
if (sidesToPublish.includes('left')) {
|
||||||
@@ -907,6 +912,35 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
return detail;
|
return detail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
buildPublishPageMeta(sides = []) {
|
||||||
|
const baseMeta = this.currentSpread?.pageMeta || {};
|
||||||
|
return sides.reduce((meta, side) => {
|
||||||
|
const source = baseMeta[side] || null;
|
||||||
|
if (!source) {
|
||||||
|
meta[side] = null;
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
const lines = Array.isArray(this.currentSpread?.[side]) ? this.currentSpread[side] : [];
|
||||||
|
const maxBlockId = lines.reduce((max, line) => Math.max(max, Number(line?.blockId || 0)), 0);
|
||||||
|
const lineCount = lines.length;
|
||||||
|
const pageIndex = Number(source.pageIndex);
|
||||||
|
const key = Number.isFinite(pageIndex) ? pageIndex : side;
|
||||||
|
const nextVersion = Math.max(1, Number(this.pageContentVersions.get(key) || 0) + 1);
|
||||||
|
this.pageContentVersions.set(key, nextVersion);
|
||||||
|
meta[side] = {
|
||||||
|
...source,
|
||||||
|
contentVersion: nextVersion,
|
||||||
|
completenessScore: (maxBlockId * 1000) + lineCount,
|
||||||
|
maxBlockId,
|
||||||
|
lineCount
|
||||||
|
};
|
||||||
|
return meta;
|
||||||
|
}, {
|
||||||
|
left: Object.prototype.hasOwnProperty.call(baseMeta, 'left') ? baseMeta.left : null,
|
||||||
|
right: Object.prototype.hasOwnProperty.call(baseMeta, 'right') ? baseMeta.right : null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
cachePublishedPages(sides = [], detail = {}) {
|
cachePublishedPages(sides = [], detail = {}) {
|
||||||
if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return;
|
if (!this.pageCache || typeof this.pageCache.cachePageCanvas !== 'function') return;
|
||||||
sides.forEach((side) => {
|
sides.forEach((side) => {
|
||||||
@@ -919,10 +953,66 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
|
|
||||||
schedulePageCacheWrite(pageMeta, canvas) {
|
schedulePageCacheWrite(pageMeta, canvas) {
|
||||||
const frozenCanvas = this.cloneCanvas(canvas);
|
const frozenCanvas = this.cloneCanvas(canvas);
|
||||||
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 16));
|
const key = this.getPageCacheWriteKey(pageMeta, frozenCanvas);
|
||||||
scheduler(() => {
|
const pending = this.pendingPageCacheWrites.get(key);
|
||||||
this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas);
|
if (pending && this.isOlderPageMeta(pageMeta, pending.pageMeta)) return pending.promise;
|
||||||
}, { timeout: 250 });
|
const previousWrite = pending?.promise || Promise.resolve();
|
||||||
|
const write = previousWrite.catch(() => false).then(() => this.pageCache?.cachePageCanvas?.(pageMeta, frozenCanvas))
|
||||||
|
.then((stored) => {
|
||||||
|
if (!stored) {
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
|
||||||
|
detail: {
|
||||||
|
type: 'db-write-failed',
|
||||||
|
pageIndex: pageMeta?.pageIndex ?? null,
|
||||||
|
key
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
return stored;
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
document.dispatchEvent(new CustomEvent('webgl-book:page-cache-problem', {
|
||||||
|
detail: {
|
||||||
|
type: 'db-write-error',
|
||||||
|
pageIndex: pageMeta?.pageIndex ?? null,
|
||||||
|
key,
|
||||||
|
message: error?.message || String(error)
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (this.pendingPageCacheWrites.get(key)?.promise === write) {
|
||||||
|
this.pendingPageCacheWrites.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.pendingPageCacheWrites.set(key, {
|
||||||
|
promise: write,
|
||||||
|
pageMeta: { ...(pageMeta || {}) }
|
||||||
|
});
|
||||||
|
return write;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPageCacheWriteKey(pageMeta = {}, canvas = null) {
|
||||||
|
if (this.pageCache && typeof this.pageCache.makePageKey === 'function') {
|
||||||
|
return this.pageCache.makePageKey({
|
||||||
|
...pageMeta,
|
||||||
|
width: canvas?.width ?? pageMeta.width,
|
||||||
|
height: canvas?.height ?? pageMeta.height
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return `${pageMeta.cacheKey || window.MODULE_CACHE_BUSTER || 'dev'}:page:${pageMeta.pageIndex}:${canvas?.width || pageMeta.width}x${canvas?.height || pageMeta.height}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPageCanvas(side) {
|
getPageCanvas(side) {
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
if (!this.isProcessing) {
|
if (!this.isProcessing) {
|
||||||
this.processNextSentence();
|
this.processNextSentence();
|
||||||
} else {
|
} else {
|
||||||
this.prefetchAhead(4, this.queueGeneration);
|
this.prefetchAhead(6, this.queueGeneration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -204,14 +204,15 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
if (!this.isWebGLBookPresentationPrepared(sentence)) {
|
if (!this.isWebGLBookPresentationPrepared(sentence)) {
|
||||||
await this.prefetchWebGLBookPresentation(sentence, {
|
await this.prefetchWebGLBookPresentation(sentence, {
|
||||||
queueGeneration,
|
queueGeneration,
|
||||||
queueIndex: 0
|
queueIndex: 0,
|
||||||
|
immediate: true
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||||
|
|
||||||
// Prefetch far enough ahead that media pauses do not block TTS
|
// Prefetch far enough ahead that media pauses do not block TTS
|
||||||
// generation for the next spoken paragraph.
|
// generation for the next spoken paragraph.
|
||||||
this.prefetchAhead(4, queueGeneration);
|
this.prefetchAhead(6, queueGeneration);
|
||||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||||
|
|
||||||
// Notify display handler with complete sentence
|
// Notify display handler with complete sentence
|
||||||
@@ -910,10 +911,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
|
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise(resolve => {
|
if (!options.immediate) {
|
||||||
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
|
await new Promise(resolve => {
|
||||||
scheduler(() => resolve(), { timeout: 120 });
|
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
|
||||||
});
|
scheduler(() => resolve(), { timeout: 80 });
|
||||||
|
});
|
||||||
|
}
|
||||||
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
||||||
|
|
||||||
const spread = typeof bookPagination.preparePendingBlock === 'function'
|
const spread = typeof bookPagination.preparePendingBlock === 'function'
|
||||||
@@ -957,7 +960,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetchAhead(maxLookahead = 4, queueGeneration = this.queueGeneration) {
|
prefetchAhead(maxLookahead = 6, queueGeneration = this.queueGeneration) {
|
||||||
if (this.sentenceQueue.length <= 1) {
|
if (this.sentenceQueue.length <= 1) {
|
||||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||||
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
|
detail: { state: 'playing-ready', reason: 'no-prefetch-needed', sentenceId: this.sentenceQueue[0]?.id }
|
||||||
|
|||||||
+136
-25
@@ -157,7 +157,7 @@ updateCameraRig(0);
|
|||||||
configureScenePostprocessing();
|
configureScenePostprocessing();
|
||||||
|
|
||||||
const clock = new THREE.Clock();
|
const clock = new THREE.Clock();
|
||||||
const targetFrameDurationMs = 1000 / 30;
|
const targetFrameDurationMs = 1000 / 60;
|
||||||
let lastRenderFrameAt = 0;
|
let lastRenderFrameAt = 0;
|
||||||
let fpsDisplay = null;
|
let fpsDisplay = null;
|
||||||
let fpsWindowStartedAt = performance.now();
|
let fpsWindowStartedAt = performance.now();
|
||||||
@@ -253,13 +253,15 @@ const preparedPageTextures = {
|
|||||||
right: new Map()
|
right: new Map()
|
||||||
};
|
};
|
||||||
const residentPageTextures = new Map();
|
const residentPageTextures = new Map();
|
||||||
const maxResidentPageTextures = 18;
|
const maxResidentPageTextures = 192;
|
||||||
let blankPageTexture = null;
|
let blankPageTexture = null;
|
||||||
|
const pageCacheProblemLog = [];
|
||||||
let currentPageMeta = {
|
let currentPageMeta = {
|
||||||
left: null,
|
left: null,
|
||||||
right: null
|
right: null
|
||||||
};
|
};
|
||||||
let pendingRightPageFlip = false;
|
let pendingRightPageFlip = false;
|
||||||
|
let pendingRightPageFlipAutoplay = false;
|
||||||
const pageRevealState = {
|
const pageRevealState = {
|
||||||
left: null,
|
left: null,
|
||||||
right: null
|
right: null
|
||||||
@@ -575,6 +577,16 @@ window.BookLabDebug = {
|
|||||||
debug: getPageTextureDebugState()
|
debug: getPageTextureDebugState()
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
getRuntimeInvariants() {
|
||||||
|
return {
|
||||||
|
targetFrameDurationMs,
|
||||||
|
residentPageTextureCount: residentPageTextures.size,
|
||||||
|
maxResidentPageTextures,
|
||||||
|
pageCacheProblemCount: pageCacheProblemLog.length,
|
||||||
|
flipFrontBackShareMaterial: materials.flipPageSurface === materials.flipPageBackSurface,
|
||||||
|
mirrorRefreshesEveryFrame: true
|
||||||
|
};
|
||||||
|
},
|
||||||
projectPointerToPage(clientX, clientY) {
|
projectPointerToPage(clientX, clientY) {
|
||||||
return projectPointerToPage(clientX, clientY);
|
return projectPointerToPage(clientX, clientY);
|
||||||
},
|
},
|
||||||
@@ -596,6 +608,9 @@ document.addEventListener('webgl-book:page-reveal-fast-forward', (event) => {
|
|||||||
document.addEventListener('webgl-book:reveal-committed', (event) => {
|
document.addEventListener('webgl-book:reveal-committed', (event) => {
|
||||||
handleRevealCommittedForPageFlip(event.detail || {});
|
handleRevealCommittedForPageFlip(event.detail || {});
|
||||||
});
|
});
|
||||||
|
document.addEventListener('webgl-book:page-cache-problem', (event) => {
|
||||||
|
recordPageCacheProblem(event.detail || {});
|
||||||
|
});
|
||||||
document.addEventListener('book-pagination:spread-updated', (event) => {
|
document.addEventListener('book-pagination:spread-updated', (event) => {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
const previousPageCount = bookPageCount;
|
const previousPageCount = bookPageCount;
|
||||||
@@ -611,6 +626,7 @@ document.addEventListener('book-pagination:spread-updated', (event) => {
|
|||||||
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
window.WebGLBookPreferenceBridge?.updatePageCount?.(bookPageCount);
|
||||||
}
|
}
|
||||||
syncBookControls();
|
syncBookControls();
|
||||||
|
if (pendingRightPageFlip) tryStartPendingRightPageFlip('spread-updated');
|
||||||
});
|
});
|
||||||
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
document.addEventListener('webgl-book:page-reserve-directive', (event) => {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
@@ -634,8 +650,7 @@ document.addEventListener('webgl-book:request-page-flip', (event) => {
|
|||||||
});
|
});
|
||||||
document.addEventListener('ui:command', (event) => {
|
document.addEventListener('ui:command', (event) => {
|
||||||
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
if (event.detail?.type === 'continue' && pendingRightPageFlip) {
|
||||||
pendingRightPageFlip = false;
|
tryStartPendingRightPageFlip('continue', { force: true });
|
||||||
startPageFlip(1);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
installBookControls();
|
installBookControls();
|
||||||
@@ -1976,9 +1991,11 @@ function syncBottomNavigation() {
|
|||||||
function handlePageCanvases(event) {
|
function handlePageCanvases(event) {
|
||||||
const detail = event.detail || {};
|
const detail = event.detail || {};
|
||||||
if (detail.pageMeta) {
|
if (detail.pageMeta) {
|
||||||
|
const hasLeftMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'left');
|
||||||
|
const hasRightMeta = Object.prototype.hasOwnProperty.call(detail.pageMeta, 'right');
|
||||||
currentPageMeta = {
|
currentPageMeta = {
|
||||||
left: detail.pageMeta.left || currentPageMeta.left || null,
|
left: hasLeftMeta ? detail.pageMeta.left : currentPageMeta.left || null,
|
||||||
right: detail.pageMeta.right || currentPageMeta.right || null
|
right: hasRightMeta ? detail.pageMeta.right : currentPageMeta.right || null
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
markPageTextureTiming('handlePageCanvases:start', {
|
markPageTextureTiming('handlePageCanvases:start', {
|
||||||
@@ -1989,8 +2006,14 @@ function handlePageCanvases(event) {
|
|||||||
pageMeta: currentPageMeta
|
pageMeta: currentPageMeta
|
||||||
});
|
});
|
||||||
if (detail.preloadOnly) {
|
if (detail.preloadOnly) {
|
||||||
if (detail.left) preloadPageTexture('left', detail.left, detail.reveal?.left);
|
if (detail.left) {
|
||||||
if (detail.right) preloadPageTexture('right', detail.right, detail.reveal?.right);
|
const texture = preloadPageTexture('left', detail.left, detail.reveal?.left);
|
||||||
|
rememberResidentPageTexture(currentPageMeta.left, texture, detail.left);
|
||||||
|
}
|
||||||
|
if (detail.right) {
|
||||||
|
const texture = preloadPageTexture('right', detail.right, detail.reveal?.right);
|
||||||
|
rememberResidentPageTexture(currentPageMeta.right, texture, detail.right);
|
||||||
|
}
|
||||||
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
|
markPageTextureTiming('handlePageCanvases:preloadOnly:end');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2041,7 +2064,7 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
|
|||||||
revealDetail,
|
revealDetail,
|
||||||
uploadedAt: performance.now()
|
uploadedAt: performance.now()
|
||||||
});
|
});
|
||||||
if (preparedPageTextures[side].size > 12) {
|
if (preparedPageTextures[side].size > 128) {
|
||||||
const oldestKey = preparedPageTextures[side].keys().next().value;
|
const oldestKey = preparedPageTextures[side].keys().next().value;
|
||||||
const oldest = preparedPageTextures[side].get(oldestKey);
|
const oldest = preparedPageTextures[side].get(oldestKey);
|
||||||
oldest?.texture?.dispose?.();
|
oldest?.texture?.dispose?.();
|
||||||
@@ -2052,6 +2075,54 @@ function preloadPageTexture(side, sourceCanvas, revealDetail = {}) {
|
|||||||
return texture;
|
return texture;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordPageCacheProblem(detail = {}) {
|
||||||
|
const entry = {
|
||||||
|
...detail,
|
||||||
|
at: performance.now()
|
||||||
|
};
|
||||||
|
pageCacheProblemLog.push(entry);
|
||||||
|
if (pageCacheProblemLog.length > 80) pageCacheProblemLog.splice(0, pageCacheProblemLog.length - 80);
|
||||||
|
document.documentElement.dataset.webglPageCacheProblems = JSON.stringify(pageCacheProblemLog);
|
||||||
|
console.warn('WebGL page cache problem', entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rememberResidentPageTexture(pageMeta = null, texture = null, sourceCanvas = null, ownsTexture = true) {
|
||||||
|
const pageIndex = Number(pageMeta?.pageIndex);
|
||||||
|
if (!texture || !Number.isFinite(pageIndex) || pageIndex < 0) return null;
|
||||||
|
const key = makePageMetaForCache(pageIndex).pageIndex;
|
||||||
|
const existing = residentPageTextures.get(key);
|
||||||
|
if (isOlderPageTextureMeta(pageMeta, existing?.pageMeta)) return existing?.texture || null;
|
||||||
|
if (existing?.ownsTexture && existing.texture && existing.texture !== texture) existing.texture.dispose?.();
|
||||||
|
residentPageTextures.set(key, {
|
||||||
|
texture,
|
||||||
|
sourceCanvas: sourceCanvas || existing?.sourceCanvas || null,
|
||||||
|
lastUsedAt: performance.now(),
|
||||||
|
ownsTexture,
|
||||||
|
pageMeta: {
|
||||||
|
...(existing?.pageMeta || {}),
|
||||||
|
...(pageMeta || {})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
while (residentPageTextures.size > maxResidentPageTextures) {
|
||||||
|
const oldestKey = residentPageTextures.keys().next().value;
|
||||||
|
const oldest = residentPageTextures.get(oldestKey);
|
||||||
|
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
|
||||||
|
residentPageTextures.delete(oldestKey);
|
||||||
|
}
|
||||||
|
return texture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOlderPageTextureMeta(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;
|
||||||
|
}
|
||||||
|
|
||||||
function makePageMetaForCache(pageIndex) {
|
function makePageMetaForCache(pageIndex) {
|
||||||
return {
|
return {
|
||||||
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
|
pageIndex: Math.max(0, Math.round(Number(pageIndex || 0))),
|
||||||
@@ -2078,6 +2149,15 @@ function getResidentPageTexture(pageIndex) {
|
|||||||
return resident.texture || null;
|
return resident.texture || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getResidentPageTextureForMeta(pageMeta = null) {
|
||||||
|
const pageIndex = Number(pageMeta?.pageIndex);
|
||||||
|
if (!Number.isFinite(pageIndex)) return null;
|
||||||
|
const key = makePageMetaForCache(pageIndex).pageIndex;
|
||||||
|
const resident = residentPageTextures.get(key);
|
||||||
|
if (!resident || isOlderPageTextureMeta(pageMeta, resident.pageMeta)) return null;
|
||||||
|
return getResidentPageTexture(pageIndex);
|
||||||
|
}
|
||||||
|
|
||||||
async function preloadCachedPageTexture(pageIndex) {
|
async function preloadCachedPageTexture(pageIndex) {
|
||||||
const meta = makePageMetaForCache(pageIndex);
|
const meta = makePageMetaForCache(pageIndex);
|
||||||
if (residentPageTextures.has(meta.pageIndex)) {
|
if (residentPageTextures.has(meta.pageIndex)) {
|
||||||
@@ -2086,17 +2166,28 @@ async function preloadCachedPageTexture(pageIndex) {
|
|||||||
}
|
}
|
||||||
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
|
const cache = window.WebGLPageCache || window.moduleRegistry?.getModule?.('webgl-page-cache') || null;
|
||||||
const sourceCanvas = await cache?.getPageCanvas?.(meta);
|
const sourceCanvas = await cache?.getPageCanvas?.(meta);
|
||||||
if (!sourceCanvas) return null;
|
if (!sourceCanvas) {
|
||||||
|
recordPageCacheProblem({
|
||||||
|
type: 'db-cache-miss',
|
||||||
|
pageIndex: meta.pageIndex,
|
||||||
|
width: meta.width,
|
||||||
|
height: meta.height
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const texture = createPageCanvasTexture(sourceCanvas);
|
const texture = createPageCanvasTexture(sourceCanvas);
|
||||||
|
const cachedMeta = sourceCanvas.__webglPageCacheMeta || meta;
|
||||||
residentPageTextures.set(meta.pageIndex, {
|
residentPageTextures.set(meta.pageIndex, {
|
||||||
texture,
|
texture,
|
||||||
sourceCanvas,
|
sourceCanvas,
|
||||||
lastUsedAt: performance.now()
|
lastUsedAt: performance.now(),
|
||||||
|
ownsTexture: true,
|
||||||
|
pageMeta: cachedMeta
|
||||||
});
|
});
|
||||||
while (residentPageTextures.size > maxResidentPageTextures) {
|
while (residentPageTextures.size > maxResidentPageTextures) {
|
||||||
const oldestKey = residentPageTextures.keys().next().value;
|
const oldestKey = residentPageTextures.keys().next().value;
|
||||||
const oldest = residentPageTextures.get(oldestKey);
|
const oldest = residentPageTextures.get(oldestKey);
|
||||||
oldest?.texture?.dispose?.();
|
if (oldest?.ownsTexture) oldest.texture?.dispose?.();
|
||||||
residentPageTextures.delete(oldestKey);
|
residentPageTextures.delete(oldestKey);
|
||||||
}
|
}
|
||||||
return texture;
|
return texture;
|
||||||
@@ -2134,7 +2225,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
|
|||||||
const texture = side === 'left' ? leftTexture : rightTexture;
|
const texture = side === 'left' ? leftTexture : rightTexture;
|
||||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex))
|
const residentTexture = Number.isFinite(Number(pageMeta?.pageIndex))
|
||||||
? getResidentPageTexture(pageMeta.pageIndex)
|
? getResidentPageTextureForMeta(pageMeta)
|
||||||
: null;
|
: null;
|
||||||
markPageTextureTiming('directUpload:start', {
|
markPageTextureTiming('directUpload:start', {
|
||||||
side,
|
side,
|
||||||
@@ -2155,6 +2246,7 @@ function uploadPageTextureDirect(side, sourceCanvas, pageMeta = null) {
|
|||||||
material.needsUpdate = true;
|
material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
bindPageTextureSource(side, texture, sourceCanvas);
|
bindPageTextureSource(side, texture, sourceCanvas);
|
||||||
|
rememberResidentPageTexture(pageMeta, texture, sourceCanvas, false);
|
||||||
markPageTextureTiming('directUpload:end', { side });
|
markPageTextureTiming('directUpload:end', { side });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2516,10 +2608,11 @@ async function startPageFlip(direction, options = {}) {
|
|||||||
function startPageFlipPrepared(direction, options = {}) {
|
function startPageFlipPrepared(direction, options = {}) {
|
||||||
if (activeFlips.length || !currentProceduralBookModel) return false;
|
if (activeFlips.length || !currentProceduralBookModel) return false;
|
||||||
if (!options.force && !canPageFlip(direction)) return false;
|
if (!options.force && !canPageFlip(direction)) return false;
|
||||||
pendingRightPageFlip = false;
|
|
||||||
delete document.documentElement.dataset.webglPendingPageFlip;
|
|
||||||
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
const flip = createPageFlip(direction, performance.now(), normalFlipDuration);
|
||||||
if (!flip) return false;
|
if (!flip) return false;
|
||||||
|
pendingRightPageFlip = false;
|
||||||
|
pendingRightPageFlipAutoplay = false;
|
||||||
|
delete document.documentElement.dataset.webglPendingPageFlip;
|
||||||
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
flip.targetSpread = Number.isFinite(Number(options.targetSpread)) ? Math.max(0, Math.round(Number(options.targetSpread))) : null;
|
||||||
prepareStaticPageForFlip(flip);
|
prepareStaticPageForFlip(flip);
|
||||||
activeFlips.push(flip);
|
activeFlips.push(flip);
|
||||||
@@ -2586,14 +2679,13 @@ function createPageFlip(direction, startTime, duration) {
|
|||||||
function prepareStaticPageForFlip(flip) {
|
function prepareStaticPageForFlip(flip) {
|
||||||
if (!flip) return;
|
if (!flip) return;
|
||||||
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
|
const sourceMaterial = flip.sourcePageSide === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
const oppositeMaterial = flip.sourcePageSide === 'left' ? materials.rightPage : materials.leftPage;
|
|
||||||
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
|
const sourceTexture = sourceMaterial?.map || (flip.sourcePageSide === 'left' ? leftTexture : rightTexture);
|
||||||
const targetSpread = Number.isFinite(Number(flip.targetSpread))
|
const targetSpread = Number.isFinite(Number(flip.targetSpread))
|
||||||
? Math.max(0, Math.round(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)));
|
: Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)) + Math.sign(Number(flip.direction || 0)));
|
||||||
const targetPages = spreadPageIndices(targetSpread);
|
const targetPages = spreadPageIndices(targetSpread);
|
||||||
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
|
const targetBackPageIndex = flip.direction > 0 ? targetPages.left : targetPages.right;
|
||||||
const backTexture = getResidentPageTexture(targetBackPageIndex) || oppositeMaterial?.map || getBlankPageTexture();
|
const backTexture = getResidentPageTexture(targetBackPageIndex) || getBlankPageTexture();
|
||||||
materials.flipPageSurface.map = sourceTexture;
|
materials.flipPageSurface.map = sourceTexture;
|
||||||
materials.flipPageBackSurface.map = backTexture;
|
materials.flipPageBackSurface.map = backTexture;
|
||||||
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
materials.flipPageSurface.normalMap = materials.pageSurface.normalMap;
|
||||||
@@ -2604,6 +2696,7 @@ function prepareStaticPageForFlip(flip) {
|
|||||||
materials.flipPageBackSurface.needsUpdate = true;
|
materials.flipPageBackSurface.needsUpdate = true;
|
||||||
flip.sourceTexture = sourceTexture;
|
flip.sourceTexture = sourceTexture;
|
||||||
flip.backTexture = backTexture;
|
flip.backTexture = backTexture;
|
||||||
|
flip.targetBackPageIndex = targetBackPageIndex;
|
||||||
if (flip.direction > 0) {
|
if (flip.direction > 0) {
|
||||||
const blankTexture = getBlankPageTexture();
|
const blankTexture = getBlankPageTexture();
|
||||||
if (blankTexture && materials.rightPage.map !== blankTexture) {
|
if (blankTexture && materials.rightPage.map !== blankTexture) {
|
||||||
@@ -2633,12 +2726,30 @@ function handleRevealCommittedForPageFlip(detail = {}) {
|
|||||||
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
|
if (detail.side !== 'right' || !isRightBodyPageComplete()) return;
|
||||||
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
if (activeFlips.length > 0 || pendingRightPageFlip) return;
|
||||||
if (isChoiceAwaitingPlayer()) return;
|
if (isChoiceAwaitingPlayer()) return;
|
||||||
if (isTtsPlaybackActive()) {
|
const autoplayFlip = isTtsPlaybackActive();
|
||||||
startPageFlip(1);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
pendingRightPageFlip = true;
|
pendingRightPageFlip = true;
|
||||||
|
pendingRightPageFlipAutoplay = autoplayFlip;
|
||||||
document.documentElement.dataset.webglPendingPageFlip = 'right';
|
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 isRightBodyPageComplete() {
|
function isRightBodyPageComplete() {
|
||||||
@@ -2853,7 +2964,7 @@ function createFlippingPageGeometry(surface) {
|
|||||||
rowPoints.forEach((point, depthIndex) => {
|
rowPoints.forEach((point, depthIndex) => {
|
||||||
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
|
||||||
topRow.push(push(point, pageThickness, u, v));
|
topRow.push(push(point, pageThickness, u, v));
|
||||||
bottomRow.push(push(point, 0, u, v));
|
bottomRow.push(push(point, 0, u, 1 - v));
|
||||||
});
|
});
|
||||||
topGrid.push(topRow);
|
topGrid.push(topRow);
|
||||||
bottomGrid.push(bottomRow);
|
bottomGrid.push(bottomRow);
|
||||||
@@ -3961,12 +4072,12 @@ function renderMirrorDebugView() {
|
|||||||
function animate(now = performance.now()) {
|
function animate(now = performance.now()) {
|
||||||
const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs;
|
const elapsedSinceLastFrame = lastRenderFrameAt ? now - lastRenderFrameAt : targetFrameDurationMs;
|
||||||
if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) {
|
if (lastRenderFrameAt && elapsedSinceLastFrame < targetFrameDurationMs) {
|
||||||
setTimeout(animate, Math.max(1, targetFrameDurationMs - elapsedSinceLastFrame));
|
requestAnimationFrame(animate);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs;
|
const frameElapsedMs = lastRenderFrameAt ? elapsedSinceLastFrame : targetFrameDurationMs;
|
||||||
lastRenderFrameAt = now;
|
lastRenderFrameAt = now;
|
||||||
setTimeout(animate, targetFrameDurationMs);
|
requestAnimationFrame(animate);
|
||||||
const delta = Math.min(0.1, frameElapsedMs / 1000);
|
const delta = Math.min(0.1, frameElapsedMs / 1000);
|
||||||
clock.getDelta();
|
clock.getDelta();
|
||||||
const t = clock.elapsedTime;
|
const t = clock.elapsedTime;
|
||||||
@@ -4008,7 +4119,7 @@ function animate(now = performance.now()) {
|
|||||||
updateBookShadowMaps();
|
updateBookShadowMaps();
|
||||||
lastFrameTiming.shadows = performance.now() - shadowStartedAt;
|
lastFrameTiming.shadows = performance.now() - shadowStartedAt;
|
||||||
const reflectionStartedAt = performance.now();
|
const reflectionStartedAt = performance.now();
|
||||||
const refreshStaticSceneBuffers = staticSceneBuffersDirty || activeFlips.length > 0;
|
const refreshStaticSceneBuffers = true;
|
||||||
if (refreshStaticSceneBuffers) {
|
if (refreshStaticSceneBuffers) {
|
||||||
updateTableReflection();
|
updateTableReflection();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
this.db = null;
|
this.db = null;
|
||||||
this.cacheStatus = 'uninitialized';
|
this.cacheStatus = 'uninitialized';
|
||||||
this.currentCacheSize = 0;
|
this.currentCacheSize = 0;
|
||||||
this.maxCacheSizeBytes = 180 * 1024 * 1024;
|
this.maxCacheSizeBytes = 5 * 1024 * 1024 * 1024;
|
||||||
this.memoryCanvasCache = new Map();
|
this.memoryCanvasCache = new Map();
|
||||||
this.maxMemoryCanvasCount = 12;
|
this.maxMemoryCanvasCount = 256;
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
'initialize',
|
'initialize',
|
||||||
@@ -27,6 +27,7 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
'makePageKey',
|
'makePageKey',
|
||||||
'canvasToBlob',
|
'canvasToBlob',
|
||||||
'blobToCanvas',
|
'blobToCanvas',
|
||||||
|
'isOlderPageEntry',
|
||||||
'manageCacheSize',
|
'manageCacheSize',
|
||||||
'calculateTotalCacheSize',
|
'calculateTotalCacheSize',
|
||||||
'deleteEntry',
|
'deleteEntry',
|
||||||
@@ -45,7 +46,7 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
this.reportProgress(100, 'WebGL page texture cache ready');
|
this.reportProgress(100, 'WebGL page texture cache ready');
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('WebGLPageCache: IndexedDB unavailable, continuing without persistent page cache', error);
|
console.error('WebGLPageCache: IndexedDB unavailable; persistent page caching is in a problem state', error);
|
||||||
this.cacheStatus = 'error';
|
this.cacheStatus = 'error';
|
||||||
this.reportProgress(100, 'WebGL page texture cache unavailable');
|
this.reportProgress(100, 'WebGL page texture cache unavailable');
|
||||||
return true;
|
return true;
|
||||||
@@ -100,7 +101,6 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
cacheKey: pageMeta.cacheKey
|
cacheKey: pageMeta.cacheKey
|
||||||
});
|
});
|
||||||
if (this.memoryCanvasCache.has(key)) return true;
|
|
||||||
try {
|
try {
|
||||||
const blob = await this.canvasToBlob(canvas);
|
const blob = await this.canvasToBlob(canvas);
|
||||||
if (!blob) return false;
|
if (!blob) return false;
|
||||||
@@ -109,6 +109,7 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
request.onsuccess = () => resolve(request.result || null);
|
request.onsuccess = () => resolve(request.result || null);
|
||||||
request.onerror = () => reject(request.error);
|
request.onerror = () => reject(request.error);
|
||||||
});
|
});
|
||||||
|
if (this.isOlderPageEntry(pageMeta, oldEntry)) return true;
|
||||||
await this.manageCacheSize(blob.size);
|
await this.manageCacheSize(blob.size);
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
const request = this.tx('readwrite').put({
|
const request = this.tx('readwrite').put({
|
||||||
@@ -116,6 +117,10 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
pageIndex,
|
pageIndex,
|
||||||
width: canvas.width,
|
width: canvas.width,
|
||||||
height: canvas.height,
|
height: canvas.height,
|
||||||
|
contentVersion: Math.max(0, Number(pageMeta.contentVersion || 0)),
|
||||||
|
completenessScore: Math.max(0, Number(pageMeta.completenessScore || 0)),
|
||||||
|
maxBlockId: Math.max(0, Number(pageMeta.maxBlockId || 0)),
|
||||||
|
lineCount: Math.max(0, Number(pageMeta.lineCount || 0)),
|
||||||
blob,
|
blob,
|
||||||
size: blob.size,
|
size: blob.size,
|
||||||
lastAccessed: Date.now()
|
lastAccessed: Date.now()
|
||||||
@@ -161,6 +166,13 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
if (!entry?.blob) return null;
|
if (!entry?.blob) return null;
|
||||||
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
const canvas = await this.blobToCanvas(entry.blob, entry.width, entry.height);
|
||||||
|
if (canvas) canvas.__webglPageCacheMeta = {
|
||||||
|
pageIndex: entry.pageIndex,
|
||||||
|
contentVersion: entry.contentVersion,
|
||||||
|
completenessScore: entry.completenessScore,
|
||||||
|
maxBlockId: entry.maxBlockId,
|
||||||
|
lineCount: entry.lineCount
|
||||||
|
};
|
||||||
if (canvas) this.rememberCanvas(key, canvas);
|
if (canvas) this.rememberCanvas(key, canvas);
|
||||||
return canvas;
|
return canvas;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -169,6 +181,17 @@ class WebGLPageCacheModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
canvasToBlob(canvas) {
|
canvasToBlob(canvas) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (typeof canvas.toBlob !== 'function') {
|
if (typeof canvas.toBlob !== 'function') {
|
||||||
|
|||||||
@@ -132,18 +132,24 @@ const checks = [
|
|||||||
['texture renderer diagnostics include reveal word counts', /wordCounts/.test(textureRendererSource) && /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource)],
|
['texture renderer diagnostics include reveal word counts', /wordCounts/.test(textureRendererSource) && /revealWords/.test(textureRendererSource) && /wordRects/.test(textureRendererSource)],
|
||||||
['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)],
|
['texture renderer does not draw immediate non-pending sides during pending reveal preparation', !/immediateSides/.test(textureRendererSource) && !/this\.drawSpread\(this\.currentSpread, immediateSides\)/.test(textureRendererSource)],
|
||||||
['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)],
|
['sentence queue consumes completed prepared lookahead items', /preparedSentenceCache/.test(sentenceQueueSource) && /this\.preparedSentenceCache\.get\(cacheKey\)/.test(sentenceQueueSource) && /return prefetched/.test(sentenceQueueSource)],
|
||||||
['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*this\.prefetchAhead\(4, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)],
|
['sentence queue front-loads 3D book presentation before playback callback', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)],
|
||||||
['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)],
|
['sentence queue prefetch prepares whole future sentence instead of speech metadata only', /this\.prepareSentence\(nextItem, \{\s*blocking: false/.test(sentenceQueueSource) && /this\.prefetchWebGLBookPresentation\(prepared/.test(sentenceQueueSource)],
|
||||||
['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(4, this\.queueGeneration\);/.test(sentenceQueueSource)],
|
['sentence queue starts lookahead when items arrive during playback', /else \{\s*this\.prefetchAhead\(6, this\.queueGeneration\);/.test(sentenceQueueSource)],
|
||||||
|
['sentence queue keeps current 3D page prep immediate while future lookahead yields cooperatively', /if \(!options\.immediate\) \{[\s\S]*requestIdleCallback[\s\S]*timeout: 80/.test(sentenceQueueSource) && /prefetchAhead\(maxLookahead = 6/.test(sentenceQueueSource)],
|
||||||
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
|
['pagination can prepare future spreads without activating visible spread', /preparePendingBlock\(block = \{\}, options = \{\}\)/.test(bookPaginationSource) && /options\.activate !== false/.test(bookPaginationSource) && /includeUnrenderedHistory/.test(bookPaginationSource)],
|
||||||
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
|
['pagination preserves active inline style tags for texture lines', /getActiveStyleTags/.test(bookPaginationSource) && /activeStyleTags/.test(bookPaginationSource) && /updateStyleTagStack/.test(bookPaginationSource)],
|
||||||
['texture renderer caches preload-only reveal canvases for later reuse', /preparedRevealCache/.test(textureRendererSource) && /preloadOnly/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /reusedPreparedCanvas/.test(textureRendererSource) && /hasPreparedRevealBlock/.test(textureRendererSource)],
|
['texture renderer caches preload-only reveal canvases for later reuse', /preparedRevealCache/.test(textureRendererSource) && /preloadOnly/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && /reusedPreparedCanvas/.test(textureRendererSource) && /hasPreparedRevealBlock/.test(textureRendererSource)],
|
||||||
['webgl page cache is loaded through module infrastructure', /webgl-page-cache-module\.js/.test(loaderSource) && /super\('webgl-page-cache'/.test(webglPageCacheSource) && /reportProgress\(20, 'Opening WebGL page texture cache'\)/.test(webglPageCacheSource)],
|
['webgl page cache is loaded through module infrastructure', /webgl-page-cache-module\.js/.test(loaderSource) && /super\('webgl-page-cache'/.test(webglPageCacheSource) && /reportProgress\(20, 'Opening WebGL page texture cache'\)/.test(webglPageCacheSource)],
|
||||||
['webgl page cache uses an isolated browser database without upgrading tts history state', /this\.dbName = 'webglPageTextureCacheDB'/.test(webglPageCacheSource) && /this\.dbVersion = 1/.test(webglPageCacheSource) && /this\.dbVersion = 3/.test(ttsFactorySource) && /this\.dbVersion = 3/.test(storyHistorySource) && !/webglPageTextureStore/.test(ttsFactorySource) && !/webglPageTextureStore/.test(storyHistorySource)],
|
['webgl page cache uses an isolated browser database without upgrading tts history state', /this\.dbName = 'webglPageTextureCacheDB'/.test(webglPageCacheSource) && /this\.dbVersion = 1/.test(webglPageCacheSource) && /this\.dbVersion = 3/.test(ttsFactorySource) && /this\.dbVersion = 3/.test(storyHistorySource) && !/webglPageTextureStore/.test(ttsFactorySource) && !/webglPageTextureStore/.test(storyHistorySource)],
|
||||||
['texture renderer persists frozen completed page canvases without blocking publish', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /schedulePageCacheWrite/.test(textureRendererSource) && /const frozenCanvas = this\.cloneCanvas\(canvas\)/.test(textureRendererSource) && /requestIdleCallback/.test(textureRendererSource) && /cachePageCanvas/.test(textureRendererSource)],
|
['texture renderer persists frozen completed page canvases immediately and without duplicate work', /webgl-page-cache/.test(textureRendererSource) && /cachePublishedPages/.test(textureRendererSource) && /schedulePageCacheWrite/.test(textureRendererSource) && /pendingPageCacheWrites/.test(textureRendererSource) && /const frozenCanvas = this\.cloneCanvas\(canvas\)/.test(textureRendererSource) && !/requestIdleCallback/.test(methodBody(textureRendererSource, 'schedulePageCacheWrite')) && /cachePageCanvas/.test(textureRendererSource)],
|
||||||
['webgl lab prewarms cached page textures into bounded vram before flips', /residentPageTextures/.test(source) && /maxResidentPageTextures/.test(source) && /preloadCachedPageTexture/.test(source) && /prewarmFlipTextures/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /getResidentPageTexture\(targetBackPageIndex\)/.test(source)],
|
['webgl cache is non-optional with a 5gb persistent budget and large memory cache', /maxCacheSizeBytes = 5 \* 1024 \* 1024 \* 1024/.test(webglPageCacheSource) && /maxMemoryCanvasCount = 256/.test(webglPageCacheSource) && /persistent page caching is in a problem state/.test(webglPageCacheSource) && !/if \(this\.memoryCanvasCache\.has\(key\)\) return true/.test(webglPageCacheSource)],
|
||||||
['webgl lab reuses resident cached page textures for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && /getResidentPageTexture\(pageMeta\.pageIndex\)/.test(source) && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, currentPageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, currentPageMeta\.right\)/.test(source)],
|
['webgl lab prewarms cached page textures into generous vram before flips', /residentPageTextures/.test(source) && /const maxResidentPageTextures = 192/.test(source) && /preloadCachedPageTexture/.test(source) && /prewarmFlipTextures/.test(source) && /await prewarmFlipTextures\(direction, targetSpread\)/.test(source) && /getResidentPageTexture\(targetBackPageIndex\)/.test(source)],
|
||||||
|
['webgl lab records cache misses as problem states', /pageCacheProblemLog/.test(source) && /recordPageCacheProblem/.test(source) && /db-cache-miss/.test(source) && /webglPageCacheProblems/.test(source)],
|
||||||
|
['webgl lab makes preload-only page canvases resident in vram immediately', /rememberResidentPageTexture/.test(source) && /if \(detail\.preloadOnly\) \{[\s\S]*rememberResidentPageTexture\(currentPageMeta\.left, texture, detail\.left\)[\s\S]*rememberResidentPageTexture\(currentPageMeta\.right, texture, detail\.right\)/.test(source)],
|
||||||
|
['webgl lab keeps current visible page textures resident without disposing shared maps', /rememberResidentPageTexture\(pageMeta, texture, sourceCanvas, false\)/.test(source) && /ownsTexture/.test(source) && /if \(oldest\?\.ownsTexture\) oldest\.texture\?\.dispose\?\.\(\)/.test(source)],
|
||||||
|
['webgl lab reuses current-enough resident cached page textures for direct stack switches', /uploadPageTextureDirect\(side, sourceCanvas, pageMeta = null\)/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source) && /usedResidentTexture/.test(source) && /uploadPageTextureDirect\('left', detail\.left, currentPageMeta\.left\)/.test(source) && /uploadPageTextureDirect\('right', detail\.right, currentPageMeta\.right\)/.test(source)],
|
||||||
['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.test(webglPageCacheSource)],
|
['webgl page cache preserves explicit cache keys across writes and reads', /cacheKey: pageMeta\.cacheKey/.test(webglPageCacheSource) && /makePageKey\(pageMeta\)/.test(webglPageCacheSource)],
|
||||||
|
['webgl page cache rejects older page versions for the same page key', /isOlderPageEntry/.test(webglPageCacheSource) && /contentVersion/.test(webglPageCacheSource) && /completenessScore/.test(webglPageCacheSource) && /if \(this\.isOlderPageEntry\(pageMeta, oldEntry\)\) return true/.test(webglPageCacheSource)],
|
||||||
['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)],
|
['targeted page flips commit target spread before emitting finished event', /bookPaginationState = \{[\s\S]*spreadIndex: Math\.max\(0, Math\.round\(Number\(flip\.targetSpread\)\)\)[\s\S]*document\.dispatchEvent\(new CustomEvent\('webgl-book:page-flip-finished'/.test(source) && /targetSpread: Number\.isFinite\(Number\(flip\.targetSpread\)\)/.test(source)],
|
||||||
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
|
['webgl debug test hook awaits the same async page flip path', /startPageFlipForTest\(direction, options = \{\}\) \{[\s\S]*return startPageFlip\(direction, options\)/.test(source)],
|
||||||
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
|
['webgl debug test hook can deterministically finish an active page flip', /advancePageFlipForTest\(elapsedMs = normalFlipDuration \+ 16\)/.test(source) && /updateActiveFlips\(targetNow\)/.test(source)],
|
||||||
@@ -167,10 +173,21 @@ const checks = [
|
|||||||
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
|
['webgl book starts at progress zero', /const DEFAULT_BOOK_PROGRESS = 0;/.test(webglSceneSource) && /appInitialState\.progress \?\? '0'/.test(source)],
|
||||||
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource)],
|
['pagination opens with blank left and title right spread', /this\.createBlankPage\(0, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource) && /this\.createTitlePage\(1\)/.test(bookPaginationSource) && /this\.createBlankPage\(2, \{ section: 'frontmatter' \}\)/.test(bookPaginationSource)],
|
||||||
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
['pagination publishes page metadata and advances near the end of physical flips', /pageMeta/.test(bookPaginationSource) && /webgl-book:page-flip-near-end/.test(bookPaginationSource) && /this\.setCurrentSpread\(this\.currentSpreadIndex \+ direction\)/.test(bookPaginationSource)],
|
||||||
['texture renderer draws title page and page numbers from page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.currentSpread\?\.pageMeta/.test(textureRendererSource)],
|
['texture renderer draws title page and page numbers from versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
||||||
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
['texture renderer uses plural page margin metrics for page numbers', /this\.metrics\.margins\.bottom/.test(textureRendererSource) && !/this\.metrics\.margin\.bottom/.test(textureRendererSource)],
|
||||||
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
|
['webgl flip borrows resident page texture and blanks right stack before forward animation', /prepareStaticPageForFlip/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.rightPage\.map = blankTexture/.test(source) && /webgl-book:page-flip-near-end/.test(source)],
|
||||||
|
['webgl flip never falls back to the opposite visible stack for target back texture', /const backTexture = getResidentPageTexture\(targetBackPageIndex\) \|\| getBlankPageTexture\(\)/.test(source) && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||||
|
['webgl page canvas metadata accepts explicit blank sides instead of retaining stale pages', /hasLeftMeta/.test(source) && /hasRightMeta/.test(source) && /Object\.prototype\.hasOwnProperty\.call\(detail\.pageMeta, 'right'\)/.test(source)],
|
||||||
|
['texture renderer publishes both spread sides for reveal preparation and fallback start', /this\.drawSpread\(this\.currentSpread \|\| this\.pagination\?\.getCurrentSpread\?\.\(\), \['left', 'right'\]\)/.test(textureRendererSource) && /const sides = \['left', 'right'\]/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
||||||
|
['texture renderer marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||||
|
['texture renderer queues newer same-page cache writes instead of dropping them', /isOlderPageMeta/.test(textureRendererSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(textureRendererSource) && /pendingPageCacheWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(textureRendererSource)],
|
||||||
|
['webgl resident page texture cache rejects older page versions before direct reuse', /isOlderPageTextureMeta/.test(source) && /getResidentPageTextureForMeta/.test(source) && /getResidentPageTextureForMeta\(pageMeta\)/.test(source)],
|
||||||
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.test(source)],
|
['webgl flip page is a two-sided body using paper-thickness constants', /flipPageBackSurface/.test(source) && /flipPageEdge/.test(source) && /new THREE\.Mesh\(geometry, \[\s*materials\.flipPageSurface,\s*materials\.flipPageBackSurface,\s*materials\.flipPageEdge\s*\]\)/.test(source) && /PROCEDURAL_BOOK\.SHEET_THICKNESS_MODEL/.test(source) && /geometry\.addGroup\(0, topIndices\.length, 0\)/.test(source)],
|
||||||
|
['webgl animated page front and back maps are independently switchable before animation starts', /materials\.flipPageBackSurface = materials\.flipPageSurface\.clone\(\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backTexture/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture/.test(source)],
|
||||||
|
['webgl animated page back face uses its own unflipped page orientation', /bottomRow\.push\(push\(point, 0, u, 1 - v\)\)/.test(source)],
|
||||||
|
['webgl scene targets 60fps with browser-frame scheduling and live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /this\.targetFrameDurationMs = 1000 \/ 60/.test(textureRendererSource) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = true/.test(source) && !/setTimeout\(animate/.test(source)],
|
||||||
|
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesEveryFrame: true/.test(source)],
|
||||||
|
['book pagination reloads to the continuation block spread when unrendered history exists', /getContinuationBlockId/.test(bookPaginationSource) && /const continuationBlockId = this\.getContinuationBlockId\(latestBlockId, latestRenderedBlockId\)/.test(bookPaginationSource) && /const continuationSpreadIndex = this\.findSpreadIndexForBlock\(continuationBlockId\)/.test(bookPaginationSource) && /rendered < latest \? rendered \+ 1 : latest/.test(bookPaginationSource)],
|
||||||
['webgl page navigation is page-count based with explicit spread mapping', /function pageToSpreadIndex/.test(source) && /Math\.floor\(page \/ 2\) \+ 1/.test(source) && /function spreadIndexToPagePosition/.test(source) && /\(spread - 1\) \* 2/.test(source)],
|
['webgl page navigation is page-count based with explicit spread mapping', /function pageToSpreadIndex/.test(source) && /Math\.floor\(page \/ 2\) \+ 1/.test(source) && /function spreadIndexToPagePosition/.test(source) && /\(spread - 1\) \* 2/.test(source)],
|
||||||
['webgl reading progress sync does not rebuild pagination as a page-count change', /function syncReadingProgressToCurrentPage/.test(source) && !/notifyBookPageCountChanged/.test(methodBody(source, 'syncReadingProgressToCurrentPage'))],
|
['webgl reading progress sync does not rebuild pagination as a page-count change', /function syncReadingProgressToCurrentPage/.test(source) && !/notifyBookPageCountChanged/.test(methodBody(source, 'syncReadingProgressToCurrentPage'))],
|
||||||
['webgl page reserve grows book size without shrinking', /function growBookIfWritableLimitReached/.test(source) && /bookPageCount < PROCEDURAL_BOOK\.PAGE_COUNT_MAX/.test(source) && /snapProceduralPageCount\(bookPageCount \+ PROCEDURAL_BOOK\.PAGE_COUNT_STEP\)/.test(source) && /bookPageCount = Math\.max\(nextPageCount, bookPageCount\)/.test(source)],
|
['webgl page reserve grows book size without shrinking', /function growBookIfWritableLimitReached/.test(source) && /bookPageCount < PROCEDURAL_BOOK\.PAGE_COUNT_MAX/.test(source) && /snapProceduralPageCount\(bookPageCount \+ PROCEDURAL_BOOK\.PAGE_COUNT_STEP\)/.test(source) && /bookPageCount = Math\.max\(nextPageCount, bookPageCount\)/.test(source)],
|
||||||
@@ -179,7 +196,7 @@ const checks = [
|
|||||||
['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)],
|
['webgl page reserve persists with sane defaults', /bookPageCount: 300/.test(persistenceSource) && /bookProgress: 0/.test(persistenceSource) && /pageReserve: 50/.test(persistenceSource)],
|
||||||
['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)],
|
['markup parser strips and stores pagereserve directives', /parsePageReserveDirective/.test(markupParserSource) && /#pagereserve\\\[/.test(markupParserSource) && /unit: match\[2\] === '%' \? 'percent' : 'pages'/.test(markupParserSource)],
|
||||||
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
|
['game loop persists webgl book state in save slots', /webglBookState: this\.getWebGLBookState\(\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8')) && /applyWebGLBookState\(browserSave\.webglBookState\)/.test(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'game-loop-module.js'), 'utf8'))],
|
||||||
['webgl right-page completion arms a flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /isRightBodyPageComplete/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip/.test(source)],
|
['webgl right-page completion arms a durable autoplay-targeted flip without bypassing choices', /handleRevealCommittedForPageFlip/.test(source) && /tryStartPendingRightPageFlip/.test(source) && /pendingRightPageFlipAutoplay/.test(source) && /const targetSpread = Math\.max\(0, Math\.round\(Number\(bookPaginationState\.spreadIndex \|\| 0\)\) \+ 1\)/.test(source) && /force: options\.force === true \|\| pendingRightPageFlipAutoplay/.test(source) && /isChoiceAwaitingPlayer/.test(source) && /pendingRightPageFlip = true/.test(source)],
|
||||||
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
|
['markup and 3d pagination accept full-page images', /'full'/.test(markupParserSource) && /size === 'full'/.test(bookPaginationSource)],
|
||||||
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
|
['story history can persist 3d pagination decisions', /persistPaginationMetrics/.test(bookPaginationSource) && /collectPaginationMetrics/.test(bookPaginationSource) && /pageStart/.test(storyHistorySource) && /pagination: metrics\.pagination/.test(storyHistorySource)]
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ async function main() {
|
|||||||
const minLabel = document.getElementById('webgl_book_nav_min_label');
|
const minLabel = document.getElementById('webgl_book_nav_min_label');
|
||||||
const maxLabel = document.getElementById('webgl_book_nav_max_label');
|
const maxLabel = document.getElementById('webgl_book_nav_max_label');
|
||||||
const textureInfo = window.BookLabDebug.getTextureInfo();
|
const textureInfo = window.BookLabDebug.getTextureInfo();
|
||||||
|
const runtimeInvariants = window.BookLabDebug.getRuntimeInvariants?.() || {};
|
||||||
const initialBookState = window.BookLabDebug.getBookState();
|
const initialBookState = window.BookLabDebug.getBookState();
|
||||||
const initialSliderMax = slider?.max || null;
|
const initialSliderMax = slider?.max || null;
|
||||||
const initialMinLabel = minLabel?.textContent || '';
|
const initialMinLabel = minLabel?.textContent || '';
|
||||||
@@ -56,6 +57,12 @@ async function main() {
|
|||||||
spreadCount: 8,
|
spreadCount: 8,
|
||||||
writtenPageLimit: 10
|
writtenPageLimit: 10
|
||||||
});
|
});
|
||||||
|
const initialNavigationDisabled = {
|
||||||
|
topBackward: Boolean(document.getElementById('flip_backward')?.disabled),
|
||||||
|
topFastBackward: Boolean(document.getElementById('fast_flip_backward')?.disabled),
|
||||||
|
bottomStart: Boolean(document.getElementById('webgl_book_nav_start')?.disabled),
|
||||||
|
bottomBack: Boolean(document.getElementById('webgl_book_nav_back')?.disabled)
|
||||||
|
};
|
||||||
slider.value = '100';
|
slider.value = '100';
|
||||||
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
slider.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
@@ -158,9 +165,21 @@ async function main() {
|
|||||||
new Promise(resolve => window.setTimeout(() => resolve(false), 5000))
|
new Promise(resolve => window.setTimeout(() => resolve(false), 5000))
|
||||||
]);
|
]);
|
||||||
const postTargetFlipState = window.BookLabDebug.getBookState();
|
const postTargetFlipState = window.BookLabDebug.getBookState();
|
||||||
|
window.BookLabDebug.setPaginationStateForTest({
|
||||||
|
spreadIndex: 5,
|
||||||
|
spreadCount: 8,
|
||||||
|
writtenPageLimit: 10
|
||||||
|
});
|
||||||
|
const endNavigationDisabled = {
|
||||||
|
topForward: Boolean(document.getElementById('flip_forward')?.disabled),
|
||||||
|
topFastForward: Boolean(document.getElementById('fast_flip_forward')?.disabled),
|
||||||
|
bottomForward: Boolean(document.getElementById('webgl_book_nav_forward')?.disabled),
|
||||||
|
bottomEnd: Boolean(document.getElementById('webgl_book_nav_end')?.disabled)
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
navExists: Boolean(nav),
|
navExists: Boolean(nav),
|
||||||
|
runtimeInvariants,
|
||||||
initialSliderMax,
|
initialSliderMax,
|
||||||
initialMinLabel,
|
initialMinLabel,
|
||||||
initialMaxLabel,
|
initialMaxLabel,
|
||||||
@@ -176,6 +195,7 @@ async function main() {
|
|||||||
height: cacheProbeResult?.height || 0
|
height: cacheProbeResult?.height || 0
|
||||||
},
|
},
|
||||||
grownBookState,
|
grownBookState,
|
||||||
|
initialNavigationDisabled,
|
||||||
clampedSliderValue,
|
clampedSliderValue,
|
||||||
percentReserveState,
|
percentReserveState,
|
||||||
overlayLayout,
|
overlayLayout,
|
||||||
@@ -186,6 +206,7 @@ async function main() {
|
|||||||
targetFlipFinished,
|
targetFlipFinished,
|
||||||
targetFlipEventDetail,
|
targetFlipEventDetail,
|
||||||
postTargetFlipState,
|
postTargetFlipState,
|
||||||
|
endNavigationDisabled,
|
||||||
textureInfo
|
textureInfo
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -202,6 +223,11 @@ async function main() {
|
|||||||
if (result.initialBookState?.pageCount !== 300) failures.push(`expected initial pageCount 300, got ${result.initialBookState?.pageCount}`);
|
if (result.initialBookState?.pageCount !== 300) failures.push(`expected initial pageCount 300, got ${result.initialBookState?.pageCount}`);
|
||||||
if (result.initialBookState?.pageReserve !== 50) failures.push(`expected initial pageReserve 50, got ${result.initialBookState?.pageReserve}`);
|
if (result.initialBookState?.pageReserve !== 50) failures.push(`expected initial pageReserve 50, got ${result.initialBookState?.pageReserve}`);
|
||||||
if (result.initialBookState?.progress !== 0) failures.push(`expected initial progress 0, got ${result.initialBookState?.progress}`);
|
if (result.initialBookState?.progress !== 0) failures.push(`expected initial progress 0, got ${result.initialBookState?.progress}`);
|
||||||
|
if (Math.abs(Number(result.runtimeInvariants?.targetFrameDurationMs || 0) - (1000 / 60)) > 0.001) {
|
||||||
|
failures.push(`expected 60fps target frame duration, got ${result.runtimeInvariants?.targetFrameDurationMs}`);
|
||||||
|
}
|
||||||
|
if (result.runtimeInvariants?.flipFrontBackShareMaterial) failures.push('flip front/back materials are shared instead of independently switchable');
|
||||||
|
if (!result.runtimeInvariants?.mirrorRefreshesEveryFrame) failures.push('mirror reflection is not marked for per-frame refresh');
|
||||||
if (JSON.stringify(result.pageSpreadMap) !== JSON.stringify([[0, 0], [1, 1], [2, 2], [3, 2], [4, 3], [5, 3]])) {
|
if (JSON.stringify(result.pageSpreadMap) !== JSON.stringify([[0, 0], [1, 1], [2, 2], [3, 2], [4, 3], [5, 3]])) {
|
||||||
failures.push(`unexpected page-to-spread map ${JSON.stringify(result.pageSpreadMap)}`);
|
failures.push(`unexpected page-to-spread map ${JSON.stringify(result.pageSpreadMap)}`);
|
||||||
}
|
}
|
||||||
@@ -213,6 +239,9 @@ async function main() {
|
|||||||
failures.push(`WebGL page cache probe failed: ${JSON.stringify(result.pageCacheProbe)}`);
|
failures.push(`WebGL page cache probe failed: ${JSON.stringify(result.pageCacheProbe)}`);
|
||||||
}
|
}
|
||||||
if (result.grownBookState?.pageCount !== 310) failures.push(`expected page count to grow to 310 at writable limit, got ${result.grownBookState?.pageCount}`);
|
if (result.grownBookState?.pageCount !== 310) failures.push(`expected page count to grow to 310 at writable limit, got ${result.grownBookState?.pageCount}`);
|
||||||
|
if (!result.initialNavigationDisabled?.topBackward || !result.initialNavigationDisabled?.topFastBackward || !result.initialNavigationDisabled?.bottomStart || !result.initialNavigationDisabled?.bottomBack) {
|
||||||
|
failures.push(`backward navigation should be disabled at first page: ${JSON.stringify(result.initialNavigationDisabled)}`);
|
||||||
|
}
|
||||||
if (result.finalSliderMax !== '310') failures.push(`expected final slider max 310, got ${result.finalSliderMax}`);
|
if (result.finalSliderMax !== '310') failures.push(`expected final slider max 310, got ${result.finalSliderMax}`);
|
||||||
if (result.finalMaxLabel !== '310') failures.push(`expected final max label 310, got ${result.finalMaxLabel}`);
|
if (result.finalMaxLabel !== '310') failures.push(`expected final max label 310, got ${result.finalMaxLabel}`);
|
||||||
if (result.clampedSliderValue !== '10') failures.push(`expected slider clamp to written page 10, got ${result.clampedSliderValue}`);
|
if (result.clampedSliderValue !== '10') failures.push(`expected slider clamp to written page 10, got ${result.clampedSliderValue}`);
|
||||||
@@ -233,6 +262,9 @@ async function main() {
|
|||||||
eventDetail: result.targetFlipEventDetail
|
eventDetail: result.targetFlipEventDetail
|
||||||
})}`);
|
})}`);
|
||||||
if (result.postTargetFlipState?.spreadIndex !== 2) failures.push(`targeted page flip should commit spread 2, got ${result.postTargetFlipState?.spreadIndex}`);
|
if (result.postTargetFlipState?.spreadIndex !== 2) failures.push(`targeted page flip should commit spread 2, got ${result.postTargetFlipState?.spreadIndex}`);
|
||||||
|
if (!result.endNavigationDisabled?.topForward || !result.endNavigationDisabled?.topFastForward || !result.endNavigationDisabled?.bottomForward || !result.endNavigationDisabled?.bottomEnd) {
|
||||||
|
failures.push(`forward navigation should be disabled at written end: ${JSON.stringify(result.endNavigationDisabled)}`);
|
||||||
|
}
|
||||||
if (!result.textureInfo?.debug?.left?.painted || !result.textureInfo?.debug?.right?.painted) failures.push('page texture publish did not paint both pages');
|
if (!result.textureInfo?.debug?.left?.painted || !result.textureInfo?.debug?.right?.painted) failures.push('page texture publish did not paint both pages');
|
||||||
|
|
||||||
if (failures.length) {
|
if (failures.length) {
|
||||||
|
|||||||
Reference in New Issue
Block a user