Rework WebGL book playback to single ownership and fix flip/reveal pipeline

Establish book-playback-timeline as the sole playback owner driving the
scene through formal webgl-book:* events (not the BookLabDebug surface),
with a single reveal clock in the scene render loop and webgl-page-cache as
the only texture cache. Remove the legacy dual playback path and the
ownsPageFlipCommit gating.

Fixes:
- Flip page detached/folded at the spine: restore the raw page-cap line for
  flip geometry (matches the prototype/pre-regression), removing
  normalizeFlipLineToVisiblePage which moved the pivot off the spine arc.
- Flip textures: distance-based UVs (no horizontal compression),
  direction-aware face material (source on the camera-facing side), source
  meta derived from the visible spread (manual flips), prewarm shape fix.
- Reveal: flash removed on the static page and the flip back surface;
  spanning blocks rebuild the reveal plan at activate and continue the
  reveal on the next spread after the fill flip.
- Cache staleness is contentVersion-primary; nav clamps to spreadCount.

Docs updated to describe the intended single-owner architecture. Regression
checks updated to match.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 15:29:50 +02:00
parent c19ebe3089
commit 8bb18fa201
9 changed files with 439 additions and 438 deletions
+5 -19
View File
@@ -309,29 +309,15 @@ class PlaybackCoordinatorModule extends BaseModule {
}
scheduleWebGLReveal(sentence, animQueue) {
let wordTimings = Array.isArray(sentence.animation?.wordTimings)
// The book playback timeline is the single owner of reveal timing. It guarantees
// sentence.animation is populated (ensureAnimationTimings) before playback. The
// coordinator trusts those timings and never recomputes them here.
const wordTimings = Array.isArray(sentence.animation?.wordTimings)
? sentence.animation.wordTimings
: [];
let cueTimings = Array.isArray(sentence.animation?.cueTimings)
const cueTimings = Array.isArray(sentence.animation?.cueTimings)
? sentence.animation.cueTimings
: [];
const timingDuration = wordTimings.reduce((max, timing) => Math.max(
max,
Number(timing?.delay || 0) + Number(timing?.duration || 0)
), Number(sentence.animation?.totalDuration || 0));
const ttsDuration = Number(sentence.tts?.duration || sentence.animation?.totalDuration || 0);
if (wordTimings.length === 0 || (timingDuration <= 0 && ttsDuration > 0)) {
const words = String(sentence.text || '').match(/\S+/g) || [];
const calculated = this.calculateWordTimings(words, ttsDuration);
wordTimings = calculated.wordTimings;
cueTimings = [];
sentence.animation = {
...(sentence.animation || {}),
wordTimings,
cueTimings,
totalDuration: calculated.totalDuration
};
}
if (typeof sentence.webglRevealController !== 'function') {
throw new Error('PlaybackCoordinator: WebGL playback requires a prepared timeline reveal controller');