Compare commits
10 Commits
0e3e2abdb6
...
webgl
| Author | SHA1 | Date | |
|---|---|---|---|
| 8e87f935b8 | |||
| 7f60ce0d63 | |||
| 0f66dae4eb | |||
| c7364b0497 | |||
| 91b5999cd2 | |||
| 705d1ea6bf | |||
| 004c077181 | |||
| b0175b7cdc | |||
| 0e4d9e89d7 | |||
| 97f0b913be |
@@ -10,10 +10,10 @@
|
|||||||
* -> activate (upload the visible textures for the target spread)
|
* -> activate (upload the visible textures for the target spread)
|
||||||
* -> reveal (animate the new block's text in)
|
* -> reveal (animate the new block's text in)
|
||||||
*
|
*
|
||||||
* It drives the scene exclusively through the formal `webgl-book:*` events and
|
* It drives the scene through the registered `webgl-book-scene` accessor and uses
|
||||||
* the registered `webgl-book-scene` accessor. It never touches `window.BookLabDebug`
|
* `webgl-book:*` events only as state notifications. It never touches
|
||||||
* (debug-only) and never throws out of the live playback path: a transient cache
|
* `window.BookLabDebug` (debug-only). Cache and scene-preparation misses are
|
||||||
* miss is surfaced as a problem state and playback degrades gracefully.
|
* surfaced as problem states instead of being hidden by alternate playback paths.
|
||||||
*/
|
*/
|
||||||
import { BaseModule } from './base-module.js';
|
import { BaseModule } from './base-module.js';
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
this.recordDiagnostic('segment-play:start', segment);
|
this.recordDiagnostic('segment-play:start', segment);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
segment.sourceSpreadIndex = this.getVisibleSpreadIndex();
|
||||||
// Commit pagination first so the flip targets the authoritative spread,
|
// Commit pagination first so the flip targets the authoritative spread,
|
||||||
// not the predicted preview spread.
|
// not the predicted preview spread.
|
||||||
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
|
await this.timeStage('commit', segment, () => this.commitSegmentSpread(segment, sentence));
|
||||||
@@ -222,12 +223,12 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
: null;
|
: null;
|
||||||
|
|
||||||
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
const revealDetail = this.createRevealDetail(sentence, previewSpread, 'prepare');
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(
|
const texturePlan = await this.textureRenderer.prepareRevealBlock(
|
||||||
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
|
continuationSpread ? { ...revealDetail, previewSpreads } : revealDetail,
|
||||||
{ phase: 'prepare', publishEvent: false }
|
{ phase: 'prepare', publishEvent: false }
|
||||||
);
|
);
|
||||||
if (continuationSpread) {
|
if (continuationSpread) {
|
||||||
this.textureRenderer.prepareContinuationRevealPlan({
|
await this.textureRenderer.prepareContinuationRevealPlan({
|
||||||
...revealDetail,
|
...revealDetail,
|
||||||
previewSpreads,
|
previewSpreads,
|
||||||
continuationSpread
|
continuationSpread
|
||||||
@@ -279,6 +280,9 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
|
|
||||||
async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
|
async commitSegmentSpread(segment = {}, sentence = segment.sentence) {
|
||||||
if (!segment || !sentence) return null;
|
if (!segment || !sentence) return null;
|
||||||
|
segment.sourceSpreadIndex = Number.isFinite(Number(segment.sourceSpreadIndex))
|
||||||
|
? Math.max(0, Math.round(Number(segment.sourceSpreadIndex)))
|
||||||
|
: this.getVisibleSpreadIndex();
|
||||||
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
|
const activeSpread = await this.pagination.preparePendingBlock(sentence, {
|
||||||
includeUnrenderedHistory: true
|
includeUnrenderedHistory: true
|
||||||
});
|
});
|
||||||
@@ -314,10 +318,18 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const spread = segment.activeSpread || segment.previewSpread;
|
const spread = segment.activeSpread || segment.previewSpread;
|
||||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
let texturePlan = segment.preparedTexturePlan
|
||||||
|
? { ...segment.preparedTexturePlan, phase: 'activate' }
|
||||||
|
: null;
|
||||||
|
if (texturePlan && this.pageCache?.hasPreparedRevealPlan?.(segment.blockId)) {
|
||||||
|
this.pageCache.takePreparedRevealPlan(segment.blockId);
|
||||||
|
}
|
||||||
|
if (!texturePlan) {
|
||||||
|
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||||
|
texturePlan = await this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
||||||
|
}
|
||||||
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
|
// Reuse the spanning-aware plan prepared during lookahead — its timing already spans
|
||||||
// both pages. No synchronous redraw on the critical path.
|
// both pages. No synchronous redraw on the critical path.
|
||||||
const texturePlan = this.textureRenderer.prepareRevealBlock(revealDetail, { publishEvent: false });
|
|
||||||
segment.activeTexturePlan = texturePlan;
|
segment.activeTexturePlan = texturePlan;
|
||||||
this.applyTexturePlan(texturePlan, segment, 'activate');
|
this.applyTexturePlan(texturePlan, segment, 'activate');
|
||||||
await this.assertSegmentReady(segment, 'activate');
|
await this.assertSegmentReady(segment, 'activate');
|
||||||
@@ -439,7 +451,10 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
requiresSpreadTransition(segment = {}) {
|
requiresSpreadTransition(segment = {}) {
|
||||||
return Math.max(0, Number(segment.targetSpreadIndex || 0)) > this.getVisibleSpreadIndex();
|
const sourceSpread = Number.isFinite(Number(segment.sourceSpreadIndex))
|
||||||
|
? Math.max(0, Math.round(Number(segment.sourceSpreadIndex)))
|
||||||
|
: this.getVisibleSpreadIndex();
|
||||||
|
return Math.max(0, Number(segment.targetSpreadIndex || 0)) > sourceSpread;
|
||||||
}
|
}
|
||||||
|
|
||||||
requiresRightPageFlipAfterReveal(spread = {}) {
|
requiresRightPageFlipAfterReveal(spread = {}) {
|
||||||
@@ -561,26 +576,42 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
|
|
||||||
async requestPageFlip(direction = 1, options = {}) {
|
async requestPageFlip(direction = 1, options = {}) {
|
||||||
if (this.isChoiceAwaitingPlayer()) return false;
|
if (this.isChoiceAwaitingPlayer()) return false;
|
||||||
// Warm the texture cache for the navigation window and verify the target pages
|
const flipPlan = await this.prepareFlipPlan(direction, options);
|
||||||
// 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({
|
await this.assertSegmentReady({
|
||||||
blockId: options.blockId ?? null,
|
blockId: options.blockId ?? null,
|
||||||
targetSpreadIndex: options.targetSpread,
|
targetSpreadIndex: options.targetSpread,
|
||||||
revealSides: []
|
revealSides: []
|
||||||
}, 'flip');
|
}, 'flip');
|
||||||
const wait = this.waitForPageFlipFinished(options.targetSpread);
|
const sceneControl = this.scene?.sceneControl || null;
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:request-page-flip', {
|
if (typeof sceneControl?.prewarmPageFlip !== 'function' || typeof sceneControl?.startPreparedPageFlip !== 'function') {
|
||||||
detail: {
|
this.pageCache?.recordProblem?.({
|
||||||
direction,
|
type: 'timeline-scene-flip-api-missing',
|
||||||
force: options.force === true,
|
targetSpread: flipPlan.targetSpread,
|
||||||
reason: options.reason || 'timeline',
|
reason: options.reason || 'timeline'
|
||||||
targetSpread: options.targetSpread,
|
});
|
||||||
revealSides: Array.isArray(options.revealSides) ? options.revealSides : null
|
return false;
|
||||||
}
|
}
|
||||||
}));
|
const scenePrewarm = await sceneControl.prewarmPageFlip(direction, {
|
||||||
return wait;
|
targetSpread: flipPlan.targetSpread,
|
||||||
|
reason: options.reason || 'timeline'
|
||||||
|
});
|
||||||
|
const started = sceneControl.startPreparedPageFlip(direction, {
|
||||||
|
force: options.force === true,
|
||||||
|
reason: options.reason || 'timeline',
|
||||||
|
targetSpread: flipPlan.targetSpread,
|
||||||
|
deferRevealSides: Array.isArray(options.revealSides) ? options.revealSides : null,
|
||||||
|
flipPlan,
|
||||||
|
prewarm: scenePrewarm
|
||||||
|
});
|
||||||
|
if (!started) {
|
||||||
|
this.pageCache?.recordProblem?.({
|
||||||
|
type: 'timeline-scene-flip-start-failed',
|
||||||
|
targetSpread: flipPlan.targetSpread,
|
||||||
|
reason: options.reason || 'timeline'
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return this.waitForPageFlipFinished(flipPlan.targetSpread, { alreadyStarted: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async prepareFlipPlan(direction = 1, options = {}) {
|
async prepareFlipPlan(direction = 1, options = {}) {
|
||||||
@@ -728,9 +759,9 @@ class BookPlaybackTimelineModule extends BaseModule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
waitForPageFlipFinished(targetSpread = null) {
|
waitForPageFlipFinished(targetSpread = null, options = {}) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
let started = false;
|
let started = options.alreadyStarted === true;
|
||||||
let resolved = false;
|
let resolved = false;
|
||||||
const expectedSpread = Number.isFinite(Number(targetSpread))
|
const expectedSpread = Number.isFinite(Number(targetSpread))
|
||||||
? Math.max(0, Math.round(Number(targetSpread)))
|
? Math.max(0, Math.round(Number(targetSpread)))
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.lastDrawSignature = null;
|
this.lastDrawSignature = null;
|
||||||
this.lastDrawSkipLoggedAt = 0;
|
this.lastDrawSkipLoggedAt = 0;
|
||||||
this.pipelineTimings = [];
|
this.pipelineTimings = [];
|
||||||
this.imageCache = new Map();
|
|
||||||
this.pageContentVersions = new Map();
|
this.pageContentVersions = new Map();
|
||||||
|
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -49,20 +48,12 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'waitForTextureFonts',
|
'waitForTextureFonts',
|
||||||
'ensureTextureFontFace',
|
'ensureTextureFontFace',
|
||||||
'createPageCanvases',
|
'createPageCanvases',
|
||||||
|
'createRasterWorker',
|
||||||
'drawSpread',
|
'drawSpread',
|
||||||
|
'drawSpreadSerial',
|
||||||
|
'rasterizeSpread',
|
||||||
'getDrawSignature',
|
'getDrawSignature',
|
||||||
'cloneCanvas',
|
'cloneCanvas',
|
||||||
'drawPageBase',
|
|
||||||
'drawPageMeta',
|
|
||||||
'drawTitlePage',
|
|
||||||
'drawPageNumber',
|
|
||||||
'drawPageLines',
|
|
||||||
'drawImageRecord',
|
|
||||||
'resolveImageSource',
|
|
||||||
'getCachedImage',
|
|
||||||
'drawImageFitted',
|
|
||||||
'drawLine',
|
|
||||||
'drawWord',
|
|
||||||
'buildRevealRegions',
|
'buildRevealRegions',
|
||||||
'shouldFlipAfterSideReveal',
|
'shouldFlipAfterSideReveal',
|
||||||
'collectRevealRegionCandidates',
|
'collectRevealRegionCandidates',
|
||||||
@@ -72,12 +63,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
'getLineNaturalWidth',
|
'getLineNaturalWidth',
|
||||||
'getLineWordCount',
|
'getLineWordCount',
|
||||||
'getImageRevealDurationMs',
|
'getImageRevealDurationMs',
|
||||||
'getInlineStyleState',
|
|
||||||
'updateInlineStyleState',
|
|
||||||
'getCanvasFont',
|
|
||||||
'applyTextStyle',
|
|
||||||
'getPageContent',
|
'getPageContent',
|
||||||
'buildLineSegments',
|
|
||||||
'prepareRevealBlock',
|
'prepareRevealBlock',
|
||||||
'prepareContinuationRevealPlan',
|
'prepareContinuationRevealPlan',
|
||||||
'takeContinuationRevealPlan',
|
'takeContinuationRevealPlan',
|
||||||
@@ -113,6 +99,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
await this.waitForTextureFonts();
|
await this.waitForTextureFonts();
|
||||||
this.reportProgress(20, 'Preparing page texture canvases');
|
this.reportProgress(20, 'Preparing page texture canvases');
|
||||||
this.createPageCanvases();
|
this.createPageCanvases();
|
||||||
|
this.createRasterWorker();
|
||||||
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
this.addEventListener(document, 'webgl-book:page-count-changed', this.handlePageCountChanged);
|
||||||
// The renderer is a pure renderer. It does not react to pagination spread
|
// The renderer is a pure renderer. It does not react to pagination spread
|
||||||
// updates with draws or reveals — the playback owner (book-playback-timeline)
|
// updates with draws or reveals — the playback owner (book-playback-timeline)
|
||||||
@@ -128,11 +115,132 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
this.addEventListener(document, 'story:history-restoring', this.stopAnimations);
|
||||||
this.addEventListener(document, 'story:client-reset', this.stopAnimations);
|
this.addEventListener(document, 'story:client-reset', this.stopAnimations);
|
||||||
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
|
this.currentSpread = this.pagination?.getCurrentSpread?.() || { index: 0, left: [], right: [], pageMeta: { left: null, right: null } };
|
||||||
this.drawSpread(this.currentSpread);
|
this.reportProgress(60, 'Loading page fonts in render worker');
|
||||||
|
await this.waitForWorkerFonts();
|
||||||
|
await this.drawSpread(this.currentSpread);
|
||||||
this.reportProgress(100, 'Book texture renderer ready');
|
this.reportProgress(100, 'Book texture renderer ready');
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createRasterWorker() {
|
||||||
|
const version = window.MODULE_CACHE_BUSTER ? `?v=${window.MODULE_CACHE_BUSTER}` : '';
|
||||||
|
this.rasterWorker = new Worker(`/js/book-texture-worker.js${version}`);
|
||||||
|
this.pendingRasterizations = new Map();
|
||||||
|
this.rasterRequestId = 0;
|
||||||
|
this.rasterTimeoutMs = 6000;
|
||||||
|
this.rasterChain = Promise.resolve();
|
||||||
|
this.fontsReadyPromise = new Promise((resolve) => { this.resolveFontsReady = resolve; });
|
||||||
|
this.rasterWorker.onmessage = (event) => {
|
||||||
|
const data = event.data || {};
|
||||||
|
if (data.type === 'fonts-ready') {
|
||||||
|
this.resolveFontsReady?.();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (data.type !== 'drawn') return;
|
||||||
|
this.settleRasterization(data.requestId, data.results);
|
||||||
|
};
|
||||||
|
// A worker crash or load failure must never leave a draw promise pending (that would
|
||||||
|
// stall the serialized draw chain and hang prepare/playback). Surface it and settle any
|
||||||
|
// in-flight draws to a logged miss so the pipeline degrades to last-good, not a hang.
|
||||||
|
this.rasterWorker.onerror = (event) => {
|
||||||
|
this.pageCache?.recordProblem?.({ type: 'texture-worker-error', message: event?.message || String(event) });
|
||||||
|
const pending = Array.from(this.pendingRasterizations.keys());
|
||||||
|
pending.forEach(id => this.settleRasterization(id, null));
|
||||||
|
};
|
||||||
|
// Warm the worker's fonts immediately so the first real page render is not delayed.
|
||||||
|
this.rasterWorker.postMessage({ type: 'warm-fonts' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until the worker has loaded its fonts before the first timed draw, so a cold font
|
||||||
|
// load is not counted inside a draw's timeout budget (which would otherwise fire on a cold
|
||||||
|
// load, leave the page blank, and let the loader complete over a black scene).
|
||||||
|
async waitForWorkerFonts() {
|
||||||
|
if (!this.fontsReadyPromise) return;
|
||||||
|
await Promise.race([
|
||||||
|
this.fontsReadyPromise,
|
||||||
|
new Promise(resolve => setTimeout(resolve, 15000))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
settleRasterization(requestId, results) {
|
||||||
|
const pending = this.pendingRasterizations.get(requestId);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pendingRasterizations.delete(requestId);
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pending.resolve(results);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plain, structured-cloneable subset of metrics the worker needs to draw a page.
|
||||||
|
buildWorkerMetrics() {
|
||||||
|
const m = this.metrics || {};
|
||||||
|
return {
|
||||||
|
width: m.width,
|
||||||
|
height: m.height,
|
||||||
|
content: m.content,
|
||||||
|
contentBySide: m.contentBySide,
|
||||||
|
typography: { fontFamily: m.typography?.fontFamily || 'serif' },
|
||||||
|
bodyFontSizePx: m.bodyFontSizePx,
|
||||||
|
typographyLineHeightPx: m.typographyLineHeightPx,
|
||||||
|
margins: { bottom: m.margins?.bottom || 0 }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title-page text lives in the DOM; read it here (the worker has no DOM) and pass it in.
|
||||||
|
buildTitleData() {
|
||||||
|
const metadata = this.gameConfig?.getMetadata?.() || {};
|
||||||
|
const t = this.localization?.t ? this.localization.t.bind(this.localization) : null;
|
||||||
|
return {
|
||||||
|
title: document.getElementById('game_title')?.textContent?.trim() || metadata.title || '',
|
||||||
|
author: document.getElementById('game_author')?.textContent?.trim()
|
||||||
|
|| (metadata.author && t ? t('title.byAuthor', { author: metadata.author }) : '') || '',
|
||||||
|
subtitle: document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '',
|
||||||
|
ornament: document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '',
|
||||||
|
legal: document.getElementById('game_legal_text')?.textContent?.trim() || [
|
||||||
|
metadata.version && t ? t('title.version', { version: metadata.version }) : '',
|
||||||
|
metadata.copyright || ''
|
||||||
|
].filter(Boolean).join(' | ')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
rasterizeSpread(sidesToDraw, hasReveal) {
|
||||||
|
if (!this.rasterWorker || !this.metrics) return Promise.resolve(null);
|
||||||
|
const requestId = ++this.rasterRequestId;
|
||||||
|
const job = {
|
||||||
|
type: 'draw',
|
||||||
|
requestId,
|
||||||
|
width: this.metrics.width,
|
||||||
|
height: this.metrics.height,
|
||||||
|
sides: sidesToDraw,
|
||||||
|
hasReveal,
|
||||||
|
metrics: this.buildWorkerMetrics(),
|
||||||
|
pageMeta: this.currentSpread?.pageMeta || {},
|
||||||
|
titleData: this.buildTitleData(),
|
||||||
|
spreads: {
|
||||||
|
left: sidesToDraw.includes('left') ? (this.currentSpread?.left || []) : [],
|
||||||
|
right: sidesToDraw.includes('right') ? (this.currentSpread?.right || []) : []
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
// Bound every job so a dropped/stuck worker response can never leave this promise
|
||||||
|
// pending and stall the draw chain; on timeout, settle to a logged miss (last-good).
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (!this.pendingRasterizations.has(requestId)) return;
|
||||||
|
this.pageCache?.recordProblem?.({ type: 'texture-worker-timeout', requestId, sides: sidesToDraw });
|
||||||
|
this.settleRasterization(requestId, null);
|
||||||
|
}, this.rasterTimeoutMs || 4000);
|
||||||
|
this.pendingRasterizations.set(requestId, { resolve, timer });
|
||||||
|
this.rasterWorker.postMessage(job);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canvasFromBitmap(bitmap) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d')?.drawImage(bitmap, 0, 0);
|
||||||
|
return canvas;
|
||||||
|
}
|
||||||
|
|
||||||
markPipelineTiming(name, detail = {}) {
|
markPipelineTiming(name, detail = {}) {
|
||||||
const entry = {
|
const entry = {
|
||||||
name,
|
name,
|
||||||
@@ -182,9 +290,23 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rasterization runs in a worker and is therefore async. Serialize draws through a chain so
|
||||||
|
// the shared render state (currentSpread, revealPublishBlockIds, revealSpreadSourceOverride,
|
||||||
|
// revealBaseCanvases) is never mutated by an overlapping draw — the critical section from
|
||||||
|
// setting that state to publishSpread stays atomic even across the worker round trip.
|
||||||
drawSpread(spread = null, sides = null, options = {}) {
|
drawSpread(spread = null, sides = null, options = {}) {
|
||||||
|
const run = () => this.drawSpreadSerial(spread, sides, options);
|
||||||
|
this.rasterChain = (this.rasterChain || Promise.resolve()).then(run, run);
|
||||||
|
return this.rasterChain;
|
||||||
|
}
|
||||||
|
|
||||||
|
async drawSpreadSerial(spread = null, sides = null, options = {}) {
|
||||||
const previousSpread = this.currentSpread;
|
const previousSpread = this.currentSpread;
|
||||||
this.currentSpread = spread || { left: [], right: [] };
|
this.currentSpread = spread || { left: [], right: [] };
|
||||||
|
// Reveal context is passed per draw (not left on the instance by the caller) so it can be
|
||||||
|
// set inside this serialized section without racing concurrent lookahead prepares.
|
||||||
|
this.revealPublishBlockIds = options.revealPublishBlockIds || null;
|
||||||
|
this.revealSpreadSourceOverride = options.revealSpreadSourceOverride || null;
|
||||||
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
const sidesToDraw = Array.isArray(sides) && sides.length ? sides : ['left', 'right'];
|
||||||
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
|
const hasReveal = this.revealPublishBlockIds && this.revealPublishBlockIds.size > 0;
|
||||||
const phase = this.getDrawPhase(options);
|
const phase = this.getDrawPhase(options);
|
||||||
@@ -195,7 +317,9 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
this.lastDrawSkipLoggedAt = now;
|
this.lastDrawSkipLoggedAt = now;
|
||||||
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
|
this.markPipelineTiming('drawSpread:skip', { sides: sidesToDraw });
|
||||||
}
|
}
|
||||||
if (phase === 'prepare') this.currentSpread = previousSpread;
|
this.revealPublishBlockIds = null;
|
||||||
|
this.revealSpreadSourceOverride = null;
|
||||||
|
this.currentSpread = previousSpread;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
this.markPipelineTiming('drawSpread:start', {
|
this.markPipelineTiming('drawSpread:start', {
|
||||||
@@ -204,21 +328,31 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
phase
|
phase
|
||||||
});
|
});
|
||||||
this.revealBaseCanvases = { left: null, right: null };
|
this.revealBaseCanvases = { left: null, right: null };
|
||||||
|
const results = await this.rasterizeSpread(sidesToDraw, hasReveal);
|
||||||
sidesToDraw.forEach((side) => {
|
sidesToDraw.forEach((side) => {
|
||||||
if (!this.canvases[side]) return;
|
const result = results?.[side];
|
||||||
this.drawPageBase(side);
|
if (!this.canvases[side] || !result) return;
|
||||||
if (hasReveal) this.revealBaseCanvases[side] = this.cloneCanvas(this.canvases[side]);
|
const ctx = this.contexts[side];
|
||||||
this.drawPageMeta(side, 'before-lines');
|
ctx.clearRect(0, 0, this.canvases[side].width, this.canvases[side].height);
|
||||||
this.drawPageLines(side, this.currentSpread?.[side] || []);
|
ctx.drawImage(result.pageBitmap, 0, 0);
|
||||||
this.drawPageMeta(side, 'after-lines');
|
result.pageBitmap.close?.();
|
||||||
|
// The paper base is identical for every page of a side; the worker sends its bitmap
|
||||||
|
// only once, and we cache the canvas and reuse it for all reveals. This removes a
|
||||||
|
// large per-block canvas/bitmap allocation that was driving GC stalls.
|
||||||
|
if (result.baseBitmap) {
|
||||||
|
if (!this.cachedBaseCanvas) this.cachedBaseCanvas = {};
|
||||||
|
this.cachedBaseCanvas[side] = this.canvasFromBitmap(result.baseBitmap);
|
||||||
|
result.baseBitmap.close?.();
|
||||||
|
}
|
||||||
|
if (hasReveal) {
|
||||||
|
this.revealBaseCanvases[side] = this.cachedBaseCanvas?.[side] || null;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
const published = this.publishSpread(sidesToDraw, options);
|
const published = this.publishSpread(sidesToDraw, options);
|
||||||
this.markPipelineTiming('drawSpread:end', {
|
this.markPipelineTiming('drawSpread:end', { sides: sidesToDraw, phase });
|
||||||
sides: sidesToDraw,
|
|
||||||
phase
|
|
||||||
});
|
|
||||||
this.revealBaseCanvases = null;
|
this.revealBaseCanvases = null;
|
||||||
this.revealPublishBlockIds = null;
|
this.revealPublishBlockIds = null;
|
||||||
|
this.revealSpreadSourceOverride = null;
|
||||||
if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
|
if (phase !== 'prepare' && !hasReveal) this.lastDrawSignature = drawSignature;
|
||||||
if (phase === 'prepare') this.currentSpread = previousSpread;
|
if (phase === 'prepare') this.currentSpread = previousSpread;
|
||||||
return published;
|
return published;
|
||||||
@@ -249,273 +383,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
return clone;
|
return clone;
|
||||||
}
|
}
|
||||||
|
|
||||||
drawPageBase(side) {
|
|
||||||
const canvas = this.canvases[side];
|
|
||||||
const ctx = this.contexts[side];
|
|
||||||
if (!canvas || !ctx) return;
|
|
||||||
|
|
||||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
||||||
ctx.fillStyle = '#f2ead0';
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
const shade = ctx.createLinearGradient(0, 0, canvas.width, 0);
|
|
||||||
if (side === 'left') {
|
|
||||||
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
|
|
||||||
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
|
|
||||||
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
|
|
||||||
} else {
|
|
||||||
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
|
|
||||||
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
|
|
||||||
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
|
|
||||||
}
|
|
||||||
ctx.fillStyle = shade;
|
|
||||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
||||||
|
|
||||||
this.hitMaps[side] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPageMeta(side, phase = 'after-lines') {
|
|
||||||
const meta = this.currentSpread?.pageMeta?.[side] || null;
|
|
||||||
if (!meta) return;
|
|
||||||
if (phase === 'before-lines' && meta.kind === 'title') this.drawTitlePage(side);
|
|
||||||
if (phase === 'after-lines') this.drawPageNumber(side, meta);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawTitlePage(side) {
|
|
||||||
const ctx = this.contexts[side];
|
|
||||||
if (!ctx || !this.metrics) return;
|
|
||||||
const content = this.getPageContent(side);
|
|
||||||
const metadata = this.gameConfig?.getMetadata?.() || {};
|
|
||||||
const titleText = document.getElementById('game_title')?.textContent?.trim() || metadata.title || '';
|
|
||||||
const authorText = document.getElementById('game_author')?.textContent?.trim()
|
|
||||||
|| (metadata.author ? this.localization?.t?.('title.byAuthor', { author: metadata.author }) : '')
|
|
||||||
|| '';
|
|
||||||
const subtitleText = document.getElementById('game_subtitle')?.textContent?.trim() || metadata.subtitle || '';
|
|
||||||
const ornamentText = document.querySelector('#start_prompt .separator, #start_prompt .ornament, #start_prompt [class*="separator"]')?.textContent?.trim() || '';
|
|
||||||
const legalText = document.getElementById('game_legal_text')?.textContent?.trim() || [
|
|
||||||
metadata.version ? this.localization?.t?.('title.version', { version: metadata.version }) : '',
|
|
||||||
metadata.copyright || ''
|
|
||||||
].filter(Boolean).join(' | ');
|
|
||||||
const centerX = content.x + content.width * 0.5;
|
|
||||||
const font = this.metrics.typography.fontFamily;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
|
||||||
if (authorText) {
|
|
||||||
ctx.font = `italic ${Math.round(this.metrics.bodyFontSizePx * 0.86)}px ${font}`;
|
|
||||||
ctx.fillText(authorText, centerX, content.y + content.height * 0.18);
|
|
||||||
}
|
|
||||||
if (titleText) {
|
|
||||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.55)}px ${font}`;
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
|
|
||||||
ctx.fillText(titleText, centerX, content.y + content.height * 0.28);
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
|
||||||
}
|
|
||||||
if (subtitleText) {
|
|
||||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.94)}px ${font}`;
|
|
||||||
ctx.fillText(subtitleText, centerX, content.y + content.height * 0.39);
|
|
||||||
}
|
|
||||||
if (ornamentText) {
|
|
||||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 1.3)}px ${font}`;
|
|
||||||
ctx.fillText(ornamentText, centerX, content.y + content.height * 0.52);
|
|
||||||
}
|
|
||||||
if (legalText) {
|
|
||||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.62)}px ${font}`;
|
|
||||||
ctx.fillText(legalText, centerX, content.y + content.height * 0.96);
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPageNumber(side, meta = {}) {
|
|
||||||
if (meta.omitPageNumber || meta.pageNumber == null) return;
|
|
||||||
const ctx = this.contexts[side];
|
|
||||||
if (!ctx || !this.metrics) return;
|
|
||||||
const content = this.getPageContent(side);
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
|
|
||||||
ctx.textAlign = 'center';
|
|
||||||
ctx.textBaseline = 'middle';
|
|
||||||
ctx.font = `${Math.round(this.metrics.bodyFontSizePx * 0.68)}px ${this.metrics.typography.fontFamily}`;
|
|
||||||
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + this.metrics.margins.bottom * 0.48);
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawPageLines(side, lines = []) {
|
|
||||||
const ctx = this.contexts[side];
|
|
||||||
if (!ctx || !this.metrics || !Array.isArray(lines)) return;
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
|
|
||||||
ctx.textBaseline = 'alphabetic';
|
|
||||||
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
|
||||||
lines.forEach(line => {
|
|
||||||
if (line?.type === 'image' || line?.kind === 'image') this.drawImageRecord(ctx, line, side);
|
|
||||||
else this.drawLine(ctx, line, side);
|
|
||||||
});
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
drawImageRecord(ctx, lineRecord = {}, side = 'left') {
|
|
||||||
const content = this.getPageContent(side);
|
|
||||||
const layout = lineRecord.metadata?.imageLayout || {};
|
|
||||||
const rect = layout.textureRect || {};
|
|
||||||
const x = content.x + Number(rect.x || 0);
|
|
||||||
const y = content.y + Number(rect.y || 0);
|
|
||||||
const width = Math.max(1, Number(rect.width || content.width));
|
|
||||||
const height = Math.max(1, Number(rect.height || this.metrics.typographyLineHeightPx));
|
|
||||||
const src = this.resolveImageSource(lineRecord.metadata || {});
|
|
||||||
|
|
||||||
ctx.save();
|
|
||||||
if (src) {
|
|
||||||
const image = this.getCachedImage(src);
|
|
||||||
if (image?.complete && image.naturalWidth > 0) {
|
|
||||||
this.drawImageFitted(ctx, image, x, y, width, height);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.restore();
|
|
||||||
}
|
|
||||||
|
|
||||||
resolveImageSource(metadata = {}) {
|
|
||||||
const explicit = String(metadata.url || metadata.src || '').trim();
|
|
||||||
if (explicit) return explicit;
|
|
||||||
const filename = String(metadata.filename || '').trim();
|
|
||||||
if (!filename) return '';
|
|
||||||
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
|
|
||||||
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCachedImage(src) {
|
|
||||||
if (!src) return null;
|
|
||||||
if (this.imageCache.has(src)) return this.imageCache.get(src);
|
|
||||||
const image = new Image();
|
|
||||||
image.decoding = 'async';
|
|
||||||
image.onload = () => this.drawSpread(this.currentSpread || this.pagination?.getCurrentSpread?.());
|
|
||||||
image.onerror = () => this.markPipelineTiming('image:load-error', { src });
|
|
||||||
image.src = src;
|
|
||||||
this.imageCache.set(src, image);
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawImageFitted(ctx, image, x, y, width, height) {
|
|
||||||
const sourceWidth = image.naturalWidth || image.width || 1;
|
|
||||||
const sourceHeight = image.naturalHeight || image.height || 1;
|
|
||||||
const sourceAspect = sourceWidth / sourceHeight;
|
|
||||||
const targetAspect = width / height;
|
|
||||||
let sx = 0;
|
|
||||||
let sy = 0;
|
|
||||||
let sw = sourceWidth;
|
|
||||||
let sh = sourceHeight;
|
|
||||||
if (sourceAspect > targetAspect) {
|
|
||||||
sw = sourceHeight * targetAspect;
|
|
||||||
sx = (sourceWidth - sw) * 0.5;
|
|
||||||
} else if (sourceAspect < targetAspect) {
|
|
||||||
sh = sourceWidth / targetAspect;
|
|
||||||
sy = (sourceHeight - sh) * 0.5;
|
|
||||||
}
|
|
||||||
ctx.drawImage(image, sx, sy, sw, sh, x, y, width, height);
|
|
||||||
}
|
|
||||||
|
|
||||||
drawLine(ctx, lineRecord = {}, side = 'left') {
|
|
||||||
const metrics = this.metrics;
|
|
||||||
const content = this.getPageContent(side);
|
|
||||||
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
|
||||||
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
|
|
||||||
const line = lineRecord.line || {};
|
|
||||||
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
|
||||||
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
|
|
||||||
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
|
|
||||||
const naturalWidth = nodes.reduce((sum, node) => {
|
|
||||||
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
|
|
||||||
return sum;
|
|
||||||
}, 0);
|
|
||||||
const centerOffset = line.align === 'center'
|
|
||||||
? Math.max(0, (content.width - naturalWidth) / 2)
|
|
||||||
: Number(line.offset || 0);
|
|
||||||
let x = content.x + centerOffset;
|
|
||||||
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
|
|
||||||
const previousVariantCaps = 'fontVariantCaps' in ctx ? ctx.fontVariantCaps : null;
|
|
||||||
const previousLetterSpacing = 'letterSpacing' in ctx ? ctx.letterSpacing : null;
|
|
||||||
const baseStyle = this.getInlineStyleState(line.activeStyleTags || [], {
|
|
||||||
italic: lineRecord.fontStyle === 'italic'
|
|
||||||
});
|
|
||||||
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
||||||
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
|
||||||
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
|
|
||||||
if (lineRecord.dropCapText) {
|
|
||||||
ctx.save();
|
|
||||||
const dropCapFontPx = Math.round(fontPx * 2.68);
|
|
||||||
const dropCapX = content.x;
|
|
||||||
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
|
||||||
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
|
||||||
ctx.textBaseline = 'top';
|
|
||||||
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
|
|
||||||
ctx.restore();
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
||||||
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
|
||||||
this.applyTextStyle(ctx, fontPx, smallCaps, baseStyle);
|
|
||||||
}
|
|
||||||
this.buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
|
|
||||||
this.drawWord(ctx, segment, x + segment.x, baseY, lineRecord, segment.wordIndex, side, fontPx, lineHeightPx, smallCaps);
|
|
||||||
});
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = previousVariantCaps || 'normal';
|
|
||||||
if ('letterSpacing' in ctx) ctx.letterSpacing = previousLetterSpacing || '0px';
|
|
||||||
}
|
|
||||||
|
|
||||||
getInlineStyleState(tags = [], base = {}) {
|
|
||||||
const state = {
|
|
||||||
bold: Boolean(base.bold),
|
|
||||||
italic: Boolean(base.italic)
|
|
||||||
};
|
|
||||||
tags.forEach(tag => {
|
|
||||||
if (tag?.bold) state.bold = true;
|
|
||||||
if (tag?.italic) state.italic = true;
|
|
||||||
});
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInlineStyleState(stack = [], value = '') {
|
|
||||||
const text = String(value || '');
|
|
||||||
if (!text.startsWith('<')) return stack;
|
|
||||||
if (text.startsWith('</')) {
|
|
||||||
if (stack.length) stack.pop();
|
|
||||||
return stack;
|
|
||||||
}
|
|
||||||
const template = document.createElement('div');
|
|
||||||
template.innerHTML = text;
|
|
||||||
const element = template.firstElementChild;
|
|
||||||
if (!element) return stack;
|
|
||||||
const tagName = element.tagName.toLowerCase();
|
|
||||||
const style = String(element.getAttribute('style') || '').toLowerCase();
|
|
||||||
const className = String(element.getAttribute('class') || '').toLowerCase();
|
|
||||||
stack.push({
|
|
||||||
tagName,
|
|
||||||
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
|
|
||||||
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
|
|
||||||
});
|
|
||||||
return stack;
|
|
||||||
}
|
|
||||||
|
|
||||||
getCanvasFont(fontPx, smallCaps = false, style = {}) {
|
|
||||||
const metrics = this.metrics;
|
|
||||||
return [
|
|
||||||
style.italic ? 'italic' : '',
|
|
||||||
smallCaps ? 'small-caps' : '',
|
|
||||||
style.bold ? '700' : '',
|
|
||||||
`${fontPx}px`,
|
|
||||||
metrics.typography.fontFamily
|
|
||||||
].filter(Boolean).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
applyTextStyle(ctx, fontPx, smallCaps = false, style = {}) {
|
|
||||||
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
|
||||||
ctx.font = this.getCanvasFont(fontPx, smallCaps, style);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPageContent(side = 'left') {
|
getPageContent(side = 'left') {
|
||||||
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
return this.metrics?.contentBySide?.[side] || this.metrics?.content || {
|
||||||
x: 0,
|
x: 0,
|
||||||
@@ -525,66 +392,6 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
buildLineSegments(ctx, nodes = [], line = {}, ratio = 0, baseStyle = {}) {
|
|
||||||
const segments = [];
|
|
||||||
let x = 0;
|
|
||||||
let currentSegment = null;
|
|
||||||
let previousWasGlue = true;
|
|
||||||
let currentWordIndex = -1;
|
|
||||||
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
|
|
||||||
|
|
||||||
nodes.forEach((node, index) => {
|
|
||||||
if (!node) return;
|
|
||||||
if (node.type === 'box' && node.value) {
|
|
||||||
const value = String(node.value);
|
|
||||||
const width = Number(node.width || ctx.measureText(value).width || 0);
|
|
||||||
const style = this.getInlineStyleState(styleStack, baseStyle);
|
|
||||||
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
|
|
||||||
currentSegment.value += value;
|
|
||||||
currentSegment.width += width;
|
|
||||||
} else {
|
|
||||||
if (previousWasGlue) currentWordIndex += 1;
|
|
||||||
currentSegment = {
|
|
||||||
value,
|
|
||||||
x,
|
|
||||||
width,
|
|
||||||
wordIndex: Math.max(0, currentWordIndex),
|
|
||||||
style
|
|
||||||
};
|
|
||||||
segments.push(currentSegment);
|
|
||||||
}
|
|
||||||
x += width;
|
|
||||||
previousWasGlue = false;
|
|
||||||
} else if (node.type === 'glue' && node.width !== 0) {
|
|
||||||
let width = Number(node.width || 0);
|
|
||||||
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
|
|
||||||
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
|
|
||||||
x += width;
|
|
||||||
previousWasGlue = true;
|
|
||||||
currentSegment = null;
|
|
||||||
} else if (node.type === 'penalty' && node.penalty === 100) {
|
|
||||||
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
|
|
||||||
if (isLineEndHyphen) {
|
|
||||||
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
|
|
||||||
currentSegment.value += '-';
|
|
||||||
currentSegment.width += hyphenWidth;
|
|
||||||
x += hyphenWidth;
|
|
||||||
}
|
|
||||||
previousWasGlue = false;
|
|
||||||
} else if (node.type === 'tag') {
|
|
||||||
this.updateInlineStyleState(styleStack, node.value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return segments;
|
|
||||||
}
|
|
||||||
|
|
||||||
drawWord(ctx, segment, x, baseY, lineRecord, localWordIndex, side, fontPx, lineHeightPx, smallCaps = false) {
|
|
||||||
const value = segment?.value || '';
|
|
||||||
this.applyTextStyle(ctx, fontPx, smallCaps, segment?.style || {});
|
|
||||||
ctx.fillText(value, x, baseY);
|
|
||||||
}
|
|
||||||
|
|
||||||
buildRevealRegions(side) {
|
buildRevealRegions(side) {
|
||||||
if (!this.revealPublishBlockIds || !this.metrics) return null;
|
if (!this.revealPublishBlockIds || !this.metrics) return null;
|
||||||
const candidates = this.collectRevealRegionCandidates();
|
const candidates = this.collectRevealRegionCandidates();
|
||||||
@@ -880,7 +687,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
prepareRevealBlock(detail = {}, options = {}) {
|
async prepareRevealBlock(detail = {}, options = {}) {
|
||||||
const blockId = detail.blockId ?? detail.id ?? null;
|
const blockId = detail.blockId ?? detail.id ?? null;
|
||||||
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
if (blockId == null || !Array.isArray(detail.wordTimings)) return;
|
||||||
const id = String(blockId);
|
const id = String(blockId);
|
||||||
@@ -912,25 +719,19 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||||
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 = ['left', 'right'];
|
const sides = ['left', 'right'];
|
||||||
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
|
// When the caller supplies the (not-yet-committed) preview spreads for a spanning
|
||||||
// block, derive this spread's reveal timing across all of them so the cached plan
|
// block, derive this spread's reveal timing across all of them so the cached plan
|
||||||
// already spans both pages, letting activate reuse it directly.
|
// already spans both pages, letting activate reuse it directly.
|
||||||
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
|
const spanningPreview = Array.isArray(detail.previewSpreads) && detail.previewSpreads.length > 1;
|
||||||
const previousOverride = this.revealSpreadSourceOverride;
|
const published = await this.drawSpread(spread, sides, {
|
||||||
if (spanningPreview) this.revealSpreadSourceOverride = detail.previewSpreads;
|
phase,
|
||||||
let published = null;
|
publishEvent: options.publishEvent !== false,
|
||||||
try {
|
revealPublishBlockIds: new Set([id]),
|
||||||
published = this.drawSpread(spread, sides, {
|
revealSpreadSourceOverride: spanningPreview ? detail.previewSpreads : null
|
||||||
phase,
|
});
|
||||||
publishEvent: options.publishEvent !== false
|
if (!spanningPreview) await this.preloadAdditionalRevealSpreads(id, spread);
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
this.revealSpreadSourceOverride = previousOverride;
|
|
||||||
}
|
|
||||||
if (!spanningPreview) this.preloadAdditionalRevealSpreads(id, spread);
|
|
||||||
if (phase === 'prepare' && published) {
|
if (phase === 'prepare' && published) {
|
||||||
this.pageCache?.rememberPreparedRevealPlan?.(id, {
|
this.pageCache?.rememberPreparedRevealPlan?.(id, {
|
||||||
...published,
|
...published,
|
||||||
@@ -957,7 +758,7 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
// computed across both spreads. revealContinuationSpread reuses this after the flip
|
// computed across both spreads. revealContinuationSpread reuses this after the flip
|
||||||
// instead of redrawing the spread synchronously on the critical path. Returns the plan
|
// instead of redrawing the spread synchronously on the critical path. Returns the plan
|
||||||
// or null (caller falls back to the synchronous redraw).
|
// or null (caller falls back to the synchronous redraw).
|
||||||
prepareContinuationRevealPlan(detail = {}) {
|
async prepareContinuationRevealPlan(detail = {}) {
|
||||||
const blockId = detail.blockId ?? detail.id ?? null;
|
const blockId = detail.blockId ?? detail.id ?? null;
|
||||||
const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null;
|
const previewSpreads = Array.isArray(detail.previewSpreads) ? detail.previewSpreads : null;
|
||||||
const continuationSpread = detail.continuationSpread || null;
|
const continuationSpread = detail.continuationSpread || null;
|
||||||
@@ -968,18 +769,12 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
if (!existing || existing.completed) {
|
if (!existing || existing.completed) {
|
||||||
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
this.activeAnimations.set(id, this.createAnimationState(blockId, wordTimings, detail));
|
||||||
}
|
}
|
||||||
const previousOverride = this.revealSpreadSourceOverride;
|
const published = await this.drawSpread(continuationSpread, ['left', 'right'], {
|
||||||
const previousPublishIds = this.revealPublishBlockIds;
|
phase: 'prepare',
|
||||||
this.revealSpreadSourceOverride = previewSpreads;
|
publishEvent: false,
|
||||||
this.revealPublishBlockIds = new Set([id]);
|
revealPublishBlockIds: new Set([id]),
|
||||||
let published = null;
|
revealSpreadSourceOverride: previewSpreads
|
||||||
try {
|
});
|
||||||
published = this.drawSpread(continuationSpread, ['left', 'right'], { phase: 'prepare', publishEvent: false });
|
|
||||||
} finally {
|
|
||||||
// drawSpread nulls revealPublishBlockIds when it finishes; restore the caller's state.
|
|
||||||
this.revealSpreadSourceOverride = previousOverride;
|
|
||||||
this.revealPublishBlockIds = previousPublishIds;
|
|
||||||
}
|
|
||||||
if (!published || !published.reveal || !Object.keys(published.reveal).length) return null;
|
if (!published || !published.reveal || !Object.keys(published.reveal).length) return null;
|
||||||
const plan = {
|
const plan = {
|
||||||
...published,
|
...published,
|
||||||
@@ -1020,15 +815,16 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
return activated;
|
return activated;
|
||||||
}
|
}
|
||||||
|
|
||||||
preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
|
async preloadAdditionalRevealSpreads(blockId, primarySpread = null) {
|
||||||
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
|
const spreads = Array.isArray(this.pagination?.spreads) ? this.pagination.spreads : [];
|
||||||
if (!spreads.length) return;
|
if (!spreads.length) return;
|
||||||
const primaryIndex = Number(primarySpread?.index);
|
const primaryIndex = Number(primarySpread?.index);
|
||||||
spreads.forEach((spread) => {
|
for (const spread of spreads) {
|
||||||
if (!spread || Number(spread.index) === primaryIndex) return;
|
if (!spread || Number(spread.index) === primaryIndex) continue;
|
||||||
if (!this.spreadContainsBlock(spread, blockId)) return;
|
if (!this.spreadContainsBlock(spread, blockId)) continue;
|
||||||
this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
|
// eslint-disable-next-line no-await-in-loop
|
||||||
});
|
await this.drawSpread(spread, ['left', 'right'], { phase: 'prepare' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
spreadContainsBlock(spread = {}, blockId = '') {
|
spreadContainsBlock(spread = {}, blockId = '') {
|
||||||
@@ -1096,13 +892,12 @@ class BookTextureRendererModule extends BaseModule {
|
|||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (changed) {
|
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
||||||
document.dispatchEvent(new CustomEvent('webgl-book:page-reveal-fast-forward', {
|
detail: {
|
||||||
detail: {
|
blockIds,
|
||||||
blockIds
|
broad: !changed
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
completeRevealBlockIds(blockIds = []) {
|
completeRevealBlockIds(blockIds = []) {
|
||||||
|
|||||||
@@ -0,0 +1,370 @@
|
|||||||
|
// OffscreenCanvas page rasterizer. Runs off the main thread so the heavy page text drawing
|
||||||
|
// (the bulk of drawSpread cost) never blocks the render loop or UI. The main thread sends a
|
||||||
|
// draw job (line records + metrics + page meta + title data + preloaded image bitmaps) and
|
||||||
|
// receives back a full-page ImageBitmap and a background-only base ImageBitmap per side; the
|
||||||
|
// main thread blits those onto its existing page canvases, leaving the texture/reveal pipeline
|
||||||
|
// unchanged. This is the single rasterization implementation — the main thread no longer draws
|
||||||
|
// page text itself.
|
||||||
|
|
||||||
|
let fontsReady = null;
|
||||||
|
const imageCache = new Map(); // src -> ImageBitmap | null
|
||||||
|
const surfaces = {}; // side -> { canvas, ctx }
|
||||||
|
// The reveal "base" layer is the plain paper background (drawPageBase) — identical for every
|
||||||
|
// page of a side at a given size. Send its bitmap only once per side+size; the main thread
|
||||||
|
// caches and reuses it, avoiding a large per-block ImageBitmap allocation (GC churn).
|
||||||
|
const sentBaseKeys = new Set();
|
||||||
|
|
||||||
|
function resolveImageSource(metadata = {}) {
|
||||||
|
const explicit = String(metadata.url || metadata.src || '').trim();
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const filename = String(metadata.filename || '').trim();
|
||||||
|
if (!filename) return '';
|
||||||
|
if (/^(https?:|data:|blob:|\/)/i.test(filename)) return filename;
|
||||||
|
return `/images/${filename.replace(/^images[\\/]/i, '').replace(/\\/g, '/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureImages(srcs = []) {
|
||||||
|
await Promise.all(srcs.map(async (src) => {
|
||||||
|
if (!src || imageCache.has(src)) return;
|
||||||
|
try {
|
||||||
|
const response = await fetch(src);
|
||||||
|
const blob = await response.blob();
|
||||||
|
imageCache.set(src, await createImageBitmap(blob));
|
||||||
|
} catch (error) {
|
||||||
|
imageCache.set(src, null);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFonts() {
|
||||||
|
if (fontsReady) return fontsReady;
|
||||||
|
if (typeof FontFace === 'undefined' || !self.fonts) {
|
||||||
|
fontsReady = Promise.resolve();
|
||||||
|
return fontsReady;
|
||||||
|
}
|
||||||
|
const faces = [
|
||||||
|
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Regular.otf)', { style: 'normal', weight: '400' }),
|
||||||
|
new FontFace('EB Garamond', 'url(/fonts/EBGaramond12-Italic.otf)', { style: 'italic', weight: '400' }),
|
||||||
|
new FontFace('EB Garamond 12', 'url(/fonts/EBGaramond12/webfonts/EBGaramond-Regular.woff2)', {}),
|
||||||
|
new FontFace('EB Garamond Initials', 'url(/fonts/EB-Garamond-Initials/EBGaramond-0.016/otf/EBGaramond-Initials.otf)', {})
|
||||||
|
];
|
||||||
|
fontsReady = Promise.all(faces.map(face => face.load()
|
||||||
|
.then(loaded => { self.fonts.add(loaded); })
|
||||||
|
.catch(() => {})));
|
||||||
|
return fontsReady;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSurface(width, height) {
|
||||||
|
if (!surfaces.shared) {
|
||||||
|
surfaces.shared = { canvas: new OffscreenCanvas(width, height) };
|
||||||
|
surfaces.shared.ctx = surfaces.shared.canvas.getContext('2d');
|
||||||
|
}
|
||||||
|
const surface = surfaces.shared;
|
||||||
|
if (surface.canvas.width !== width) surface.canvas.width = width;
|
||||||
|
if (surface.canvas.height !== height) surface.canvas.height = height;
|
||||||
|
return surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPageContent(metrics, side) {
|
||||||
|
return metrics?.contentBySide?.[side] || metrics?.content || {
|
||||||
|
x: 0, y: 0, width: metrics?.width || 1, height: metrics?.height || 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getInlineStyleState(tags = [], base = {}) {
|
||||||
|
const state = { bold: Boolean(base.bold), italic: Boolean(base.italic) };
|
||||||
|
tags.forEach(tag => {
|
||||||
|
if (tag?.bold) state.bold = true;
|
||||||
|
if (tag?.italic) state.italic = true;
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DOM-free inline-tag parser (the main-thread renderer used document.createElement; a worker
|
||||||
|
// has no DOM, so parse the tag string directly).
|
||||||
|
function updateInlineStyleState(stack = [], value = '') {
|
||||||
|
const text = String(value || '');
|
||||||
|
if (!text.startsWith('<')) return stack;
|
||||||
|
if (text.startsWith('</')) {
|
||||||
|
if (stack.length) stack.pop();
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
const tagMatch = text.match(/^<\s*([a-zA-Z0-9]+)/);
|
||||||
|
if (!tagMatch) return stack;
|
||||||
|
const tagName = tagMatch[1].toLowerCase();
|
||||||
|
const style = (text.match(/style\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
|
||||||
|
const className = (text.match(/class\s*=\s*"([^"]*)"/i)?.[1] || '').toLowerCase();
|
||||||
|
stack.push({
|
||||||
|
tagName,
|
||||||
|
bold: tagName === 'strong' || tagName === 'b' || /font-weight\s*:\s*(bold|[6-9]00)/.test(style) || className.includes('bold'),
|
||||||
|
italic: tagName === 'em' || tagName === 'i' || /font-style\s*:\s*italic/.test(style) || className.includes('italic')
|
||||||
|
});
|
||||||
|
return stack;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasFont(metrics, fontPx, smallCaps, style) {
|
||||||
|
return [
|
||||||
|
style.italic ? 'italic' : '',
|
||||||
|
smallCaps ? 'small-caps' : '',
|
||||||
|
style.bold ? '700' : '',
|
||||||
|
`${fontPx}px`,
|
||||||
|
metrics.typography.fontFamily
|
||||||
|
].filter(Boolean).join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTextStyle(ctx, metrics, fontPx, smallCaps, style) {
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||||
|
ctx.font = getCanvasFont(metrics, fontPx, smallCaps, style);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildLineSegments(ctx, nodes, line, ratio, baseStyle) {
|
||||||
|
const segments = [];
|
||||||
|
let x = 0;
|
||||||
|
let currentSegment = null;
|
||||||
|
let previousWasGlue = true;
|
||||||
|
let currentWordIndex = -1;
|
||||||
|
const styleStack = Array.isArray(line.activeStyleTags) ? line.activeStyleTags.map(tag => ({ ...tag })) : [];
|
||||||
|
|
||||||
|
nodes.forEach((node, index) => {
|
||||||
|
if (!node) return;
|
||||||
|
if (node.type === 'box' && node.value) {
|
||||||
|
const value = String(node.value);
|
||||||
|
const width = Number(node.width || ctx.measureText(value).width || 0);
|
||||||
|
const style = getInlineStyleState(styleStack, baseStyle);
|
||||||
|
if (currentSegment && !previousWasGlue && currentSegment.style.bold === style.bold && currentSegment.style.italic === style.italic) {
|
||||||
|
currentSegment.value += value;
|
||||||
|
currentSegment.width += width;
|
||||||
|
} else {
|
||||||
|
if (previousWasGlue) currentWordIndex += 1;
|
||||||
|
currentSegment = { value, x, width, wordIndex: Math.max(0, currentWordIndex), style };
|
||||||
|
segments.push(currentSegment);
|
||||||
|
}
|
||||||
|
x += width;
|
||||||
|
previousWasGlue = false;
|
||||||
|
} else if (node.type === 'glue' && node.width !== 0) {
|
||||||
|
let width = Number(node.width || 0);
|
||||||
|
if (ratio > 0) width += Number(node.stretch || 0) * ratio;
|
||||||
|
if (ratio < 0) width += Number(node.shrink || 0) * ratio;
|
||||||
|
x += width;
|
||||||
|
previousWasGlue = true;
|
||||||
|
currentSegment = null;
|
||||||
|
} else if (node.type === 'penalty' && node.penalty === 100) {
|
||||||
|
const isLineEndHyphen = Boolean(line.hyphenated && index === nodes.length - 1 && currentSegment);
|
||||||
|
if (isLineEndHyphen) {
|
||||||
|
const hyphenWidth = Number(node.width || ctx.measureText('-').width || 0);
|
||||||
|
currentSegment.value += '-';
|
||||||
|
currentSegment.width += hyphenWidth;
|
||||||
|
x += hyphenWidth;
|
||||||
|
}
|
||||||
|
previousWasGlue = false;
|
||||||
|
} else if (node.type === 'tag') {
|
||||||
|
updateInlineStyleState(styleStack, node.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return segments;
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawLine(ctx, metrics, lineRecord, side) {
|
||||||
|
const content = getPageContent(metrics, side);
|
||||||
|
const fontPx = Math.max(1, Number(lineRecord.fontPx || 22));
|
||||||
|
const lineHeightPx = Math.max(fontPx + 2, Number(lineRecord.lineHeightPx || metrics.typographyLineHeightPx || 30));
|
||||||
|
const line = lineRecord.line || {};
|
||||||
|
const nodes = Array.isArray(line.nodes) ? line.nodes : [];
|
||||||
|
const baseY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + fontPx;
|
||||||
|
const ratio = line.isFinal || line.align === 'center' ? 0 : Number(line.ratio || 0);
|
||||||
|
const naturalWidth = nodes.reduce((sum, node) => {
|
||||||
|
if (node.type === 'box' || node.type === 'glue') return sum + Number(node.width || 0);
|
||||||
|
return sum;
|
||||||
|
}, 0);
|
||||||
|
const centerOffset = line.align === 'center'
|
||||||
|
? Math.max(0, (content.width - naturalWidth) / 2)
|
||||||
|
: Number(line.offset || 0);
|
||||||
|
const x = content.x + centerOffset;
|
||||||
|
const smallCaps = Boolean(lineRecord.smallCaps || line.smallCaps);
|
||||||
|
const baseStyle = getInlineStyleState(line.activeStyleTags || [], { italic: lineRecord.fontStyle === 'italic' });
|
||||||
|
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||||
|
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
||||||
|
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
|
||||||
|
if (lineRecord.dropCapText) {
|
||||||
|
ctx.save();
|
||||||
|
const dropCapFontPx = Math.round(fontPx * 2.68);
|
||||||
|
const dropCapX = content.x;
|
||||||
|
const dropCapY = content.y + (Number(lineRecord.pageLine || 0) * lineHeightPx) + (fontPx * 0.25);
|
||||||
|
ctx.font = `${dropCapFontPx}px "EB Garamond Initials", ${metrics.typography.fontFamily}`;
|
||||||
|
ctx.textBaseline = 'top';
|
||||||
|
ctx.fillText(String(lineRecord.dropCapText), dropCapX, dropCapY);
|
||||||
|
ctx.restore();
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = smallCaps ? 'all-small-caps' : 'normal';
|
||||||
|
if ('letterSpacing' in ctx) ctx.letterSpacing = smallCaps ? `${fontPx * 0.012}px` : '0px';
|
||||||
|
applyTextStyle(ctx, metrics, fontPx, smallCaps, baseStyle);
|
||||||
|
}
|
||||||
|
buildLineSegments(ctx, nodes, line, ratio, baseStyle).forEach((segment) => {
|
||||||
|
applyTextStyle(ctx, metrics, fontPx, smallCaps, segment.style || {});
|
||||||
|
ctx.fillText(segment.value || '', x + segment.x, baseY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawImageFitted(ctx, bitmap, x, y, width, height) {
|
||||||
|
const sourceWidth = bitmap.width || 1;
|
||||||
|
const sourceHeight = bitmap.height || 1;
|
||||||
|
const sourceAspect = sourceWidth / sourceHeight;
|
||||||
|
const targetAspect = width / height;
|
||||||
|
let sx = 0, sy = 0, sw = sourceWidth, sh = sourceHeight;
|
||||||
|
if (sourceAspect > targetAspect) {
|
||||||
|
sw = sourceHeight * targetAspect;
|
||||||
|
sx = (sourceWidth - sw) * 0.5;
|
||||||
|
} else if (sourceAspect < targetAspect) {
|
||||||
|
sh = sourceWidth / targetAspect;
|
||||||
|
sy = (sourceHeight - sh) * 0.5;
|
||||||
|
}
|
||||||
|
ctx.drawImage(bitmap, sx, sy, sw, sh, x, y, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawImageRecord(ctx, metrics, lineRecord, side) {
|
||||||
|
const content = getPageContent(metrics, side);
|
||||||
|
const layout = lineRecord.metadata?.imageLayout || {};
|
||||||
|
const rect = layout.textureRect || {};
|
||||||
|
const x = content.x + Number(rect.x || 0);
|
||||||
|
const y = content.y + Number(rect.y || 0);
|
||||||
|
const width = Math.max(1, Number(rect.width || content.width));
|
||||||
|
const height = Math.max(1, Number(rect.height || metrics.typographyLineHeightPx));
|
||||||
|
const bitmap = imageCache.get(resolveImageSource(lineRecord.metadata || {}));
|
||||||
|
if (!bitmap) return;
|
||||||
|
ctx.save();
|
||||||
|
drawImageFitted(ctx, bitmap, x, y, width, height);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPageBase(ctx, side, width, height) {
|
||||||
|
ctx.clearRect(0, 0, width, height);
|
||||||
|
ctx.fillStyle = '#f2ead0';
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
const shade = ctx.createLinearGradient(0, 0, width, 0);
|
||||||
|
if (side === 'left') {
|
||||||
|
shade.addColorStop(0, 'rgba(255, 255, 255, 0.06)');
|
||||||
|
shade.addColorStop(0.78, 'rgba(255, 255, 255, 0)');
|
||||||
|
shade.addColorStop(1, 'rgba(70, 48, 28, 0.08)');
|
||||||
|
} else {
|
||||||
|
shade.addColorStop(0, 'rgba(70, 48, 28, 0.08)');
|
||||||
|
shade.addColorStop(0.22, 'rgba(255, 255, 255, 0)');
|
||||||
|
shade.addColorStop(1, 'rgba(255, 255, 255, 0.06)');
|
||||||
|
}
|
||||||
|
ctx.fillStyle = shade;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTitlePage(ctx, metrics, side, titleData) {
|
||||||
|
if (!titleData) return;
|
||||||
|
const content = getPageContent(metrics, side);
|
||||||
|
const centerX = content.x + content.width * 0.5;
|
||||||
|
const font = metrics.typography.fontFamily;
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.9)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||||
|
if (titleData.author) {
|
||||||
|
ctx.font = `italic ${Math.round(metrics.bodyFontSizePx * 0.86)}px ${font}`;
|
||||||
|
ctx.fillText(titleData.author, centerX, content.y + content.height * 0.18);
|
||||||
|
}
|
||||||
|
if (titleData.title) {
|
||||||
|
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.55)}px ${font}`;
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'small-caps';
|
||||||
|
ctx.fillText(titleData.title, centerX, content.y + content.height * 0.28);
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||||
|
}
|
||||||
|
if (titleData.subtitle) {
|
||||||
|
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.94)}px ${font}`;
|
||||||
|
ctx.fillText(titleData.subtitle, centerX, content.y + content.height * 0.39);
|
||||||
|
}
|
||||||
|
if (titleData.ornament) {
|
||||||
|
ctx.font = `${Math.round(metrics.bodyFontSizePx * 1.3)}px ${font}`;
|
||||||
|
ctx.fillText(titleData.ornament, centerX, content.y + content.height * 0.52);
|
||||||
|
}
|
||||||
|
if (titleData.legal) {
|
||||||
|
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.62)}px ${font}`;
|
||||||
|
ctx.fillText(titleData.legal, centerX, content.y + content.height * 0.96);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPageNumber(ctx, metrics, side, meta) {
|
||||||
|
if (!meta || meta.omitPageNumber || meta.pageNumber == null) return;
|
||||||
|
const content = getPageContent(metrics, side);
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.74)';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.textBaseline = 'middle';
|
||||||
|
ctx.font = `${Math.round(metrics.bodyFontSizePx * 0.68)}px ${metrics.typography.fontFamily}`;
|
||||||
|
ctx.fillText(String(meta.pageNumber), content.x + content.width * 0.5, content.y + content.height + metrics.margins.bottom * 0.48);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawPageLines(ctx, metrics, side, lines) {
|
||||||
|
ctx.save();
|
||||||
|
ctx.fillStyle = 'rgba(31, 19, 10, 0.86)';
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
if ('fontKerning' in ctx) ctx.fontKerning = 'normal';
|
||||||
|
if ('fontVariantCaps' in ctx) ctx.fontVariantCaps = 'normal';
|
||||||
|
(Array.isArray(lines) ? lines : []).forEach(line => {
|
||||||
|
if (line?.type === 'image' || line?.kind === 'image') drawImageRecord(ctx, metrics, line, side);
|
||||||
|
else drawLine(ctx, metrics, line, side);
|
||||||
|
});
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderSide(job, side) {
|
||||||
|
const { metrics, width, height } = job;
|
||||||
|
const surface = getSurface(width, height);
|
||||||
|
const ctx = surface.ctx;
|
||||||
|
const meta = job.pageMeta?.[side] || null;
|
||||||
|
|
||||||
|
drawPageBase(ctx, side, width, height);
|
||||||
|
let baseBitmap = null;
|
||||||
|
const baseKey = `${side}:${width}x${height}`;
|
||||||
|
if (job.hasReveal && !sentBaseKeys.has(baseKey)) {
|
||||||
|
baseBitmap = await createImageBitmap(surface.canvas);
|
||||||
|
sentBaseKeys.add(baseKey);
|
||||||
|
}
|
||||||
|
if (meta?.kind === 'title') drawTitlePage(ctx, metrics, side, job.titleData);
|
||||||
|
drawPageLines(ctx, metrics, side, job.spreads?.[side] || []);
|
||||||
|
drawPageNumber(ctx, metrics, side, meta);
|
||||||
|
const pageBitmap = await createImageBitmap(surface.canvas);
|
||||||
|
return { pageBitmap, baseBitmap };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectImageSources(job) {
|
||||||
|
const srcs = new Set();
|
||||||
|
(job.sides || ['left', 'right']).forEach((side) => {
|
||||||
|
(job.spreads?.[side] || []).forEach((line) => {
|
||||||
|
if (line?.type === 'image' || line?.kind === 'image') {
|
||||||
|
const src = resolveImageSource(line.metadata || {});
|
||||||
|
if (src) srcs.add(src);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return Array.from(srcs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDraw(job) {
|
||||||
|
await ensureFonts();
|
||||||
|
await ensureImages(collectImageSources(job));
|
||||||
|
const results = {};
|
||||||
|
const transfer = [];
|
||||||
|
for (const side of (job.sides || ['left', 'right'])) {
|
||||||
|
// eslint-disable-next-line no-await-in-loop
|
||||||
|
const { pageBitmap, baseBitmap } = await renderSide(job, side);
|
||||||
|
results[side] = { pageBitmap, baseBitmap };
|
||||||
|
transfer.push(pageBitmap);
|
||||||
|
if (baseBitmap) transfer.push(baseBitmap);
|
||||||
|
}
|
||||||
|
self.postMessage({ type: 'drawn', requestId: job.requestId, results }, transfer);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.onmessage = (event) => {
|
||||||
|
const data = event.data || {};
|
||||||
|
if (data.type === 'draw') handleDraw(data);
|
||||||
|
else if (data.type === 'warm-fonts') ensureFonts().then(() => self.postMessage({ type: 'fonts-ready' }));
|
||||||
|
};
|
||||||
@@ -7,6 +7,9 @@ import { BaseModule } from './base-module.js';
|
|||||||
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
const TTS_GENERATION_TIMEOUT_MS = 60000;
|
||||||
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
const ASSET_PRELOAD_TIMEOUT_MS = 60000;
|
||||||
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
const USER_CANCEL_BLOCKING_WAIT_MIN_MS = 5000;
|
||||||
|
// Prepare only the next block's page render ahead of playback. Higher values let multiple
|
||||||
|
// large page rasterizations overlap, spiking allocation into multi-second GC stalls.
|
||||||
|
const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1;
|
||||||
|
|
||||||
class SentenceQueueModule extends BaseModule {
|
class SentenceQueueModule extends BaseModule {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -23,6 +26,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
// Cache prepared future queue items so the playback path can consume
|
// Cache prepared future queue items so the playback path can consume
|
||||||
// work that was already generated during lookahead.
|
// work that was already generated during lookahead.
|
||||||
this.prefetchingSpeech = new Map();
|
this.prefetchingSpeech = new Map();
|
||||||
|
this.prefetchingWebGLBook = new Map();
|
||||||
this.preparedSentenceCache = new Map();
|
this.preparedSentenceCache = new Map();
|
||||||
this.autoplay = true;
|
this.autoplay = true;
|
||||||
this.inputMode = 'text';
|
this.inputMode = 'text';
|
||||||
@@ -33,6 +37,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
this.generationRequests = new Map();
|
this.generationRequests = new Map();
|
||||||
this.assetPreloadRequests = new Map();
|
this.assetPreloadRequests = new Map();
|
||||||
this.queueGeneration = 0;
|
this.queueGeneration = 0;
|
||||||
|
this.webglBookPrepareChain = Promise.resolve();
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.bindMethods([
|
this.bindMethods([
|
||||||
@@ -46,7 +51,10 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
'getPreparedSentence',
|
'getPreparedSentence',
|
||||||
'prefetchAhead',
|
'prefetchAhead',
|
||||||
'prefetchWebGLBookPresentation',
|
'prefetchWebGLBookPresentation',
|
||||||
|
'runWebGLBookPresentationPrepare',
|
||||||
'isWebGLBookPresentationPrepared',
|
'isWebGLBookPresentationPrepared',
|
||||||
|
'getWebGLBookPresentationKey',
|
||||||
|
'isWebGLBookPresentationEligible',
|
||||||
'prepareSpeechMetadata',
|
'prepareSpeechMetadata',
|
||||||
'preloadAssetsForItem',
|
'preloadAssetsForItem',
|
||||||
'normalizeTtsText',
|
'normalizeTtsText',
|
||||||
@@ -210,18 +218,18 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||||
|
|
||||||
// Prefetch far enough ahead that media pauses do not block TTS
|
|
||||||
// generation for the next spoken paragraph.
|
|
||||||
this.prefetchAhead(6, queueGeneration);
|
|
||||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
|
||||||
|
|
||||||
// Notify display handler with complete sentence
|
// Notify display handler with complete sentence
|
||||||
if (this.onSentenceReadyCallback) {
|
if (this.onSentenceReadyCallback) {
|
||||||
await new Promise(resolve => {
|
const playbackFinished = new Promise(resolve => {
|
||||||
sentence.onComplete = resolve;
|
sentence.onComplete = resolve;
|
||||||
sentence.playbackStartedAt = performance.now();
|
sentence.playbackStartedAt = performance.now();
|
||||||
this.onSentenceReadyCallback(sentence, resolve);
|
this.onSentenceReadyCallback(sentence, resolve);
|
||||||
});
|
});
|
||||||
|
this.scheduleLookaheadAfterDisplay(item, queueGeneration);
|
||||||
|
await playbackFinished;
|
||||||
|
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||||
|
} else {
|
||||||
|
this.prefetchAhead(6, queueGeneration);
|
||||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -890,12 +898,42 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
return this.prepareSentence(item);
|
return this.prepareSentence(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getWebGLBookPresentationKey(sentence = {}) {
|
||||||
|
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
||||||
|
if (blockId == null) return null;
|
||||||
|
return `${sentence.gameId || sentence.metadata?.gameId || 'game'}:${blockId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
isWebGLBookPresentationEligible(sentence = {}) {
|
||||||
|
if (!sentence) return false;
|
||||||
|
return ['paragraph', 'heading'].includes(sentence.kind || sentence.type);
|
||||||
|
}
|
||||||
|
|
||||||
async prefetchWebGLBookPresentation(sentence, options = {}) {
|
async prefetchWebGLBookPresentation(sentence, options = {}) {
|
||||||
if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null;
|
if (!this.isWebGLBookPresentationEligible(sentence)) return null;
|
||||||
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|
||||||
|| document.body?.classList?.contains('webgl-mode');
|
|| document.body?.classList?.contains('webgl-mode');
|
||||||
if (!isWebGLMode) return null;
|
if (!isWebGLMode) return null;
|
||||||
|
|
||||||
|
const key = this.getWebGLBookPresentationKey(sentence);
|
||||||
|
if (!key) return null;
|
||||||
|
const existing = this.prefetchingWebGLBook.get(key);
|
||||||
|
if (existing) return existing;
|
||||||
|
|
||||||
|
const queued = this.webglBookPrepareChain
|
||||||
|
.catch(() => null)
|
||||||
|
.then(() => this.runWebGLBookPresentationPrepare(sentence, options));
|
||||||
|
this.webglBookPrepareChain = queued.catch(() => null);
|
||||||
|
this.prefetchingWebGLBook.set(key, queued);
|
||||||
|
return queued.finally(() => {
|
||||||
|
if (this.prefetchingWebGLBook.get(key) === queued) {
|
||||||
|
this.prefetchingWebGLBook.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async runWebGLBookPresentationPrepare(sentence, options = {}) {
|
||||||
|
if (!this.isWebGLBookPresentationEligible(sentence)) return null;
|
||||||
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
||||||
if (blockId == null) return null;
|
if (blockId == null) return null;
|
||||||
const bookPlaybackTimeline = this.getModule('book-playback-timeline');
|
const bookPlaybackTimeline = this.getModule('book-playback-timeline');
|
||||||
@@ -912,6 +950,7 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
const segment = await bookPlaybackTimeline.prepareSentence(sentence, {
|
const segment = await bookPlaybackTimeline.prepareSentence(sentence, {
|
||||||
immediate: options.immediate === true
|
immediate: options.immediate === true
|
||||||
});
|
});
|
||||||
|
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
||||||
if (!segment) return null;
|
if (!segment) return null;
|
||||||
sentence.webglBookPresentation = {
|
sentence.webglBookPresentation = {
|
||||||
prepared: true,
|
prepared: true,
|
||||||
@@ -934,6 +973,18 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scheduleLookaheadAfterDisplay(item, queueGeneration = this.queueGeneration) {
|
||||||
|
const run = () => {
|
||||||
|
if (this.isCurrentQueueItem(item, queueGeneration)) {
|
||||||
|
this.prefetchAhead(6, queueGeneration);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
const scheduleIdle = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 180));
|
||||||
|
scheduleIdle(run, { timeout: 260 });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
prefetchAhead(maxLookahead = 6, 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', {
|
||||||
@@ -944,14 +995,33 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let started = 0;
|
let started = 0;
|
||||||
let spokenPrepared = 0;
|
let webglBookLookahead = 0;
|
||||||
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
|
const limit = Math.min(this.sentenceQueue.length, maxLookahead + 1);
|
||||||
|
const allowWebGLBookPrefetch = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||||
|
|
||||||
for (let index = 1; index < limit; index += 1) {
|
for (let index = 1; index < limit; index += 1) {
|
||||||
const nextItem = this.sentenceQueue[index];
|
const nextItem = this.sentenceQueue[index];
|
||||||
const nextCacheKey = this.getCacheKey(nextItem);
|
const nextCacheKey = this.getCacheKey(nextItem);
|
||||||
|
const cachedPrepared = this.preparedSentenceCache.get(nextCacheKey);
|
||||||
|
const webglBookCandidate = this.isWebGLBookPresentationEligible(cachedPrepared || nextItem);
|
||||||
|
const shouldPrepareWebGLBook = allowWebGLBookPrefetch
|
||||||
|
&& webglBookCandidate
|
||||||
|
&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD;
|
||||||
|
if (webglBookCandidate) webglBookLookahead += 1;
|
||||||
|
|
||||||
|
if (cachedPrepared && !this.prefetchingSpeech.has(nextCacheKey)) {
|
||||||
|
if (shouldPrepareWebGLBook && !this.isWebGLBookPresentationPrepared(cachedPrepared)) {
|
||||||
|
this.prefetchWebGLBookPresentation(cachedPrepared, {
|
||||||
|
queueGeneration,
|
||||||
|
queueIndex: index
|
||||||
|
}).catch(err => {
|
||||||
|
console.warn('SentenceQueue: WebGL book prefetch failed:', err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.prefetchingSpeech.has(nextCacheKey)) {
|
if (this.prefetchingSpeech.has(nextCacheKey)) {
|
||||||
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -969,10 +1039,12 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
queueIndex: index
|
queueIndex: index
|
||||||
});
|
});
|
||||||
if (queueGeneration !== this.queueGeneration) return null;
|
if (queueGeneration !== this.queueGeneration) return null;
|
||||||
await this.prefetchWebGLBookPresentation(prepared, {
|
if (shouldPrepareWebGLBook) {
|
||||||
queueGeneration,
|
await this.prefetchWebGLBookPresentation(prepared, {
|
||||||
queueIndex: index
|
queueGeneration,
|
||||||
});
|
queueIndex: index
|
||||||
|
});
|
||||||
|
}
|
||||||
if (queueGeneration !== this.queueGeneration) return null;
|
if (queueGeneration !== this.queueGeneration) return null;
|
||||||
this.preparedSentenceCache.set(nextCacheKey, prepared);
|
this.preparedSentenceCache.set(nextCacheKey, prepared);
|
||||||
return prepared;
|
return prepared;
|
||||||
@@ -997,13 +1069,6 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
this.prefetchingSpeech.set(nextCacheKey, promise);
|
this.prefetchingSpeech.set(nextCacheKey, promise);
|
||||||
started += 1;
|
started += 1;
|
||||||
|
|
||||||
if (this.isSpeechItem(nextItem)) {
|
|
||||||
spokenPrepared += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (spokenPrepared >= 1 && started >= 2) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (started === 0) {
|
if (started === 0) {
|
||||||
@@ -1409,7 +1474,9 @@ class SentenceQueueModule extends BaseModule {
|
|||||||
this.cancelGenerationRequests('sentence-queue-cleared');
|
this.cancelGenerationRequests('sentence-queue-cleared');
|
||||||
this.cancelAssetPreloads('sentence-queue-cleared');
|
this.cancelAssetPreloads('sentence-queue-cleared');
|
||||||
this.prefetchingSpeech.clear();
|
this.prefetchingSpeech.clear();
|
||||||
|
this.prefetchingWebGLBook.clear();
|
||||||
this.preparedSentenceCache.clear();
|
this.preparedSentenceCache.clear();
|
||||||
|
this.webglBookPrepareChain = Promise.resolve();
|
||||||
this.pauseBeforeNextReason = null;
|
this.pauseBeforeNextReason = null;
|
||||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||||
detail: { reason: 'sentence-queue-cleared' }
|
detail: { reason: 'sentence-queue-cleared' }
|
||||||
|
|||||||
@@ -1023,9 +1023,10 @@ class UIDisplayHandlerModule extends BaseModule {
|
|||||||
this.revealImageBlock(element);
|
this.revealImageBlock(element);
|
||||||
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
} else if (sentence.kind === 'paragraph' || sentence.kind === 'heading') {
|
||||||
if (useWebGLBookReveal) {
|
if (useWebGLBookReveal) {
|
||||||
await this.prepareWebGLBookReveal(sentence);
|
await this.playWebGLBookSentence(sentence);
|
||||||
|
} else {
|
||||||
|
await this.playbackCoordinator.play(sentence);
|
||||||
}
|
}
|
||||||
await this.playbackCoordinator.play(sentence);
|
|
||||||
if (useWebGLBookReveal && sentence.blockId != null) {
|
if (useWebGLBookReveal && sentence.blockId != null) {
|
||||||
this.markBlockRendered(sentence.blockId);
|
this.markBlockRendered(sentence.blockId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,6 +308,18 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
normalizeProcessState(state) {
|
normalizeProcessState(state) {
|
||||||
const playbackCoordinator = this.getModule('playback-coordinator');
|
const playbackCoordinator = this.getModule('playback-coordinator');
|
||||||
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
|
const isPlaying = Boolean(playbackCoordinator?.isPlaying);
|
||||||
|
// The player is in control when an input prompt is open AND the book is not actively
|
||||||
|
// playing a sentence (the timeline owns webglBookPlaybackActive). Then the cursor must
|
||||||
|
// show the input/server state, never the playback feather — even if a stale playing-*
|
||||||
|
// state lingers — so strip the playback overlay. While a sentence is actually playing
|
||||||
|
// the feather wins, even if an input mode is still set from the previous turn.
|
||||||
|
const playbackActive = document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||||
|
const awaitingPlayer = !playbackActive && ['choice', 'text', 'end'].includes(this.inputMode);
|
||||||
|
if (awaitingPlayer) {
|
||||||
|
if (state === 'playing-ready') return 'ready';
|
||||||
|
if (state === 'playing-generating') return 'waiting-generating';
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
if (isPlaying && state === 'ready') {
|
if (isPlaying && state === 'ready') {
|
||||||
return 'playing-ready';
|
return 'playing-ready';
|
||||||
@@ -345,6 +357,12 @@ class UIInputHandlerModule extends BaseModule {
|
|||||||
this.setInputModeDataset();
|
this.setInputModeDataset();
|
||||||
const state = document.documentElement.dataset.processState || 'loading';
|
const state = document.documentElement.dataset.processState || 'loading';
|
||||||
this.setInputAvailability(this.inputMode === 'text' && state === 'ready');
|
this.setInputAvailability(this.inputMode === 'text' && state === 'ready');
|
||||||
|
// Opening an input-awaiting prompt hands control to the player; reflect that in the
|
||||||
|
// cursor immediately instead of leaving the prior playback state showing (the live
|
||||||
|
// flow does not always dispatch a fresh process-state when the prompt appears).
|
||||||
|
if (this.inputMode !== 'none') {
|
||||||
|
this.setProcessState('ready', { reason: `input-mode:${this.inputMode}` });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setInputModeDataset() {
|
setInputModeDataset() {
|
||||||
|
|||||||
+136
-31
@@ -7,7 +7,9 @@ import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
|
|||||||
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-l';
|
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-l';
|
||||||
|
|
||||||
const canvas = document.getElementById('scene');
|
const canvas = document.getElementById('scene');
|
||||||
canvas.style.cursor = 'grab';
|
// The canvas inherits the document-level process-state cursor (awaiting input / server /
|
||||||
|
// background / animation) so the 3D scene communicates game state like the overlay does.
|
||||||
|
// A grab cursor is shown only transiently while actively right-drag-rotating the camera.
|
||||||
const tableDebugModes = {
|
const tableDebugModes = {
|
||||||
none: 0,
|
none: 0,
|
||||||
shadow: 1,
|
shadow: 1,
|
||||||
@@ -138,6 +140,7 @@ const dynamicBufferRefreshIntervalMs = 1000 / 30;
|
|||||||
// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing
|
// frames are just the cheap scene render and hold 60fps. Candle flicker is the only thing
|
||||||
// changing them then, which 8Hz captures imperceptibly.
|
// changing them then, which 8Hz captures imperceptibly.
|
||||||
const staticGeometryBufferRefreshIntervalMs = 1000 / 8;
|
const staticGeometryBufferRefreshIntervalMs = 1000 / 8;
|
||||||
|
const revealGeometryBufferRefreshIntervalMs = 1000 / 4;
|
||||||
const flipDynamicBufferGraceMs = 180;
|
const flipDynamicBufferGraceMs = 180;
|
||||||
let lastBookShadowRefreshAt = -Infinity;
|
let lastBookShadowRefreshAt = -Infinity;
|
||||||
let lastTableReflectionRefreshAt = -Infinity;
|
let lastTableReflectionRefreshAt = -Infinity;
|
||||||
@@ -182,6 +185,8 @@ const lastFrameTiming = {};
|
|||||||
const slowFrameLog = [];
|
const slowFrameLog = [];
|
||||||
const loaderTimings = {};
|
const loaderTimings = {};
|
||||||
const pageTextureTimings = [];
|
const pageTextureTimings = [];
|
||||||
|
let queuedNavigationPrewarm = null;
|
||||||
|
let queuedNavigationPrewarmHandle = null;
|
||||||
|
|
||||||
function markLoaderTiming(name) {
|
function markLoaderTiming(name) {
|
||||||
loaderTimings[name] = performance.now();
|
loaderTimings[name] = performance.now();
|
||||||
@@ -383,6 +388,10 @@ const materials = {
|
|||||||
}),
|
}),
|
||||||
flipPageSurface: new THREE.MeshStandardMaterial({
|
flipPageSurface: new THREE.MeshStandardMaterial({
|
||||||
color: 0xeee6cc,
|
color: 0xeee6cc,
|
||||||
|
map: getBlankPageTexture(),
|
||||||
|
normalMap: paperTextures.normal,
|
||||||
|
normalScale: new THREE.Vector2(0.004, 0.004),
|
||||||
|
roughnessMap: paperTextures.roughness,
|
||||||
roughness: 0.92,
|
roughness: 0.92,
|
||||||
metalness: 0,
|
metalness: 0,
|
||||||
emissive: 0x100d08,
|
emissive: 0x100d08,
|
||||||
@@ -438,6 +447,8 @@ const materials = {
|
|||||||
};
|
};
|
||||||
materials.flipPageBackSurface = materials.flipPageSurface.clone();
|
materials.flipPageBackSurface = materials.flipPageSurface.clone();
|
||||||
materials.flipPageBackSurface.map = getBlankPageTexture();
|
materials.flipPageBackSurface.map = getBlankPageTexture();
|
||||||
|
materials.flipPageBackSurface.normalMap = paperTextures.normal;
|
||||||
|
materials.flipPageBackSurface.roughnessMap = paperTextures.roughness;
|
||||||
materials.flipPageBackSurface.side = THREE.FrontSide;
|
materials.flipPageBackSurface.side = THREE.FrontSide;
|
||||||
materials.flipPageEdge = materials.pageSurface.clone();
|
materials.flipPageEdge = materials.pageSurface.clone();
|
||||||
materials.flipPageEdge.map = paperTextures.edge;
|
materials.flipPageEdge.map = paperTextures.edge;
|
||||||
@@ -620,6 +631,15 @@ window.BookLabDebug = {
|
|||||||
requestPageFlip(direction = 1, options = {}) {
|
requestPageFlip(direction = 1, options = {}) {
|
||||||
return startPageFlip(direction, options);
|
return startPageFlip(direction, options);
|
||||||
},
|
},
|
||||||
|
async prewarmPageFlip(direction = 1, options = {}) {
|
||||||
|
const targetSpread = Number.isFinite(Number(options.targetSpread))
|
||||||
|
? Math.max(0, Math.round(Number(options.targetSpread)))
|
||||||
|
: null;
|
||||||
|
return prewarmFlipTextures(direction, targetSpread);
|
||||||
|
},
|
||||||
|
startPreparedPageFlip(direction = 1, options = {}) {
|
||||||
|
return startPageFlipPrepared(direction, options);
|
||||||
|
},
|
||||||
getRevealDebugState() {
|
getRevealDebugState() {
|
||||||
return getRevealDebugState();
|
return getRevealDebugState();
|
||||||
},
|
},
|
||||||
@@ -678,7 +698,9 @@ if (webglBookSceneModule) {
|
|||||||
setPageReserve: (value) => setPageReserve(value),
|
setPageReserve: (value) => setPageReserve(value),
|
||||||
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
|
setMaxVisitedPagePosition: (value) => window.BookLabDebug.setMaxVisitedPagePosition(value),
|
||||||
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
|
redrawPageTextures: () => window.BookLabDebug.redrawPageTextures(),
|
||||||
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY)
|
projectPointerToPage: (clientX, clientY) => projectPointerToPage(clientX, clientY),
|
||||||
|
prewarmPageFlip: (direction = 1, options = {}) => window.BookLabDebug.prewarmPageFlip(direction, options),
|
||||||
|
startPreparedPageFlip: (direction = 1, options = {}) => window.BookLabDebug.startPreparedPageFlip(direction, options)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -714,6 +736,15 @@ document.addEventListener('story:client-reset', () => {
|
|||||||
pageRevealFreezeAt = null;
|
pageRevealFreezeAt = null;
|
||||||
clearPageReveal('left', 'client-reset');
|
clearPageReveal('left', 'client-reset');
|
||||||
clearPageReveal('right', 'client-reset');
|
clearPageReveal('right', 'client-reset');
|
||||||
|
// Return the book to the title spread so the new game's first block flips in from the
|
||||||
|
// title page. Otherwise the view stays on the previous game's spread, the segment's
|
||||||
|
// source and target spread match, and the title->content page turn is skipped.
|
||||||
|
if (Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))) !== 0) {
|
||||||
|
bookPaginationState = { ...bookPaginationState, spreadIndex: 0 };
|
||||||
|
const titleSpread = getPaginationSpread(0);
|
||||||
|
if (titleSpread) window.BookTextureRenderer?.drawSpread?.(titleSpread, ['left', 'right'], { force: true });
|
||||||
|
syncBookControls();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
// Pagination spread updates only carry state. The playback owner decides when the
|
// 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
|
// visible spread changes (via flips). The scene jumps directly only for non-playback
|
||||||
@@ -1919,14 +1950,24 @@ function getCurrentPagePosition() {
|
|||||||
function getMaxNavigableSpread() {
|
function getMaxNavigableSpread() {
|
||||||
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1)));
|
||||||
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
|
const visitedSpread = pageToSpreadIndex(maxVisitedPagePosition);
|
||||||
return Math.max(0, Math.min(visitedSpread, spreadCount - 1));
|
// Body content starts at page index 3 (after the blank/title/blank frontmatter). Until any
|
||||||
|
// content is written the book stays on the title spread — no flipping forward into blank
|
||||||
|
// leaves. Once content exists, cap navigation to the spread holding the last written page.
|
||||||
|
const writtenPageLimit = Math.max(0, Number(bookPaginationState.writtenPageLimit || 0));
|
||||||
|
const contentSpread = writtenPageLimit >= 3 ? pageToSpreadIndex(writtenPageLimit) : 0;
|
||||||
|
return Math.max(0, Math.min(visitedSpread, contentSpread, spreadCount - 1));
|
||||||
}
|
}
|
||||||
|
|
||||||
// The page-number readout shows the odd (right) page of the visible pair, or 0 at the
|
// Title spread reads 0; every other spread reads the printed page number of its right page.
|
||||||
// title spread.
|
// Frontmatter (blank/title/blank) occupies page indices 0-2 and is unnumbered, so the first
|
||||||
|
// body page (index 3) prints as 1 and right-page index N prints as N-2. Prefer the paginated
|
||||||
|
// page number when present, otherwise derive it from the index.
|
||||||
function spreadPageLabel(spreadIndex) {
|
function spreadPageLabel(spreadIndex) {
|
||||||
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
const spread = Math.max(0, Math.round(Number(spreadIndex || 0)));
|
||||||
return spread <= 0 ? '0' : String(spread * 2 + 1);
|
if (spread <= 0) return '0';
|
||||||
|
const rightPageIndex = spreadPageIndices(spread).right;
|
||||||
|
const pageNumber = getPaginationPageMeta(rightPageIndex)?.pageNumber;
|
||||||
|
return pageNumber != null ? String(pageNumber) : String(Math.max(0, rightPageIndex - 2));
|
||||||
}
|
}
|
||||||
|
|
||||||
function scheduleBookRebuild(reason = 'scheduled') {
|
function scheduleBookRebuild(reason = 'scheduled') {
|
||||||
@@ -2099,6 +2140,16 @@ function ensureBottomNavigation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function navigateToSpread(targetSpread) {
|
function navigateToSpread(targetSpread) {
|
||||||
|
if (isManualBookNavigationBusy()) {
|
||||||
|
markPageTextureTiming('navigation:blocked-busy', {
|
||||||
|
targetSpread,
|
||||||
|
activeFlips: activeFlips.length,
|
||||||
|
revealActive: hasActivePageReveal(),
|
||||||
|
playbackActive: document.documentElement.dataset.webglBookPlaybackActive === 'true'
|
||||||
|
});
|
||||||
|
syncBookControls();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
const maxSpread = getMaxNavigableSpread();
|
const maxSpread = getMaxNavigableSpread();
|
||||||
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
|
const target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread);
|
||||||
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0)));
|
||||||
@@ -2128,7 +2179,7 @@ function navigateByPageDelta(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncBookControls() {
|
function syncBookControls() {
|
||||||
const busy = activeFlips.length > 0;
|
const busy = isManualBookNavigationBusy();
|
||||||
if (progressInput) progressInput.value = readingProgress.toFixed(3);
|
if (progressInput) progressInput.value = readingProgress.toFixed(3);
|
||||||
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
|
if (progressValue) progressValue.textContent = readingProgress.toFixed(2);
|
||||||
if (pageCountInput) pageCountInput.value = String(bookPageCount);
|
if (pageCountInput) pageCountInput.value = String(bookPageCount);
|
||||||
@@ -2157,10 +2208,12 @@ function syncBottomNavigation() {
|
|||||||
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
|
bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1');
|
||||||
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
bottomNavigation.root.dataset.bookSize = String(bookPageCount);
|
||||||
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
bottomNavigation.root.dataset.pageReserve = String(pageReserve);
|
||||||
bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
const busy = isManualBookNavigationBusy();
|
||||||
bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0;
|
bottomNavigation.slider.disabled = busy;
|
||||||
bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
bottomNavigation.startButton.disabled = busy || currentSpread <= 0;
|
||||||
bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread;
|
bottomNavigation.backButton.disabled = busy || currentSpread <= 0;
|
||||||
|
bottomNavigation.forwardButton.disabled = busy || currentSpread >= maxSpread;
|
||||||
|
bottomNavigation.endButton.disabled = busy || currentSpread >= maxSpread;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePageTextureRecords(event) {
|
function handlePageTextureRecords(event) {
|
||||||
@@ -2219,19 +2272,18 @@ function handlePageTextureRecords(event) {
|
|||||||
if (!detail.right && effectivePageMeta.right?.kind === 'blank') {
|
if (!detail.right && effectivePageMeta.right?.kind === 'blank') {
|
||||||
applyExplicitBlankPageTexture('right', effectivePageMeta.right, 'page-texture-records');
|
applyExplicitBlankPageTexture('right', effectivePageMeta.right, 'page-texture-records');
|
||||||
}
|
}
|
||||||
markStaticSceneBuffersDirty();
|
// A page-texture content change moves no geometry, so it must NOT force the AO/shadow/
|
||||||
|
// reflection recompute (that produced 23-42ms frames on every block during playback).
|
||||||
|
// AO and shadows depend only on geometry; the soft tabletop reflection picks up the new
|
||||||
|
// page on its normal throttled cadence. Only geometry changes (flips, camera, rebuild,
|
||||||
|
// resize) dirty the static buffers.
|
||||||
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
|
document.documentElement.dataset.webglPageTextureMetrics = JSON.stringify({
|
||||||
width: leftCanvas.width,
|
width: leftCanvas.width,
|
||||||
height: leftCanvas.height,
|
height: leftCanvas.height,
|
||||||
source: 'book-texture-renderer'
|
source: 'book-texture-renderer'
|
||||||
});
|
});
|
||||||
markPageTextureTiming('handlePageTextureRecords:end');
|
markPageTextureTiming('handlePageTextureRecords:end');
|
||||||
prewarmNavigationTextureWindow('page-texture-records', { recordMiss: false }).catch((error) => {
|
scheduleNavigationTextureWindowPrewarm('page-texture-records', { recordMiss: false });
|
||||||
pageTextureStore?.recordProblem?.({
|
|
||||||
type: 'navigation-window-prewarm-error',
|
|
||||||
message: error?.message || String(error)
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePageTextureRecordDetail(detail = {}) {
|
function normalizePageTextureRecordDetail(detail = {}) {
|
||||||
@@ -2435,13 +2487,44 @@ async function prewarmNavigationTextureWindow(reason = 'navigation-window', opti
|
|||||||
return result || {};
|
return result || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scheduleNavigationTextureWindowPrewarm(reason = 'navigation-window', options = {}) {
|
||||||
|
queuedNavigationPrewarm = {
|
||||||
|
reason,
|
||||||
|
options: { ...(options || {}) }
|
||||||
|
};
|
||||||
|
if (queuedNavigationPrewarmHandle !== null) return;
|
||||||
|
const run = () => {
|
||||||
|
queuedNavigationPrewarmHandle = null;
|
||||||
|
const queued = queuedNavigationPrewarm;
|
||||||
|
queuedNavigationPrewarm = null;
|
||||||
|
if (!queued) return;
|
||||||
|
if (activeFlips.length > 0 || hasActivePageReveal()) {
|
||||||
|
scheduleNavigationTextureWindowPrewarm(queued.reason, queued.options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
prewarmNavigationTextureWindow(queued.reason, queued.options).catch((error) => {
|
||||||
|
pageTextureStore?.recordProblem?.({
|
||||||
|
type: 'navigation-window-prewarm-error',
|
||||||
|
message: error?.message || String(error)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (typeof window.requestIdleCallback === 'function') {
|
||||||
|
queuedNavigationPrewarmHandle = window.requestIdleCallback(run, { timeout: 350 });
|
||||||
|
} else {
|
||||||
|
queuedNavigationPrewarmHandle = window.setTimeout(run, 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function prewarmFlipTextures(direction, targetSpread = null) {
|
async function prewarmFlipTextures(direction, targetSpread = null) {
|
||||||
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
|
const currentSpread = Math.max(0, Number(bookPaginationState.spreadIndex || 0));
|
||||||
const nextSpread = Number.isFinite(Number(targetSpread))
|
const nextSpread = Number.isFinite(Number(targetSpread))
|
||||||
? Math.max(0, Math.round(Number(targetSpread)))
|
? Math.max(0, Math.round(Number(targetSpread)))
|
||||||
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
|
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
|
||||||
prepareSpreadTextureRecordsForFlip(currentSpread);
|
// Await the (now async, worker-backed) draws so the spreads are resident before the cache
|
||||||
prepareSpreadTextureRecordsForFlip(nextSpread);
|
// lookup below — otherwise the flip can race ahead and find a missing texture.
|
||||||
|
await prepareSpreadTextureRecordsForFlip(currentSpread);
|
||||||
|
await prepareSpreadTextureRecordsForFlip(nextSpread);
|
||||||
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
|
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
|
||||||
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
|
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
|
||||||
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
|
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
|
||||||
@@ -2451,11 +2534,11 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function prepareSpreadTextureRecordsForFlip(spreadIndex) {
|
async function prepareSpreadTextureRecordsForFlip(spreadIndex) {
|
||||||
const spread = getPaginationSpread(spreadIndex);
|
const spread = getPaginationSpread(spreadIndex);
|
||||||
if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false;
|
if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false;
|
||||||
if (spreadTextureRecordsReady(spread)) return true;
|
if (spreadTextureRecordsReady(spread)) return true;
|
||||||
window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
|
await window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
|
||||||
phase: 'prepare'
|
phase: 'prepare'
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
@@ -2537,10 +2620,7 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
|
|||||||
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null);
|
const baseTexture = prepared?.baseTexture || (revealDetail?.baseCanvas ? pageTextureStore?.createTextureFromCanvas?.(revealDetail.baseCanvas) : null);
|
||||||
|
|
||||||
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
|
const revealBlockIds = Array.isArray(revealDetail.blockIds) ? revealDetail.blockIds.map(value => String(value)) : [];
|
||||||
const activeStartedAt = revealBlockIds
|
const activeStartedAt = getRevealStartTimeForBlockIds(revealBlockIds);
|
||||||
.map(blockId => activeRevealBlockStarts.get(blockId))
|
|
||||||
.filter(value => Number.isFinite(Number(value)))
|
|
||||||
.sort((a, b) => a - b)[0] ?? null;
|
|
||||||
|
|
||||||
pageRevealState[side] = {
|
pageRevealState[side] = {
|
||||||
startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null),
|
startedAt: activeStartedAt ?? (revealDetail.startNow ? performance.now() : null),
|
||||||
@@ -2592,6 +2672,22 @@ function beginPageReveal(side, sourceCanvas, revealDetail = {}) {
|
|||||||
markPageTextureTiming('revealUpload:end', { side });
|
markPageTextureTiming('revealUpload:end', { side });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRevealStartTimeForBlockIds(blockIds = []) {
|
||||||
|
const startedAt = (Array.isArray(blockIds) ? blockIds : [])
|
||||||
|
.map(blockId => activeRevealBlockStarts.get(String(blockId)))
|
||||||
|
.filter(value => Number.isFinite(Number(value)))
|
||||||
|
.sort((a, b) => a - b)[0] ?? null;
|
||||||
|
if (startedAt !== null) return startedAt;
|
||||||
|
const pendingBlockId = (Array.isArray(blockIds) ? blockIds : [])
|
||||||
|
.map(blockId => String(blockId))
|
||||||
|
.find(blockId => pendingRevealStartBlockIds.has(blockId));
|
||||||
|
if (!pendingBlockId) return null;
|
||||||
|
const now = performance.now();
|
||||||
|
activeRevealBlockStarts.set(pendingBlockId, now);
|
||||||
|
pendingRevealStartBlockIds.delete(pendingBlockId);
|
||||||
|
return now;
|
||||||
|
}
|
||||||
|
|
||||||
function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
|
function applyPendingPageReveal(side, shader = getPageRevealShader(side)) {
|
||||||
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
const material = side === 'left' ? materials.leftPage : materials.rightPage;
|
||||||
const revealDetail = material?.userData?.pendingPageReveal;
|
const revealDetail = material?.userData?.pendingPageReveal;
|
||||||
@@ -3133,8 +3229,6 @@ function prepareStaticPageForFlip(flip, prewarm = null) {
|
|||||||
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
materials.flipPageBackSurface.normalMap = materials.pageSurface.normalMap;
|
||||||
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
materials.flipPageSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||||
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
materials.flipPageBackSurface.roughnessMap = materials.pageSurface.roughnessMap;
|
||||||
materials.flipPageSurface.needsUpdate = true;
|
|
||||||
materials.flipPageBackSurface.needsUpdate = true;
|
|
||||||
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
|
syncFlipRevealShaderFromSource(sourceSide, materials.flipPageSurface);
|
||||||
syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface);
|
syncFlipRevealShaderFromSource(targetBackSide, materials.flipPageBackSurface);
|
||||||
flip.sourceTexture = sourceTexture;
|
flip.sourceTexture = sourceTexture;
|
||||||
@@ -3267,6 +3361,12 @@ function hasActivePageReveal() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isManualBookNavigationBusy() {
|
||||||
|
return activeFlips.length > 0
|
||||||
|
|| hasActivePageReveal()
|
||||||
|
|| document.documentElement.dataset.webglBookPlaybackActive === 'true';
|
||||||
|
}
|
||||||
|
|
||||||
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
|
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) {
|
||||||
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
|
const skipSides = Array.isArray(options.skipSides) ? options.skipSides : [];
|
||||||
const pageIndices = spreadPageIndices(spreadIndex);
|
const pageIndices = spreadPageIndices(spreadIndex);
|
||||||
@@ -4432,7 +4532,7 @@ function installCameraControls() {
|
|||||||
cameraRig.dragging = false;
|
cameraRig.dragging = false;
|
||||||
cameraRig.navigationActive = false;
|
cameraRig.navigationActive = false;
|
||||||
cameraRig.keys.clear();
|
cameraRig.keys.clear();
|
||||||
canvas.style.cursor = 'grab';
|
canvas.style.cursor = '';
|
||||||
canvas.releasePointerCapture(event.pointerId);
|
canvas.releasePointerCapture(event.pointerId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4440,7 +4540,7 @@ function installCameraControls() {
|
|||||||
cameraRig.dragging = false;
|
cameraRig.dragging = false;
|
||||||
cameraRig.navigationActive = false;
|
cameraRig.navigationActive = false;
|
||||||
cameraRig.keys.clear();
|
cameraRig.keys.clear();
|
||||||
canvas.style.cursor = 'grab';
|
canvas.style.cursor = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
canvas.addEventListener('wheel', (event) => {
|
canvas.addEventListener('wheel', (event) => {
|
||||||
@@ -4725,7 +4825,12 @@ function animate(now = performance.now()) {
|
|||||||
: Infinity;
|
: Infinity;
|
||||||
const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs;
|
const deferDynamicBuffersForFlipStart = activeFlips.length > 0 && newestFlipAge < flipDynamicBufferGraceMs;
|
||||||
const geometryAnimating = activeFlips.length > 0;
|
const geometryAnimating = activeFlips.length > 0;
|
||||||
const bufferRefreshIntervalMs = geometryAnimating ? dynamicBufferRefreshIntervalMs : staticGeometryBufferRefreshIntervalMs;
|
const revealAnimating = hasActivePageReveal();
|
||||||
|
const bufferRefreshIntervalMs = geometryAnimating
|
||||||
|
? dynamicBufferRefreshIntervalMs
|
||||||
|
: revealAnimating
|
||||||
|
? revealGeometryBufferRefreshIntervalMs
|
||||||
|
: staticGeometryBufferRefreshIntervalMs;
|
||||||
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
|
const shadowRefreshDue = !deferDynamicBuffersForFlipStart && (
|
||||||
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs
|
forceDynamicBufferRefresh || now - lastBookShadowRefreshAt >= bufferRefreshIntervalMs
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -365,14 +365,14 @@ class WebGLBookSceneModule extends BaseModule {
|
|||||||
|
|
||||||
async initializeScene() {
|
async initializeScene() {
|
||||||
if (this.labImportPromise) return this.labImportPromise;
|
if (this.labImportPromise) return this.labImportPromise;
|
||||||
const cacheBuster = window.MODULE_CACHE_BUSTER || Date.now();
|
const moduleVersion = window.MODULE_CACHE_BUSTER || 'dev';
|
||||||
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(cacheBuster)}`);
|
this.labImportPromise = import(`/js/webgl-book-lab.js?v=${encodeURIComponent(moduleVersion)}`);
|
||||||
await this.labImportPromise;
|
await this.labImportPromise;
|
||||||
this.reportProgress(94, 'Uploading initial book page textures');
|
this.reportProgress(94, 'Uploading initial book page textures');
|
||||||
const pagination = this.getModule('book-pagination');
|
const pagination = this.getModule('book-pagination');
|
||||||
const initialSpread = pagination?.getCurrentSpread?.();
|
const initialSpread = pagination?.getCurrentSpread?.();
|
||||||
if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') {
|
if (initialSpread && typeof window.BookTextureRenderer?.drawSpread === 'function') {
|
||||||
window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true });
|
await window.BookTextureRenderer.drawSpread(initialSpread, ['left', 'right'], { force: true });
|
||||||
} else {
|
} else {
|
||||||
window.BookTextureRenderer?.publishSpread?.();
|
window.BookTextureRenderer?.publishSpread?.();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ const bookPlaybackTimelinePath = path.join(__dirname, '..', 'public', 'js', 'boo
|
|||||||
const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8');
|
const bookPlaybackTimelineSource = fs.readFileSync(bookPlaybackTimelinePath, 'utf8');
|
||||||
const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js');
|
const ttsFactoryPath = path.join(__dirname, '..', 'public', 'js', 'tts-factory-module.js');
|
||||||
const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8');
|
const ttsFactorySource = fs.readFileSync(ttsFactoryPath, 'utf8');
|
||||||
|
const textureWorkerPath = path.join(__dirname, '..', 'public', 'js', 'book-texture-worker.js');
|
||||||
|
const textureWorkerSource = fs.readFileSync(textureWorkerPath, 'utf8');
|
||||||
|
|
||||||
function dependencyList(source, moduleId) {
|
function dependencyList(source, moduleId) {
|
||||||
const classStart = source.indexOf(`super('${moduleId}'`);
|
const classStart = source.indexOf(`super('${moduleId}'`);
|
||||||
@@ -127,6 +129,7 @@ const checks = [
|
|||||||
['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealRegionCount/.test(source)],
|
['webgl lab exposes reveal uniform diagnostics', /getRevealDebugState/.test(source) && /bookRevealActive/.test(source) && /bookRevealElapsedMs/.test(source) && /bookRevealRegionCount/.test(source)],
|
||||||
['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)],
|
['webgl lab records page reveal clear reasons', /clearPageReveal\(side, reason/.test(source) && /webglRevealClearLog/.test(source)],
|
||||||
['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)],
|
['webgl reveal clock starts on first render frame', /pendingStart/.test(source) && /state\.pendingStart/.test(source) && /state\.startedAt = now/.test(source)],
|
||||||
|
['webgl reveal start survives event-before-state ordering', /function getRevealStartTimeForBlockIds/.test(source) && /activeRevealBlockStarts\.set\(pendingBlockId, now\)/.test(source) && /pendingRevealStartBlockIds\.delete\(pendingBlockId\)/.test(source)],
|
||||||
['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)],
|
['webgl reveal visual clock is derived from absolute playback time', /visualElapsedMs/.test(source) && /activeRevealBlockStarts/.test(source) && /state\.visualElapsedMs = Math\.max\(0, now - state\.startedAt\)/.test(source) && !/revealFrameDeltaMs/.test(source)],
|
||||||
['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)],
|
['webgl fast-forward accelerates reveal instead of clearing the mask immediately', /fastForwarding/.test(source) && /fastForwardDurationMs/.test(source) && !/clearPageReveal\(side, 'fast-forward'\)/.test(source)],
|
||||||
['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)],
|
['webgl lab records page texture binding timings', /pageTextureTimings/.test(source) && /markPageTextureTiming/.test(source) && /webglPageTextureTimings/.test(source)],
|
||||||
@@ -137,10 +140,14 @@ const checks = [
|
|||||||
['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.test(textureRendererSource)],
|
['texture renderer diagnostics include reveal region counts', /regionCounts/.test(textureRendererSource) && /lineRects/.test(textureRendererSource) && /durationMs/.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]*immediate: true[\s\S]*this\.prefetchAhead\(6, queueGeneration\);[\s\S]*this\.onSentenceReadyCallback/.test(sentenceQueueSource)],
|
['sentence queue starts future lookahead only after current display playback is entered and idle', /const sentence = await this\.getPreparedSentence\(item\);[\s\S]*await this\.prefetchWebGLBookPresentation\(sentence[\s\S]*immediate: true[\s\S]*const playbackFinished = new Promise/.test(sentenceQueueSource) && /this\.onSentenceReadyCallback\(sentence, resolve\);[\s\S]*this\.scheduleLookaheadAfterDisplay\(item, queueGeneration\);[\s\S]*await playbackFinished/.test(sentenceQueueSource) && /scheduleLookaheadAfterDisplay\(item, queueGeneration = this\.queueGeneration\) \{[\s\S]*this\.prefetchAhead\(6, queueGeneration\)[\s\S]*requestAnimationFrame[\s\S]*requestIdleCallback/.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\(6, 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)],
|
['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)],
|
||||||
|
['sentence queue serializes heavy WebGL book preparation separately from speech prefetch', /prefetchingWebGLBook = new Map/.test(sentenceQueueSource) && /webglBookPrepareChain = Promise\.resolve\(\)/.test(sentenceQueueSource) && /this\.webglBookPrepareChain[\s\S]*\.then\(\(\) => this\.runWebGLBookPresentationPrepare/.test(sentenceQueueSource)],
|
||||||
|
['sentence queue caps WebGL book lookahead without capping TTS lookahead window', /const WEBGL_BOOK_PREFETCH_LOOKAHEAD = 1/.test(sentenceQueueSource) && /webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.test(sentenceQueueSource) && !/spokenPrepared >= 1 && started >= 2/.test(sentenceQueueSource)],
|
||||||
|
['texture worker sends the static paper base bitmap once per side and the renderer reuses it', /sentBaseKeys/.test(textureWorkerSource) && /const baseKey = `\$\{side\}:\$\{width\}x\$\{height\}`/.test(textureWorkerSource) && /this\.cachedBaseCanvas\[side\] = this\.canvasFromBitmap/.test(textureRendererSource) && /this\.revealBaseCanvases\[side\] = this\.cachedBaseCanvas\?\.\[side\]/.test(textureRendererSource)],
|
||||||
|
['sentence queue gates WebGL book lookahead to active 3D playback only', /const allowWebGLBookPrefetch = document\.documentElement\.dataset\.webglBookPlaybackActive === 'true'/.test(sentenceQueueSource) && /const shouldPrepareWebGLBook = allowWebGLBookPrefetch[\s\S]*&& webglBookCandidate[\s\S]*&& webglBookLookahead < WEBGL_BOOK_PREFETCH_LOOKAHEAD/.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 stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && !/hasPreparedRevealBlock/.test(textureRendererSource)],
|
['texture renderer stores prepared reveal plans in the shared texture store', !/preparedRevealCache/.test(textureRendererSource) && /rememberPreparedRevealPlan/.test(webglPageCacheSource) && /takePreparedRevealPlan/.test(textureRendererSource) && /publishPreparedReveal/.test(textureRendererSource) && !/hasPreparedRevealBlock/.test(textureRendererSource)],
|
||||||
@@ -158,9 +165,13 @@ const checks = [
|
|||||||
['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)],
|
||||||
|
['texture renderer front-loads worker fonts before the first draw so a cold render is not cut short by the timeout', /fonts-ready/.test(textureWorkerSource) && /this\.resolveFontsReady/.test(textureRendererSource) && /await this\.waitForWorkerFonts\(\)/.test(textureRendererSource) && /await this\.drawSpread\(this\.currentSpread\)/.test(textureRendererSource)],
|
||||||
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
|
['sentence queue skips duplicate current-item 3D book presentation when reveal is cached', /isWebGLBookPresentationPrepared/.test(sentenceQueueSource) && /if \(!this\.isWebGLBookPresentationPrepared\(sentence\)\) \{\s*await this\.prefetchWebGLBookPresentation/.test(sentenceQueueSource) && /sentence\.webglBookPresentation = \{\s*prepared: true/.test(sentenceQueueSource)],
|
||||||
['3D overflow reveal commits the spread then requests a timeline flip via event before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /addEventListener\('webgl-book:request-page-flip'/.test(source) && /startPageFlip\(direction, \{/.test(source)],
|
['3D overflow reveal commits the spread then starts a prepared timeline flip before activating', /requiresSpreadTransition\(segment\)/.test(bookPlaybackTimelineSource) && /this\.commitSegmentSpread\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /this\.requestPageFlip\(1, \{[\s\S]*targetSpread: segment\.targetSpreadIndex/.test(bookPlaybackTimelineSource) && /this\.activatePreparedSegment\(segment, sentence\)/.test(bookPlaybackTimelineSource) && /sceneControl\.prewarmPageFlip/.test(bookPlaybackTimelineSource) && /sceneControl\.startPreparedPageFlip/.test(bookPlaybackTimelineSource) && !/dispatchEvent\(new CustomEvent\('webgl-book:request-page-flip'/.test(bookPlaybackTimelineSource) && /prewarmPageFlip: \(direction = 1, options = \{\}\)/.test(source) && /startPreparedPageFlip: \(direction = 1, options = \{\}\)/.test(source)],
|
||||||
['texture renderer paints inline bold and italic styles', /getInlineStyleState/.test(textureRendererSource) && /updateInlineStyleState/.test(textureRendererSource) && /getCanvasFont/.test(textureRendererSource) && /segment\?\.style/.test(textureRendererSource)],
|
['texture worker paints inline bold and italic styles off the main thread', /getInlineStyleState/.test(textureWorkerSource) && /updateInlineStyleState/.test(textureWorkerSource) && /getCanvasFont/.test(textureWorkerSource) && /segment\.style/.test(textureWorkerSource) && !/drawLine\(ctx/.test(textureRendererSource)],
|
||||||
|
['texture renderer delegates page rasterization to an OffscreenCanvas worker and blits the result', /book-texture-worker\.js/.test(textureRendererSource) && /rasterizeSpread/.test(textureRendererSource) && /ctx\.drawImage\(result\.pageBitmap, 0, 0\)/.test(textureRendererSource) && /OffscreenCanvas/.test(textureWorkerSource) && /createImageBitmap/.test(textureWorkerSource)],
|
||||||
|
['texture renderer recovers from worker error/timeout so a draw promise never hangs the chain', /this\.rasterWorker\.onerror/.test(textureRendererSource) && /texture-worker-timeout/.test(textureRendererSource) && /settleRasterization/.test(textureRendererSource) && /clearTimeout\(pending\.timer\)/.test(textureRendererSource)],
|
||||||
|
['flip prewarm awaits the async worker draw before the resident-texture lookup', /await prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /await window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\]/.test(source)],
|
||||||
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
['webgl lab can preload page textures without swapping visible page material through texture store', /preparePageTexture\(side = 'left'/.test(webglPageCacheSource) && /takePreparedPageTexture\(side = 'left'/.test(webglPageCacheSource) && /renderer\.initTexture\(texture\)/.test(webglPageCacheSource) && /takePreparedPageTexture/.test(source) && !/const preparedPageTextures/.test(source)],
|
||||||
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
['webgl page text textures avoid mipmap generation', /function configurePageCanvasTexture/.test(source) && /texture\.minFilter = THREE\.LinearFilter/.test(source) && /texture\.generateMipmaps = false/.test(source)],
|
||||||
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
['webgl reveal shader masks against a base-page texture instead of flat color blocks', /bookRevealBaseMap/.test(source) && /bookRevealUseBaseMap/.test(source) && /revealBaseColor/.test(source) && /baseCanvas/.test(textureRendererSource)],
|
||||||
@@ -169,7 +180,7 @@ const checks = [
|
|||||||
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
|
['webgl page format reduces only outer margins from previous value', /outerBaseIn: 0\.27/.test(pageFormatSource) && /outerThicknessFactor: 0\.015/.test(pageFormatSource) && /outerMaxIn: 0\.315/.test(pageFormatSource) && /innerBaseIn: 0\.42/.test(pageFormatSource)],
|
||||||
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
|
['webgl mode enlarges and inverts DOM overlay text without touching 2D mode', /body\.webgl-mode \{[\s\S]*font-size: 18px;/.test(styleSource) && /body\.webgl-mode \.choice-list \.choice-button/.test(styleSource) && /rgba\(246, 231, 201/.test(styleSource)],
|
||||||
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
|
['webgl choice overlay hides title clutter and prevents horizontal scrollbar', /body\.webgl-mode #page_left #game_title/.test(styleSource) && /body\.webgl-mode #page_left #start_prompt/.test(styleSource) && /overflow-x: hidden/.test(styleSource) && /book\.style\.width = 'min\(44rem/.test(webglSceneSource)],
|
||||||
['3D live text bypasses #page_right DOM rendering and uses book texture reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
|
['3D live text bypasses #page_right DOM rendering and uses the timeline-owned book reveal directly', /const useWebGLBookReveal = this\.isWebGLMode\(\) && \(sentence\.kind === 'paragraph' \|\| sentence\.kind === 'heading'\)/.test(uiDisplayHandlerSource) && /if \(useWebGLBookReveal\) \{[\s\S]*await this\.playWebGLBookSentence\(sentence\);[\s\S]*this\.markBlockRendered\(sentence\.blockId\);[\s\S]*return null;/.test(uiDisplayHandlerSource) && !/if \(useWebGLBookReveal\) \{[\s\S]*await this\.prepareWebGLBookReveal\(sentence\);[\s\S]*await this\.playbackCoordinator\.play\(sentence\);[\s\S]*return null;/.test(uiDisplayHandlerSource)],
|
||||||
['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")],
|
['drop-cap remaining text does not reinsert discretionary hyphen markers', /extractRemainingLayoutText/.test(bookPaginationSource) && !bookPaginationSource.includes("fragments.push('|')")],
|
||||||
['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)],
|
['drop-cap reservation keeps a normal text gap beside the initial', /measureDropCapReservation/.test(bookPaginationSource) && /measureNormalTextGap\(fontPx\)/.test(bookPaginationSource)],
|
||||||
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
|
['drop-cap reservation uses both ink bounds and font advance width', /const advanceWidth = metrics\.width \|\| 0/.test(bookPaginationSource) && /Math\.max\(inkRight, advanceWidth, lineHeightPx \* 1\.08\)/.test(bookPaginationSource)],
|
||||||
@@ -181,17 +192,18 @@ const checks = [
|
|||||||
['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)],
|
['pagination normalizes every spread to explicit left and right page records', /normalizePagesForSpreads/.test(bookPaginationSource) && /const lastSpreadRightIndex/.test(bookPaginationSource) && /this\.createBlankPage\(index/.test(bookPaginationSource) && /normalizedPages\.forEach/.test(bookPaginationSource)],
|
||||||
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
|
['texture renderer adopts initial pagination spread so title page is painted after loader order', /this\.currentSpread = this\.pagination\?\.getCurrentSpread\?\.\(\) \|\| \{ index: 0/.test(textureRendererSource) && /this\.drawSpread\(this\.currentSpread\);/.test(textureRendererSource)],
|
||||||
['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 versioned page metadata', /drawTitlePage/.test(textureRendererSource) && /game_title/.test(textureRendererSource) && /drawPageNumber/.test(textureRendererSource) && /pageMeta: this\.buildPublishPageMeta\(sidesToPublish\)/.test(textureRendererSource)],
|
['texture worker draws title page and page numbers; renderer marshals title data and versioned page metadata', /drawTitlePage/.test(textureWorkerSource) && /drawPageNumber/.test(textureWorkerSource) && /game_title/.test(textureRendererSource) && /buildTitleData/.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 worker uses plural page margin metrics for page numbers', /metrics\.margins\.bottom/.test(textureWorkerSource) && !/metrics\.margin\.bottom/.test(textureWorkerSource)],
|
||||||
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
['webgl flip assigns explicit source and back page textures before animation starts', /resolveCurrentFlipSourceTexture\(sourceSide\)/.test(source) && /const targetBackSide = flip\.direction > 0 \? 'left' : 'right'/.test(source) && /const targetBackPageMeta = getPaginationPageMeta\(targetBackPageIndex\) \|\| makeBlankPageMeta\(targetBackPageIndex\)/.test(source) && /materials\.flipPageSurface\.map = sourceTexture/.test(source) && /materials\.flipPageBackSurface\.map = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source)],
|
||||||
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
['webgl flip never falls back to the opposite visible stack for target back texture', /function resolveFlipBackTexture\(pageMeta = null, prewarmedTexture = null\)/.test(source) && source.includes('return pageTextureStore?.getResidentTextureForMeta?.(pageMeta);') && !/oppositeMaterial\?\.map/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||||
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
['webgl page texture record metadata normalizes omitted or null sides into explicit blank pages', /function normalizePageMetaPair/.test(source) && /function makeBlankPageMeta/.test(source) && /applyExplicitBlankPageTexture/.test(source) && /normalizePageTextureRecordDetail/.test(source) && !/hasLeftMeta/.test(methodBody(source, 'handlePageTextureRecords'))],
|
||||||
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = this\.drawSpread\(spread, sides/.test(textureRendererSource) && /pageMeta: prepared\.pageMeta \|\| \{\}/.test(textureRendererSource)],
|
['texture renderer publishes both spread sides for reveal preparation', /const sides = \['left', 'right'\]/.test(textureRendererSource) && /published = await this\.drawSpread\(spread, sides/.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 marks page canvases with content versions before cache writes', /pageContentVersions/.test(textureRendererSource) && /buildPublishPageMeta/.test(textureRendererSource) && /completenessScore: \(maxBlockId \* 1000\) \+ lineCount/.test(textureRendererSource)],
|
||||||
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
['texture store queues newer same-page cache writes instead of dropping them', /storePageCanvas/.test(webglPageCacheSource) && /isOlderPageMeta/.test(webglPageCacheSource) && /const previousWrite = pending\?\.promise \|\| Promise\.resolve\(\)/.test(webglPageCacheSource) && /pendingPageWrites\.set\(key, \{[\s\S]*pageMeta: \{ \.\.\.\(pageMeta \|\| \{\}\) \}/.test(webglPageCacheSource)],
|
||||||
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
['webgl texture store resident cache reuses newest page version for older readiness requests', /isOlderPageMeta/.test(webglPageCacheSource) && /getResidentTextureForMeta/.test(webglPageCacheSource) && /if \(!resident\) return null/.test(webglPageCacheSource) && !/if \(!resident \|\| this\.isOlderPageMeta\(pageMeta, resident\.pageMeta\)\) return null/.test(webglPageCacheSource)],
|
||||||
['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) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.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) && /const topMaterialIndex = direction > 0 \? 1 : 0/.test(source) && /const bottomMaterialIndex = direction > 0 \? 0 : 1/.test(source) && /geometry\.addGroup\(0, topIndices\.length, topMaterialIndex\)/.test(source) && /geometry\.addGroup\(topIndices\.length, bottomIndices\.length, bottomMaterialIndex\)/.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 = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.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 = backDeferred \? getBlankPageTexture\(\) : \(backTexture \|\| getBlankPageTexture\(\)\)/.test(source) && /flip\.sourceTexture = sourceTexture/.test(source) && /flip\.backTexture = backTexture \|\| getBlankPageTexture\(\)/.test(source)],
|
||||||
|
['webgl flip page material variants are compiled during loader, not at first texture swap', /flipPageSurface: new THREE\.MeshStandardMaterial\(\{[\s\S]*map: getBlankPageTexture\(\),[\s\S]*normalMap: paperTextures\.normal,[\s\S]*roughnessMap: paperTextures\.roughness/.test(source) && !/materials\.flipPageSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip')) && !/materials\.flipPageBackSurface\.needsUpdate = true/.test(methodBody(source, 'prepareStaticPageForFlip'))],
|
||||||
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
|
['webgl flip preflight exposes texture side and orientation invariants for browser tests', /lastFlipTexturePreflight/.test(source) && /sourceTextureMatchesBackTexture/.test(source) && /targetBackSide/.test(source) && /getRuntimeInvariants/.test(source)],
|
||||||
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
|
['webgl animated page maps source and destination textures to direction-aware physical sides', /const topPageSide = direction > 0 \? targetSide : sourceSide/.test(source) && /const bottomPageSide = direction > 0 \? sourceSide : targetSide/.test(source) && /topRow\.push\(push\(point, pageThickness, pageUvForSide\(topPageSide, u, v\)\)\)/.test(source) && /bottomRow\.push\(push\(point, 0, pageUvForSide\(bottomPageSide, u, v\)\)\)/.test(source) && /side < 0 \? 1 - pageU : pageU/.test(source) && /y: v/.test(source)],
|
||||||
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
|
['webgl animated page UVs use the same fore-edge inset as the visible stack cap', /PAGE_TEXTURE_FORE_EDGE_INSET_RATIO/.test(source) && /const pageU = THREE\.MathUtils\.clamp\(u \/ Math\.max\(0\.0001, 1 - inset\), 0, 1\)/.test(source)],
|
||||||
@@ -199,6 +211,8 @@ const checks = [
|
|||||||
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
|
['webgl flip prewarm prepares current and target spread texture records before cache lookup', /prepareSpreadTextureRecordsForFlip\(currentSpread\)/.test(source) && /prepareSpreadTextureRecordsForFlip\(nextSpread\)/.test(source) && /function prepareSpreadTextureRecordsForFlip/.test(source) && /spreadTextureRecordsReady\(spread\)/.test(source) && /window\.BookTextureRenderer\.drawSpread\(spread, \['left', 'right'\], \{[\s\S]*phase: 'prepare'/.test(source)],
|
||||||
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
['webgl animated page reuses geometry buffers during flips', /function updateFlippingPageGeometry/.test(source) && /position\.needsUpdate = true/.test(source) && /updateFlippingPageGeometry\(flip\.mesh\.geometry, surface\)/.test(source) && !/flip\.mesh\.geometry\.dispose\(\);\s*flip\.mesh\.geometry = geometry;/.test(methodBody(source, 'setActivePageGeometry'))],
|
||||||
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
|
['webgl scene targets 60fps with browser-frame scheduling and staggered live mirror refresh', /const targetFrameDurationMs = 1000 \/ 60/.test(source) && /const minRenderFrameIntervalMs = targetFrameDurationMs \* 0\.5/.test(source) && /requestAnimationFrame\(animate\)/.test(source) && /const refreshStaticSceneBuffers = staticSceneBuffersDirty \|\| activeFlips\.length > 0/.test(source) && /const dynamicBufferRefreshIntervalMs = 1000 \/ 30/.test(source) && /const flipDynamicBufferGraceMs = 180/.test(source) && /const bothHeavyPassesDue = shadowRefreshDue && reflectionRefreshDue/.test(source) && /const refreshReflectionThisFrame/.test(source) && /updateTableReflection\(\);/.test(source) && /mirrorRefreshesAtFps/.test(source) && !/setTimeout\(animate/.test(source)],
|
||||||
|
['webgl reveal playback throttles dynamic buffers without freezing mirror permanently', /const revealGeometryBufferRefreshIntervalMs = 1000 \/ 4/.test(source) && /const revealAnimating = hasActivePageReveal\(\)/.test(source) && /revealAnimating[\s\S]*revealGeometryBufferRefreshIntervalMs/.test(source)],
|
||||||
|
['webgl navigation texture prewarm yields until reveal and flip critical frames are clear', /function scheduleNavigationTextureWindowPrewarm/.test(source) && /requestIdleCallback/.test(source) && /activeFlips\.length > 0 \|\| hasActivePageReveal\(\)/.test(source) && /scheduleNavigationTextureWindowPrewarm\('page-texture-records'/.test(source)],
|
||||||
['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)],
|
['texture renderer has no private reveal clock (scene render loop is the single clock)', !/this\.targetFrameDurationMs/.test(textureRendererSource) && !/tickAnimations/.test(textureRendererSource) && !/requestAnimationFrame/.test(textureRendererSource)],
|
||||||
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
['webgl scene lowers mirror target and caps table film maps to 2k', /const reflectionPixelRatio = 0\.72/.test(source) && /const tableReflectionBaseWidth = 1536/.test(source) && /const tableReflectionBaseHeight = 864/.test(source) && /tableDustTexture = loadUtilityTexture\('\/assets\/webgl\/table_dust_4k\.png', \{ maxSize: 2048 \}\)/.test(source) && /tableGreaseTexture = loadUtilityTexture\('\/assets\/webgl\/table_grease_4k\.png', \{ maxSize: 2048 \}\)/.test(source)],
|
||||||
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
|
['webgl debug exposes runtime invariants for visual regression tests', /getRuntimeInvariants\(\)/.test(source) && /residentPageTextureCount/.test(source) && /flipFrontBackShareMaterial/.test(source) && /mirrorRefreshesAtFps/.test(source) && /mirrorDefersDuringFlipStartMs/.test(source)],
|
||||||
@@ -219,14 +233,16 @@ const checks = [
|
|||||||
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
|
['webgl autoplay flip source prefers currently revealing visible material over resident cache', /if \(revealStateMatchesPage\(side, pageMeta\)\) return material\?\.map \|\| null/.test(source) && /revealStateMatchesPage\(sourceSide, sourcePageMeta\) \? sourceSide : null/.test(source)],
|
||||||
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
|
['webgl flipping page materials mirror active reveal shader uniforms on both sides', /materials\.flipPageSurface\.userData\.bookPageReveal/.test(source) && /syncFlipRevealShaderFromSource/.test(source) && /bookRevealRegionRects/.test(source) && /materials\.flipPageSurface\.userData\.sourceRevealSide === side/.test(source) && /revealStateMatchesPage\(targetBackSide, targetBackPageMeta\) \? targetBackSide : null/.test(source)],
|
||||||
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
|
['webgl prepared texture records do not mutate the visible page metadata', /const incomingPageMeta = detail\.pageMeta/.test(source) && /if \(detail\.phase !== 'prepare' && detail\.pageMeta\) \{[\s\S]*currentPageMeta = incomingPageMeta/.test(source) && /pageMeta: effectivePageMeta/.test(source)],
|
||||||
['webgl scene force-redraws current pagination spread for initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
|
['webgl scene awaits current pagination spread redraw during loader initial title upload', /const initialSpread = pagination\?\.getCurrentSpread\?\.\(\)/.test(webglSceneSource) && /await window\.BookTextureRenderer\.drawSpread\(initialSpread, \['left', 'right'\], \{ force: true \}\)/.test(webglSceneSource) && !/Date\.now\(\)/.test(webglSceneSource) && /options\.force !== true && phase !== 'prepare'/.test(textureRendererSource)],
|
||||||
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)],
|
['texture renderer marks committed reveal blocks complete so pauses cannot replay them', /webgl-book:reveal-committed/.test(textureRendererSource) && /completeRevealBlockIds/.test(textureRendererSource) && /this\.revealedBlockIds\.add\(id\)/.test(textureRendererSource)],
|
||||||
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
|
['webgl timeline recalculates placeholder zero-duration reveal timings from TTS duration', /existingTimings/.test(bookPlaybackTimelineSource) && /existingDuration/.test(bookPlaybackTimelineSource) && /ttsDuration/.test(bookPlaybackTimelineSource) && /existingTimings\.length > 0 && \(existingDuration > 0 \|\| ttsDuration <= 0\)/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)],
|
['webgl playback coordinator trusts timeline-prepared reveal timings without recomputing', !/calculateWordTimings/.test(methodBody(playbackCoordinatorSource, 'scheduleWebGLReveal')) && /single owner of reveal timing/.test(playbackCoordinatorSource) && /sentence\.webglRevealController\(/.test(playbackCoordinatorSource)],
|
||||||
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
|
['texture renderer derives reveal regions from the just-drawn preview spread before committed pagination', /if \(this\.currentSpread\) sourceSpreads\.push\(this\.currentSpread\)/.test(textureRendererSource) && /paginationSpreads\.forEach/.test(textureRendererSource) && /Number\(spread\.index\) === Number\(this\.currentSpread\.index\)/.test(textureRendererSource)],
|
||||||
['texture renderer prepares a spanning block continuation spread in the background and reuses it (no synchronous redraw on the critical path)', /revealSpreadSourceOverride/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(textureRendererSource) && /takeContinuationRevealPlan/.test(textureRendererSource) && /`\$\{id\}:cont`/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /takeContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /previewSpreads/.test(bookPaginationSource)],
|
['texture renderer prepares a spanning block continuation spread in the background and reuses it (no synchronous redraw on the critical path)', /revealSpreadSourceOverride/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(textureRendererSource) && /takeContinuationRevealPlan/.test(textureRendererSource) && /`\$\{id\}:cont`/.test(textureRendererSource) && /prepareContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /takeContinuationRevealPlan/.test(bookPlaybackTimelineSource) && /previewSpreads/.test(bookPaginationSource)],
|
||||||
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
|
['texture renderer preloads every spread touched by an active reveal block', /preloadAdditionalRevealSpreads/.test(textureRendererSource) && /spreadContainsBlock/.test(textureRendererSource) && /this\.drawSpread\(spread, \['left', 'right'\], \{ phase: 'prepare' \}\)/.test(textureRendererSource)],
|
||||||
['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /this\.revealSpreadSourceOverride = detail\.previewSpreads/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
|
['book playback timeline has one prepare path: spanning-aware plan reused at activate and continuation, no fallback', /revealSpreadSourceOverride: spanningPreview \? detail\.previewSpreads : null/.test(textureRendererSource) && /this\.revealSpreadSourceOverride = options\.revealSpreadSourceOverride/.test(textureRendererSource) && !/forceRebuild/.test(textureRendererSource) && !/forceRebuild/.test(bookPlaybackTimelineSource) && !/spanningPlanPrepared/.test(bookPlaybackTimelineSource) && /const texturePlan = this\.textureRenderer\.takeContinuationRevealPlan\(segment\.blockId, spread\.index\)/.test(bookPlaybackTimelineSource)],
|
||||||
|
['book playback timeline reuses prepared activation texture plan on the critical path', /let texturePlan = segment\.preparedTexturePlan/.test(bookPlaybackTimelineSource) && /\{ \.\.\.segment\.preparedTexturePlan, phase: 'activate' \}/.test(bookPlaybackTimelineSource) && /takePreparedRevealPlan\(segment\.blockId\)/.test(bookPlaybackTimelineSource) && /if \(!texturePlan\) \{[\s\S]*prepareRevealBlock/.test(bookPlaybackTimelineSource)],
|
||||||
|
['book playback timeline compares preplay flip against source spread captured before commit', /segment\.sourceSpreadIndex = this\.getVisibleSpreadIndex\(\)/.test(bookPlaybackTimelineSource) && /segment\.sourceSpreadIndex = Number\.isFinite/.test(bookPlaybackTimelineSource) && /const sourceSpread = Number\.isFinite/.test(bookPlaybackTimelineSource) && /targetSpreadIndex \|\| 0\)\) > sourceSpread/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)],
|
['webgl visible spread is owned by scene flips, not pagination publishes', /spreadUpdate:state-only/.test(source) && /webglBookPlaybackActive/.test(source) && /spreadUpdate:jump/.test(source) && /window\.BookTextureRenderer\?\.drawSpread\?\.\(spread, \['left', 'right'\], \{ force: true \}\)/.test(source)],
|
||||||
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(\s*[\s\S]*revealDetail[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
|
['3D overflow reveal preloads target spread before forced page flip', /createRevealDetail\(sentence, previewSpread, 'prepare'\)/.test(bookPlaybackTimelineSource) && /this\.textureRenderer\.prepareRevealBlock\(\s*[\s\S]*revealDetail[\s\S]*phase: 'prepare'[\s\S]*publishEvent: false/.test(bookPlaybackTimelineSource) && /this\.prewarmSegmentTextures\(segment\)/.test(bookPlaybackTimelineSource) && /this\.assertSegmentReady\(segment, 'prepare'\)/.test(bookPlaybackTimelineSource) && /reason: 'timeline-preplay-spread-transition'/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
['book playback timeline is loaded through module infrastructure', /book-playback-timeline-module\.js/.test(loaderSource) && /super\('book-playback-timeline'/.test(bookPlaybackTimelineSource) && /reportProgress\(100, 'Book playback timeline ready'\)/.test(bookPlaybackTimelineSource)],
|
||||||
@@ -242,7 +258,10 @@ const checks = [
|
|||||||
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
|
['book playback timeline flips at planned right-page fragment time without a stray commit timeout', /waitForPlannedRightReveal/.test(bookPlaybackTimelineSource) && /getRightRevealDurationMs/.test(bookPlaybackTimelineSource) && /segment\.revealStartedPromise/.test(bookPlaybackTimelineSource) && /const timer = setTimeout\(\(\) => finish\(true\), remaining\)/.test(bookPlaybackTimelineSource) && !/waitForRevealCommit/.test(bookPlaybackTimelineSource)],
|
||||||
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
['book playback timeline exposes reveal lifecycle benchmark entries', /benchmarkEntries/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-start'/.test(bookPlaybackTimelineSource) && /markBenchmark\('reveal-committed'/.test(bookPlaybackTimelineSource) && /webglBookBenchmark/.test(bookPlaybackTimelineSource)],
|
||||||
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
['webgl scene records reveal start and slow-frame benchmark diagnostics', /revealState:created/.test(source) && /revealStart:applied/.test(source) && /slowFrameLog/.test(source) && /getBenchmarkState/.test(source) && /webglSlowFrames/.test(source)],
|
||||||
['webgl navigation is spread-based and caps at visited/written spread', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, spreadCount - 1\)/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /spread <= 0 \? '0' : String\(spread \* 2 \+ 1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
|
['webgl navigation is spread-based and caps at the written-content spread (title-only before content)', /function navigateToSpread\(/.test(source) && /function navigateBySpreadDelta\(/.test(source) && /function getMaxNavigableSpread\(\)/.test(source) && /Math\.min\(visitedSpread, contentSpread, spreadCount - 1\)/.test(source) && /writtenPageLimit >= 3 \? pageToSpreadIndex\(writtenPageLimit\) : 0/.test(source) && /navigateBySpreadDelta\(1\)/.test(source) && /currentSpread < getMaxNavigableSpread\(\)/.test(source)],
|
||||||
|
['webgl spread label reads 0 at the title and the right page number elsewhere', /function spreadPageLabel\(spreadIndex\)/.test(source) && /if \(spread <= 0\) return '0'/.test(source) && /spreadPageIndices\(spread\)\.right/.test(source) && /rightPageIndex - 2/.test(source)],
|
||||||
|
['webgl manual page navigation is blocked while reveal playback or flips are active', /function isManualBookNavigationBusy\(\) \{[\s\S]*activeFlips\.length > 0[\s\S]*hasActivePageReveal\(\)[\s\S]*webglBookPlaybackActive/.test(source) && /function navigateToSpread\(targetSpread\) \{[\s\S]*if \(isManualBookNavigationBusy\(\)\) \{[\s\S]*navigation:blocked-busy/.test(source) && /bottomNavigation\.slider\.disabled = busy/.test(source)],
|
||||||
|
['webgl fast-forward always reaches scene reveal state even without renderer-side active animations', /fastForwardAnimations\(\) \{[\s\S]*webgl-book:page-reveal-fast-forward[\s\S]*broad: !changed/.test(textureRendererSource) && /function fastForwardPageReveals\(blockIds = \[\]\) \{[\s\S]*const matches = ids\.size === 0/.test(source)],
|
||||||
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
['webgl save restore carries visited page limit for navigation', /maxVisitedPagePosition/.test(source) && /setMaxVisitedPagePosition/.test(source) && /state\.maxVisitedPagePosition \?\? state\.pagePosition/.test(webglSceneSource)],
|
||||||
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
['webgl page flips require resident nonblank back textures before animation starts', /prepareStaticPageForFlip\(flip, prewarm = null\)/.test(source) && /flip-back-texture-missing/.test(source) && /targetBackPageMeta\.kind !== 'blank'/.test(source) && /return false;/.test(methodBody(source, 'prepareStaticPageForFlip')) && /flipTexturePreflight:ready/.test(source) && /if \(!prepareStaticPageForFlip\(flip, options\.prewarm \|\| null\)\) \{[\s\S]*return false;[\s\S]*\}/.test(source)],
|
||||||
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
['webgl fast page flips preflight the actual target spread', /firstFlip\.targetSpread = Number\.isFinite\(Number\(options\.targetSpread\)\)/.test(source) && /if \(!prepareStaticPageForFlip\(firstFlip, options\.prewarm \|\| null\)\) return false/.test(source)],
|
||||||
|
|||||||
Reference in New Issue
Block a user