diff --git a/public/js/book-texture-renderer-module.js b/public/js/book-texture-renderer-module.js index 78fab65..9f71c2b 100644 --- a/public/js/book-texture-renderer-module.js +++ b/public/js/book-texture-renderer-module.js @@ -53,7 +53,6 @@ class BookTextureRendererModule extends BaseModule { 'drawSpreadSerial', 'rasterizeSpread', 'getDrawSignature', - 'cloneCanvas', 'buildRevealRegions', 'shouldFlipAfterSideReveal', 'collectRevealRegionCandidates', @@ -373,15 +372,6 @@ class BookTextureRendererModule extends BaseModule { }).join('|'); } - cloneCanvas(canvas) { - if (!canvas) return null; - const clone = document.createElement('canvas'); - clone.width = canvas.width; - clone.height = canvas.height; - const context = clone.getContext('2d'); - if (context) context.drawImage(canvas, 0, 0); - return clone; - } getPageContent(side = 'left') { return this.metrics?.contentBySide?.[side] || this.metrics?.content || { @@ -953,19 +943,17 @@ class BookTextureRendererModule extends BaseModule { pageMeta: this.buildPublishPageMeta(sidesToPublish), phase }; - if (sidesToPublish.includes('left')) { - detail.left = phase === 'prepare' ? this.cloneCanvas(this.canvases.left) : this.canvases.left; - } - if (sidesToPublish.includes('right')) { - detail.right = phase === 'prepare' ? this.cloneCanvas(this.canvases.right) : this.canvases.right; - } + // The page-texture-records event is dispatched synchronously below, and its handler + // uploads the canvas to a GPU texture synchronously (renderer.initTexture). So the live + // page canvas (and the static cached base) are consumed before the next draw can touch + // them — no per-draw clones needed (those large canvas copies drove the GC stalls). + if (sidesToPublish.includes('left')) detail.left = this.canvases.left; + if (sidesToPublish.includes('right')) detail.right = this.canvases.right; const reveal = {}; sidesToPublish.forEach((side) => { const sideReveal = this.buildRevealRegions(side); if (!sideReveal) return; - sideReveal.baseCanvas = phase === 'prepare' - ? this.cloneCanvas(this.revealBaseCanvases?.[side]) - : this.revealBaseCanvases?.[side] || null; + sideReveal.baseCanvas = this.revealBaseCanvases?.[side] || null; regionCounts[side] = sideReveal.lineRects.length; reveal[side] = sideReveal; }); diff --git a/public/js/webgl-book-lab.js b/public/js/webgl-book-lab.js index 1cd590b..2ec03a2 100644 --- a/public/js/webgl-book-lab.js +++ b/public/js/webgl-book-lab.js @@ -1950,14 +1950,21 @@ function getCurrentPagePosition() { function getMaxNavigableSpread() { const spreadCount = Math.max(1, Math.round(Number(bookPaginationState.spreadCount || 1))); 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. +// Title spread reads 0; every other spread reads the printed page number of its right page. function spreadPageLabel(spreadIndex) { 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) : '0'; } function scheduleBookRebuild(reason = 'scheduled') { @@ -2130,6 +2137,16 @@ function ensureBottomNavigation() { } 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 target = THREE.MathUtils.clamp(Math.round(Number(targetSpread || 0)), 0, maxSpread); const currentSpread = Math.max(0, Math.round(Number(bookPaginationState.spreadIndex || 0))); @@ -2159,7 +2176,7 @@ function navigateByPageDelta(delta) { } function syncBookControls() { - const busy = activeFlips.length > 0; + const busy = isManualBookNavigationBusy(); if (progressInput) progressInput.value = readingProgress.toFixed(3); if (progressValue) progressValue.textContent = readingProgress.toFixed(2); if (pageCountInput) pageCountInput.value = String(bookPageCount); @@ -2188,10 +2205,12 @@ function syncBottomNavigation() { bottomNavigation.root.style.setProperty('--book-nav-reserve-start', '1'); bottomNavigation.root.dataset.bookSize = String(bookPageCount); bottomNavigation.root.dataset.pageReserve = String(pageReserve); - bottomNavigation.startButton.disabled = activeFlips.length > 0 || currentSpread <= 0; - bottomNavigation.backButton.disabled = activeFlips.length > 0 || currentSpread <= 0; - bottomNavigation.forwardButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; - bottomNavigation.endButton.disabled = activeFlips.length > 0 || currentSpread >= maxSpread; + const busy = isManualBookNavigationBusy(); + bottomNavigation.slider.disabled = busy; + bottomNavigation.startButton.disabled = busy || currentSpread <= 0; + bottomNavigation.backButton.disabled = busy || currentSpread <= 0; + bottomNavigation.forwardButton.disabled = busy || currentSpread >= maxSpread; + bottomNavigation.endButton.disabled = busy || currentSpread >= maxSpread; } function handlePageTextureRecords(event) { @@ -3339,6 +3358,12 @@ function hasActivePageReveal() { }); } +function isManualBookNavigationBusy() { + return activeFlips.length > 0 + || hasActivePageReveal() + || document.documentElement.dataset.webglBookPlaybackActive === 'true'; +} + function applyResidentSpreadTextures(spreadIndex, reason = 'resident-spread', options = {}) { const skipSides = Array.isArray(options.skipSides) ? options.skipSides : []; const pageIndices = spreadPageIndices(spreadIndex); diff --git a/scripts/check-webgl-book-lab.js b/scripts/check-webgl-book-lab.js index 747015d..62efa9a 100644 --- a/scripts/check-webgl-book-lab.js +++ b/scripts/check-webgl-book-lab.js @@ -258,7 +258,8 @@ 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 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 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) && /getPaginationPageMeta\(rightPageIndex\)\?\.pageNumber/.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)],