Fix spanning-paragraph reveal pacing (right page no longer consumes full TTS)
A paragraph that overflows the right page onto the next spread revealed its single right-page line over the entire TTS, then timed out (timeline-reveal-commit-timeout) and only flipped after the whole narration. Two root causes: - At activate the reused lookahead segment played a sentence instance whose animation word-timings were lost (wordTimings=[], totalDuration=0), so reveal timing fell back to an area estimate spanning the full TTS. Snapshot the timings at prepare and restore them at activate. - Reveal duration was distributed by ink area, but just-paginated continuation lines have ~0 area, so the one right-page line received the whole duration. Distribute by word count (reliable) with area as fallback. Now the right page reveals only its word share (~2.7s for a 6/55-word line), commits, and flips while TTS continues; the continuation animates on the next spread. Also rewrote the right-reveal wait to a single timer + commit/fast-forward listeners with cleanup, removing the stray timeline-reveal-commit-timeout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -55,7 +55,6 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
'waitForVisualCompletion',
|
||||
'revealContinuationSpread',
|
||||
'waitForPlannedRightReveal',
|
||||
'waitForRevealCommit',
|
||||
'requestPageFlip',
|
||||
'prepareFlipPlan',
|
||||
'waitForPageFlipFinished',
|
||||
@@ -228,6 +227,14 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
revealSides,
|
||||
requiresPreFlip: targetSpreadIndex > currentSpreadIndex,
|
||||
requiresRightFlip: revealSides.includes('right') && this.requiresRightPageFlipAfterReveal(previewSpread),
|
||||
// Snapshot the reveal timings now. A reused lookahead segment can be played by
|
||||
// a sentence instance whose animation timings were lost; without them the
|
||||
// reveal can't be word-paced and stretches across the whole TTS.
|
||||
preparedAnimation: {
|
||||
wordTimings: Array.isArray(revealDetail.wordTimings) ? revealDetail.wordTimings : [],
|
||||
cueTimings: Array.isArray(revealDetail.cueTimings) ? revealDetail.cueTimings : [],
|
||||
totalDuration: Number(revealDetail.totalDuration || 0)
|
||||
},
|
||||
preparedTexturePlan: texturePlan,
|
||||
preparedAt: performance.now(),
|
||||
revealStartedAt: null,
|
||||
@@ -274,6 +281,16 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
|
||||
async activatePreparedSegment(segment = {}, sentence = segment.sentence) {
|
||||
if (!segment || !sentence) return null;
|
||||
// Restore the reveal timings captured at prepare if the live sentence lost them,
|
||||
// otherwise the reveal degrades to an area estimate spanning the whole TTS.
|
||||
if (segment.preparedAnimation?.wordTimings?.length && !(sentence.animation?.wordTimings?.length)) {
|
||||
sentence.animation = {
|
||||
...(sentence.animation || {}),
|
||||
wordTimings: segment.preparedAnimation.wordTimings,
|
||||
cueTimings: segment.preparedAnimation.cueTimings,
|
||||
totalDuration: segment.preparedAnimation.totalDuration
|
||||
};
|
||||
}
|
||||
const spread = segment.activeSpread || segment.previewSpread;
|
||||
const revealDetail = this.createRevealDetail(sentence, spread, 'activate');
|
||||
// For a spanning block the prepared reveal plan was built during lookahead before
|
||||
@@ -477,6 +494,9 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Resolve when the right page's own portion of the reveal is done — its computed
|
||||
// duration elapses, the reveal commits, or the player fast-forwards — whichever comes
|
||||
// first. Single timer + listeners with full cleanup, so no stray commit-timeout fires.
|
||||
async waitForPlannedRightReveal(segment = {}) {
|
||||
const startedAt = Number(segment.revealStartedAt)
|
||||
|| await (segment.revealStartedPromise || Promise.resolve(performance.now()));
|
||||
@@ -488,13 +508,29 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
});
|
||||
const elapsed = Math.max(0, performance.now() - Number(startedAt || performance.now()));
|
||||
const remaining = Math.max(0, duration - elapsed);
|
||||
const planned = new Promise(resolve => {
|
||||
setTimeout(() => resolve(true), remaining);
|
||||
const blockId = String(segment.blockId ?? '');
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (value) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
clearTimeout(timer);
|
||||
document.removeEventListener('webgl-book:reveal-committed', onCommit);
|
||||
document.removeEventListener('webgl-book:page-reveal-fast-forward', onFastForward);
|
||||
resolve(value);
|
||||
};
|
||||
const onCommit = (event) => {
|
||||
const detail = event.detail || {};
|
||||
if (detail.side !== 'right') return;
|
||||
const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : [];
|
||||
if (blockId && ids.length && !ids.includes(blockId)) return;
|
||||
finish(true);
|
||||
};
|
||||
const onFastForward = () => finish(true);
|
||||
const timer = setTimeout(() => finish(true), remaining);
|
||||
document.addEventListener('webgl-book:reveal-committed', onCommit);
|
||||
document.addEventListener('webgl-book:page-reveal-fast-forward', onFastForward);
|
||||
});
|
||||
return Promise.race([
|
||||
planned,
|
||||
this.waitForRevealCommit(segment)
|
||||
]);
|
||||
}
|
||||
|
||||
getRightRevealDurationMs(segment = {}) {
|
||||
@@ -505,37 +541,6 @@ class BookPlaybackTimelineModule extends BaseModule {
|
||||
return Math.max(1, Number(segment.sentence?.animation?.totalDuration || 1));
|
||||
}
|
||||
|
||||
waitForRevealCommit(segment = {}) {
|
||||
const blockId = String(segment.blockId ?? '');
|
||||
if (!blockId) return Promise.resolve(false);
|
||||
return new Promise(resolve => {
|
||||
let resolved = false;
|
||||
const finish = (value) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
clearTimeout(timeoutId);
|
||||
document.removeEventListener('webgl-book:reveal-committed', onCommitted);
|
||||
resolve(value);
|
||||
};
|
||||
const onCommitted = (event) => {
|
||||
const detail = event.detail || {};
|
||||
if (detail.side !== 'right' || detail.pageFlipAfterReveal !== true) return;
|
||||
const ids = Array.isArray(detail.blockIds) ? detail.blockIds.map(value => String(value)) : [];
|
||||
if (!ids.includes(blockId)) return;
|
||||
finish(true);
|
||||
};
|
||||
const timeoutId = setTimeout(() => {
|
||||
this.pageCache?.recordProblem?.({
|
||||
type: 'timeline-reveal-commit-timeout',
|
||||
blockId: segment.blockId,
|
||||
targetSpread: segment.targetSpreadIndex
|
||||
});
|
||||
finish(false);
|
||||
}, Math.max(2000, Number(segment.sentence?.animation?.totalDuration || 0) + 3000));
|
||||
document.addEventListener('webgl-book:reveal-committed', onCommitted);
|
||||
});
|
||||
}
|
||||
|
||||
async requestPageFlip(direction = 1, options = {}) {
|
||||
if (this.isChoiceAwaitingPlayer()) return false;
|
||||
// Warm the texture cache for the navigation window and verify the target pages
|
||||
|
||||
@@ -698,9 +698,18 @@ class BookTextureRendererModule extends BaseModule {
|
||||
const useWordShare = totalBlockWords > 0 && collectedWords > 0 && collectedWords < totalBlockWords;
|
||||
const totalDuration = useWordShare ? baseDuration * (collectedWords / totalBlockWords) : baseDuration;
|
||||
let fallbackDelay = useWordShare && Number.isFinite(wordsBefore) ? baseDuration * (wordsBefore / totalBlockWords) : 0;
|
||||
// Weight each line by its word count when available, falling back to ink area.
|
||||
// Word counts are reliable even for just-paginated continuation lines whose rect
|
||||
// area can be ~0; area-weighting there would hand the whole duration to the one
|
||||
// line on the current page and stretch it across the entire TTS.
|
||||
const useWordWeights = collectedWords > 0;
|
||||
const totalWeight = useWordWeights ? collectedWords : totalArea;
|
||||
textRegions.forEach((region) => {
|
||||
const duration = totalArea > 0
|
||||
? Math.max(1, totalDuration * (Math.max(1, region.timingArea || region.area) / totalArea))
|
||||
const weight = useWordWeights
|
||||
? Math.max(0, Number(region.blockWordCount || 0))
|
||||
: Math.max(1, region.timingArea || region.area);
|
||||
const duration = totalWeight > 0
|
||||
? Math.max(1, totalDuration * (weight / totalWeight))
|
||||
: Math.max(1, totalDuration / Math.max(1, textRegions.length));
|
||||
timedRegions.push({
|
||||
...region,
|
||||
|
||||
Reference in New Issue
Block a user