Fix WebGL reveal timing and flip texture readiness

This commit is contained in:
2026-06-10 13:54:54 +02:00
parent e3d66686b9
commit 97eab216b7
7 changed files with 210 additions and 36 deletions
+1
View File
@@ -353,6 +353,7 @@ class BookPaginationModule extends BaseModule {
lineHeightPx: layout.lineHeightPx,
fontStyle: layout.fontStyle,
blockWordStart: blockWordCursor,
lineWordCount,
dropCapText: layoutLineIndex === 0 ? layout.dropCapText : '',
smallCaps: Boolean(layout.dropCap && layoutLineIndex === 0)
});
+10 -2
View File
@@ -208,9 +208,17 @@ class BookPlaybackTimelineModule extends BaseModule {
}
ensureAnimationTimings(sentence = {}) {
if (Array.isArray(sentence.animation?.wordTimings) && sentence.animation.wordTimings.length > 0) return;
const existingTimings = Array.isArray(sentence.animation?.wordTimings)
? sentence.animation.wordTimings
: [];
const existingDuration = existingTimings.reduce((max, timing) => Math.max(
max,
Number(timing?.delay || 0) + Number(timing?.duration || 0)
), Number(sentence.animation?.totalDuration || 0));
const ttsDuration = Number(sentence.tts?.duration || 0);
if (existingTimings.length > 0 && (existingDuration > 0 || ttsDuration <= 0)) return;
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
sentence.animation = this.calculateAnimationTiming(words, ttsDuration, sentence.cueMarkers || []);
}
calculateAnimationTiming(words = [], totalDuration = 0, cueMarkers = []) {
+59 -12
View File
@@ -119,6 +119,13 @@ class BookTextureRendererModule extends BaseModule {
const latestRenderedBlockId = Math.max(0, Number(event.detail?.latestRenderedBlockId || 0));
const visibility = event.detail?.visibility || 'current';
this.currentSpread = spread || { left: [], right: [] };
if (document.documentElement.dataset.webglPageFlipActive === 'true' && this.activeAnimations.size === 0) {
this.markPipelineTiming('spreadUpdate:skip-during-flip', {
spreadIndex,
visibility
});
return;
}
if (latestBlockId && Number(latestBlockId) > latestRenderedBlockId) {
this.markPendingReveal(latestBlockId);
const id = String(latestBlockId);
@@ -674,9 +681,16 @@ class BookTextureRendererModule extends BaseModule {
collectRevealRegionCandidates() {
const candidates = [];
const sourceSpreads = Array.isArray(this.pagination?.spreads) && this.pagination.spreads.length
? this.pagination.spreads
: [this.currentSpread || { index: 0, left: [], right: [] }];
const sourceSpreads = [];
if (this.currentSpread) sourceSpreads.push(this.currentSpread);
if (Array.isArray(this.pagination?.spreads)) {
this.pagination.spreads.forEach((spread) => {
if (!spread) return;
if (this.currentSpread && Number(spread.index) === Number(this.currentSpread.index)) return;
sourceSpreads.push(spread);
});
}
if (!sourceSpreads.length) sourceSpreads.push({ index: 0, left: [], right: [] });
sourceSpreads.forEach((spread) => {
['left', 'right'].forEach((side) => {
const spreadLines = Array.isArray(spread?.[side]) ? spread[side] : [];
@@ -707,17 +721,31 @@ class BookTextureRendererModule extends BaseModule {
const fixedRegions = sortedRegions.filter(region => region.fixedDurationMs > 0);
let fallbackDelay = 0;
const totalArea = textRegions.reduce((sum, region) => sum + Math.max(1, region.area), 0);
const wordTimings = Array.isArray(animation.wordTimings) ? animation.wordTimings : [];
const canUseLineWordSpans = wordTimings.length > 0
&& textRegions.every(region => Number.isFinite(Number(region.blockWordStart)) && Number(region.blockWordCount) > 0);
textRegions.forEach((region) => {
const duration = totalArea > 0
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea))
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
timedRegions.push({
...region,
timing: { delay: fallbackDelay, duration }
if (canUseLineWordSpans) {
textRegions.forEach((region) => {
const timing = this.getLineTimingFromWords(region, wordTimings);
timedRegions.push({
...region,
timing
});
fallbackDelay = Math.max(fallbackDelay, timing.delay + timing.duration);
});
fallbackDelay += duration;
});
} else {
textRegions.forEach((region) => {
const duration = totalArea > 0
? Math.max(1, totalDuration * (Math.max(1, region.area) / totalArea))
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
timedRegions.push({
...region,
timing: { delay: fallbackDelay, duration }
});
fallbackDelay += duration;
});
}
fixedRegions.forEach((region) => {
timedRegions.push({
@@ -738,6 +766,23 @@ class BookTextureRendererModule extends BaseModule {
});
}
getLineTimingFromWords(region = {}, wordTimings = []) {
const start = Math.max(0, Math.floor(Number(region.blockWordStart || 0)));
const count = Math.max(1, Math.floor(Number(region.blockWordCount || 1)));
const first = wordTimings[Math.min(start, wordTimings.length - 1)] || { delay: 0, duration: 1 };
const lastIndex = Math.min(wordTimings.length - 1, start + count - 1);
const last = wordTimings[lastIndex] || first;
const delay = Math.max(0, Number(first.delay || 0));
const end = Math.max(
delay + 1,
Number(last.delay || 0) + Math.max(1, Number(last.duration || 1))
);
return {
delay,
duration: Math.max(1, end - delay)
};
}
createRevealRegionForLine(side, lineRecord = {}, spreadIndex = null) {
const blockId = String(lineRecord?.blockId ?? '');
if (!blockId || !this.revealPublishBlockIds.has(blockId)) return null;
@@ -770,6 +815,8 @@ class BookTextureRendererModule extends BaseModule {
spreadIndex: Math.max(0, Number((spreadIndex ?? Math.floor(Number(lineRecord.pageIndex || 0) / 2)) || 0)),
blockId,
lineIndex: Number(lineRecord.lineIndex ?? lineRecord.pageLine ?? 0),
blockWordStart: Number(lineRecord.blockWordStart ?? 0),
blockWordCount: Number(lineRecord.lineWordCount ?? 0),
fixedDurationMs,
area: rectWidth * rectHeight,
pixelRect: { x: left, y: top, right, bottom },
+1 -1
View File
@@ -24,7 +24,7 @@ const ModuleState = {
ERROR: 'ERROR'
};
const MODULE_CACHE_BUSTER = '20260610-book-timeline-d';
const MODULE_CACHE_BUSTER = '20260610-book-timeline-j';
window.MODULE_CACHE_BUSTER = MODULE_CACHE_BUSTER;
/**
+13 -2
View File
@@ -315,11 +315,22 @@ class PlaybackCoordinatorModule extends BaseModule {
let cueTimings = Array.isArray(sentence.animation?.cueTimings)
? sentence.animation.cueTimings
: [];
if (wordTimings.length === 0) {
const timingDuration = wordTimings.reduce((max, timing) => Math.max(
max,
Number(timing?.delay || 0) + Number(timing?.duration || 0)
), Number(sentence.animation?.totalDuration || 0));
const ttsDuration = Number(sentence.tts?.duration || sentence.animation?.totalDuration || 0);
if (wordTimings.length === 0 || (timingDuration <= 0 && ttsDuration > 0)) {
const words = String(sentence.text || '').match(/\S+/g) || [];
const calculated = this.calculateWordTimings(words, sentence.tts?.duration || sentence.animation?.totalDuration || 0);
const calculated = this.calculateWordTimings(words, ttsDuration);
wordTimings = calculated.wordTimings;
cueTimings = [];
sentence.animation = {
...(sentence.animation || {}),
wordTimings,
cueTimings,
totalDuration: calculated.totalDuration
};
}
if (typeof sentence.webglRevealController !== 'function') {
+117 -17
View File
@@ -4,7 +4,7 @@ import { RenderPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postproces
import { SSAOPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SSAOPass.js';
import { SMAAPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/SMAAPass.js';
import { OutputPass } from 'https://esm.sh/three@0.165.0/examples/jsm/postprocessing/OutputPass.js';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-d';
import { PROCEDURAL_BOOK, createProceduralBookModel, snapProceduralPageCount } from './procedural-book-model.js?v=20260610-book-timeline-j';
const canvas = document.getElementById('scene');
canvas.style.cursor = 'grab';
@@ -278,6 +278,7 @@ const pageRevealState = {
};
let pageRevealFreezeAt = null;
const pageRevealClearLog = [];
let scheduledBookRebuildFrame = null;
await reportLabStep(52, 'Generating leather texture set');
const leatherTextures = createLeatherTextures();
await reportLabStep(56, 'Generating spine cloth texture set');
@@ -1842,12 +1843,33 @@ function getCurrentPagePosition() {
return spreadIndexToPagePosition(bookPaginationState.spreadIndex);
}
function syncReadingProgressToCurrentPage() {
function scheduleBookRebuild(reason = 'scheduled') {
if (scheduledBookRebuildFrame !== null) return;
const scheduler = typeof window.requestIdleCallback === 'function'
? (callback) => window.requestIdleCallback(callback, { timeout: 180 })
: requestAnimationFrame;
scheduledBookRebuildFrame = scheduler(() => {
scheduledBookRebuildFrame = null;
markPageTextureTiming('bookRebuild:deferred', { reason });
buildBook();
syncBookControls();
});
}
function syncReadingProgressToCurrentPage(options = {}) {
const nextProgress = THREE.MathUtils.clamp(getCurrentPagePosition() / Math.max(1, bookPageCount), 0, 1);
if (Math.abs(nextProgress - readingProgress) < 0.0001) return;
readingProgress = nextProgress;
const changed = Math.abs(nextProgress - readingProgress) >= 0.0001;
if (changed) {
readingProgress = nextProgress;
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
if (!changed && options.rebuild !== 'defer') return;
if (options.rebuild === 'defer') {
scheduleBookRebuild(options.reason || 'reading-progress-sync');
return;
}
if (options.rebuild === false) return;
buildBook();
window.WebGLBookPreferenceBridge?.updateProgress?.(readingProgress);
}
function growBookIfWritableLimitReached() {
@@ -2266,15 +2288,20 @@ function getPaginationPageMeta(pageIndex) {
const index = Math.max(0, Math.round(Number(pageIndex || 0)));
const spreadIndex = Math.floor(index / 2);
const side = index % 2 === 0 ? 'left' : 'right';
const pagination = window.moduleRegistry?.getModule?.('book-pagination') || null;
const spread = typeof pagination?.getSpread === 'function'
? pagination.getSpread(spreadIndex)
: Array.isArray(pagination?.spreads)
? pagination.spreads[spreadIndex]
: null;
const spread = getPaginationSpread(spreadIndex);
return spread?.pageMeta?.[side] || null;
}
function getPaginationSpread(spreadIndex) {
const index = Math.max(0, Math.round(Number(spreadIndex || 0)));
const pagination = window.moduleRegistry?.getModule?.('book-pagination') || null;
return typeof pagination?.getSpread === 'function'
? pagination.getSpread(index)
: Array.isArray(pagination?.spreads)
? pagination.spreads[index]
: null;
}
async function prewarmSpreadTextures(spreadIndex) {
return pageTextureStore?.prewarmSpreadTextures?.(spreadIndex, makePageMetaForCache) || {
spreadIndex: Math.max(0, Math.round(Number(spreadIndex || 0))),
@@ -2314,6 +2341,8 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
const nextSpread = Number.isFinite(Number(targetSpread))
? Math.max(0, Math.round(Number(targetSpread)))
: Math.max(0, currentSpread + Math.sign(Number(direction || 0)));
prepareSpreadTextureRecordsForFlip(currentSpread);
prepareSpreadTextureRecordsForFlip(nextSpread);
const windowMap = await prewarmNavigationTextureWindow('flip-prewarm', { targetSpread: nextSpread });
const current = windowMap?.[currentSpread] || await prewarmSpreadTextures(currentSpread);
const next = windowMap?.[nextSpread] || await prewarmSpreadTextures(nextSpread);
@@ -2323,6 +2352,25 @@ async function prewarmFlipTextures(direction, targetSpread = null) {
};
}
function prepareSpreadTextureRecordsForFlip(spreadIndex) {
const spread = getPaginationSpread(spreadIndex);
if (!spread || typeof window.BookTextureRenderer?.drawSpread !== 'function') return false;
if (spreadTextureRecordsReady(spread)) return true;
window.BookTextureRenderer.drawSpread(spread, ['left', 'right'], {
phase: 'prepare'
});
return true;
}
function spreadTextureRecordsReady(spread = null) {
if (!spread?.pageMeta || !pageTextureStore) return false;
return ['left', 'right'].every((side) => {
const meta = spread.pageMeta?.[side] || null;
if (!meta || meta.kind === 'blank') return true;
return Boolean(pageTextureStore.getResidentTextureForMeta?.(meta));
});
}
function takePreparedPageTexture(side, revealDetail = {}) {
const key = getRevealCacheKey(revealDetail);
const prepared = pageTextureStore?.takePreparedPageTexture?.(side, key) || null;
@@ -3047,6 +3095,9 @@ function updateActiveFlips(now) {
const targetSpread = Number.isFinite(Number(flip.targetSpread))
? Math.max(0, Math.round(Number(flip.targetSpread)))
: null;
if (targetSpread !== null && !hasActivePageReveal()) {
applyResidentSpreadTextures(targetSpread, 'page-flip-near-end');
}
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-near-end', {
detail: {
direction: flip.direction,
@@ -3060,6 +3111,48 @@ function updateActiveFlips(now) {
completed.forEach((flip) => finishActiveFlip(flip));
}
function hasActivePageReveal() {
return ['left', 'right'].some((side) => {
const state = pageRevealState[side];
if (!state) return false;
if (state.startedAt != null) return true;
return Array.isArray(state.blockIds) && state.blockIds.length > 0;
});
}
function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread') {
const pageIndices = spreadPageIndices(spreadIndex);
['left', 'right'].forEach((side) => {
const pageIndex = pageIndices[side];
const pageMeta = getPaginationPageMeta(pageIndex) || makeBlankPageMeta(pageIndex);
const texture = pageMeta.kind === 'blank'
? getBlankPageTexture()
: pageTextureStore?.getResidentTextureForMeta?.(pageMeta);
if (!texture) {
pageTextureStore?.recordProblem?.({
type: 'resident-spread-texture-missing',
reason,
side,
spreadIndex,
pageIndex,
pageKind: pageMeta.kind
});
return;
}
const material = side === 'left' ? materials.leftPage : materials.rightPage;
clearPageReveal(side, reason);
if (material.map !== texture) {
material.map = texture;
material.needsUpdate = true;
}
});
markStaticSceneBuffersDirty();
markPageTextureTiming('residentSpreadTextures:applied', {
reason,
spreadIndex
});
}
function buildFlippingPageSurface(sourceLine, destinationLine, direction, t, pageOffset = 0) {
const widthSegments = sourceLine.points.length - 1;
const depthSegments = 18;
@@ -3208,6 +3301,10 @@ function createFlippingPageGeometry(surface, direction = 1) {
const depthSegments = surface[0].length - 1;
const sourceSide = direction > 0 ? 1 : -1;
const targetSide = -sourceSide;
const topPageSide = direction > 0 ? targetSide : sourceSide;
const bottomPageSide = direction > 0 ? sourceSide : targetSide;
const topMaterialIndex = direction > 0 ? 1 : 0;
const bottomMaterialIndex = direction > 0 ? 0 : 1;
const push = (point, yOffset, uv) => {
const index = positions.length / 3;
positions.push(point.x, point.y + yOffset, point.z);
@@ -3221,8 +3318,8 @@ function createFlippingPageGeometry(surface, direction = 1) {
const u = widthSegments <= 0 ? 0 : widthIndex / widthSegments;
rowPoints.forEach((point, depthIndex) => {
const v = depthSegments <= 0 ? 0 : depthIndex / depthSegments;
topRow.push(push(point, pageThickness, pageUvForSide(sourceSide, u, v)));
bottomRow.push(push(point, 0, pageUvForSide(targetSide, u, v)));
topRow.push(push(point, pageThickness, pageUvForSide(topPageSide, u, v)));
bottomRow.push(push(point, 0, pageUvForSide(bottomPageSide, u, v)));
});
topGrid.push(topRow);
bottomGrid.push(bottomRow);
@@ -3257,8 +3354,8 @@ function createFlippingPageGeometry(surface, direction = 1) {
geometry.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3));
geometry.setAttribute('uv', new THREE.Float32BufferAttribute(uvs, 2));
geometry.clearGroups();
geometry.addGroup(0, topIndices.length, 0);
geometry.addGroup(topIndices.length, bottomIndices.length, 1);
geometry.addGroup(0, topIndices.length, topMaterialIndex);
geometry.addGroup(topIndices.length, bottomIndices.length, bottomMaterialIndex);
geometry.addGroup(topIndices.length + bottomIndices.length, wallIndices.length, 2);
geometry.computeVertexNormals();
return geometry;
@@ -3271,7 +3368,7 @@ function createFlippingPageGeometry(surface, direction = 1) {
function pageUvForSide(side, u, v) {
return {
x: side < 0 ? 1 - u : u,
y: 1 - v
y: v
};
}
@@ -3313,7 +3410,10 @@ function finishActiveFlip(flip) {
spreadIndex: Math.max(0, Math.round(Number(flip.targetSpread)))
};
maxVisitedPagePosition = Math.max(maxVisitedPagePosition, getCurrentPagePosition());
syncReadingProgressToCurrentPage();
syncReadingProgressToCurrentPage({
rebuild: 'defer',
reason: 'page-flip-finished'
});
}
document.dispatchEvent(new CustomEvent('webgl-book:page-flip-finished', {
detail: {