Checkpoint WebGL book reveal optimization
This commit is contained in:
@@ -20,8 +20,10 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.isProcessing = false;
|
||||
this.onSentenceReadyCallback = null;
|
||||
|
||||
// Cache in-flight TTS prefetches only. Layout belongs to the renderer.
|
||||
// Cache prepared future queue items so the playback path can consume
|
||||
// work that was already generated during lookahead.
|
||||
this.prefetchingSpeech = new Map();
|
||||
this.preparedSentenceCache = new Map();
|
||||
this.autoplay = true;
|
||||
this.inputMode = 'text';
|
||||
this.lastContinueAt = 0;
|
||||
@@ -43,6 +45,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
'getCacheKey',
|
||||
'getPreparedSentence',
|
||||
'prefetchAhead',
|
||||
'prefetchWebGLBookPresentation',
|
||||
'prepareSpeechMetadata',
|
||||
'preloadAssetsForItem',
|
||||
'normalizeTtsText',
|
||||
@@ -156,9 +159,12 @@ class SentenceQueueModule extends BaseModule {
|
||||
text: String(queueItem.text || '').trim()
|
||||
});
|
||||
|
||||
// Process the queue if not already processing
|
||||
// Process the queue if not already processing. If playback is already
|
||||
// running, immediately start lookahead for the newly appended item.
|
||||
if (!this.isProcessing) {
|
||||
this.processNextSentence();
|
||||
} else {
|
||||
this.prefetchAhead(4, this.queueGeneration);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,6 +200,11 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
const sentence = await this.getPreparedSentence(item);
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
await this.prefetchWebGLBookPresentation(sentence, {
|
||||
queueGeneration,
|
||||
queueIndex: 0
|
||||
});
|
||||
if (!this.isCurrentQueueItem(item, queueGeneration)) return;
|
||||
|
||||
// Prefetch far enough ahead that media pauses do not block TTS
|
||||
// generation for the next spoken paragraph.
|
||||
@@ -499,14 +510,15 @@ class SentenceQueueModule extends BaseModule {
|
||||
* Prepare queue metadata. This module intentionally does not create layout:
|
||||
* live rendering and history rendering must go through the same renderer.
|
||||
*/
|
||||
async prepareSentence(item) {
|
||||
async prepareSentence(item, options = {}) {
|
||||
const text = typeof item === 'string' ? item : item.text;
|
||||
const id = item.id || `paragraph-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
const metadata = typeof item === 'object' && item !== null ? item : {};
|
||||
const blocking = options.blocking !== false;
|
||||
|
||||
try {
|
||||
if (metadata.type && !['paragraph', 'heading'].includes(metadata.type)) {
|
||||
await this.preloadAssetsForItem(metadata, { blocking: true, sentenceId: id });
|
||||
await this.preloadAssetsForItem(metadata, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -529,7 +541,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
await this.preloadAssetsForItem({
|
||||
type: 'paragraph',
|
||||
cueMarkers: metadata.cueMarkers || []
|
||||
}, { blocking: true, sentenceId: id });
|
||||
}, { blocking, sentenceId: id, prefetch: Boolean(options.prefetch) });
|
||||
}
|
||||
|
||||
const ttsData = await this.prepareSpeechMetadata(text, {
|
||||
@@ -537,7 +549,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
blockId: metadata.blockId ?? null,
|
||||
turnId: metadata.turnId ?? null,
|
||||
ttsInstructions: Array.isArray(metadata.ttsInstructions) ? metadata.ttsInstructions : [],
|
||||
blocking: true
|
||||
blocking
|
||||
});
|
||||
|
||||
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
|
||||
@@ -834,7 +846,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
resolve();
|
||||
};
|
||||
const onCommand = (event) => {
|
||||
if (event.detail?.type === 'continue') {
|
||||
if (event.detail?.type === 'continue' && !this.isChoiceAwaitingPlayer()) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
@@ -846,15 +858,81 @@ class SentenceQueueModule extends BaseModule {
|
||||
return `${item?.id || ''}:${item?.text || ''}`;
|
||||
}
|
||||
|
||||
isChoiceAwaitingPlayer() {
|
||||
if (this.inputMode !== 'choice') {
|
||||
return false;
|
||||
}
|
||||
const choicePanel = document.getElementById('story_choices');
|
||||
return Boolean(choicePanel && !choicePanel.hidden && choicePanel.dataset.choiceReady === 'true');
|
||||
}
|
||||
|
||||
async getPreparedSentence(item) {
|
||||
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
|
||||
const cacheKey = this.getCacheKey(item);
|
||||
const prepared = this.preparedSentenceCache.get(cacheKey);
|
||||
if (prepared) {
|
||||
this.preparedSentenceCache.delete(cacheKey);
|
||||
return prepared;
|
||||
}
|
||||
|
||||
const pending = this.prefetchingSpeech.get(cacheKey);
|
||||
if (pending) {
|
||||
pending.catch(() => null);
|
||||
const prefetched = await pending.catch(() => null);
|
||||
if (prefetched) {
|
||||
this.preparedSentenceCache.delete(cacheKey);
|
||||
return prefetched;
|
||||
}
|
||||
}
|
||||
|
||||
return this.prepareSentence(item);
|
||||
}
|
||||
|
||||
async prefetchWebGLBookPresentation(sentence, options = {}) {
|
||||
if (!sentence || !['paragraph', 'heading'].includes(sentence.kind || sentence.type)) return null;
|
||||
const isWebGLMode = document.body?.dataset?.webglUiMode === '3d'
|
||||
|| document.body?.classList?.contains('webgl-mode');
|
||||
if (!isWebGLMode) return null;
|
||||
|
||||
const blockId = sentence.blockId ?? sentence.metadata?.blockId ?? null;
|
||||
if (blockId == null) return null;
|
||||
const bookPagination = this.getModule('book-pagination');
|
||||
const bookTextureRenderer = this.getModule('book-texture-renderer');
|
||||
if (!bookPagination || !bookTextureRenderer) return null;
|
||||
|
||||
if (!Array.isArray(sentence.animation?.wordTimings) || sentence.animation.wordTimings.length === 0) {
|
||||
const words = String(sentence.layoutText || sentence.text || '').match(/\S+/g) || [];
|
||||
sentence.animation = this.calculateAnimationTiming(words, sentence.tts?.duration || 0, sentence.cueMarkers || []);
|
||||
}
|
||||
|
||||
await new Promise(resolve => {
|
||||
const scheduler = window.requestIdleCallback || ((callback) => window.setTimeout(callback, 1));
|
||||
scheduler(() => resolve(), { timeout: 120 });
|
||||
});
|
||||
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
||||
|
||||
const spread = typeof bookPagination.preparePendingBlock === 'function'
|
||||
? await bookPagination.preparePendingBlock(sentence, {
|
||||
activate: false,
|
||||
publish: false,
|
||||
includeUnrenderedHistory: true
|
||||
})
|
||||
: null;
|
||||
if (!spread) return null;
|
||||
if (options.queueGeneration !== undefined && options.queueGeneration !== this.queueGeneration) return null;
|
||||
|
||||
if (typeof bookTextureRenderer.prepareRevealBlock === 'function') {
|
||||
bookTextureRenderer.prepareRevealBlock({
|
||||
id: sentence.id,
|
||||
blockId,
|
||||
wordTimings: sentence.animation?.wordTimings || [],
|
||||
cueTimings: sentence.animation?.cueTimings || [],
|
||||
totalDuration: sentence.animation?.totalDuration || 0,
|
||||
spread,
|
||||
preloadOnly: true
|
||||
}, { preloadOnly: true });
|
||||
}
|
||||
return spread;
|
||||
}
|
||||
|
||||
isCurrentQueueItem(item, queueGeneration = this.queueGeneration) {
|
||||
return queueGeneration === this.queueGeneration && this.sentenceQueue[0] === item;
|
||||
}
|
||||
@@ -888,35 +966,28 @@ class SentenceQueueModule extends BaseModule {
|
||||
|
||||
const promise = (async () => {
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
await this.preloadAssetsForItem(nextItem, {
|
||||
sentenceId: nextItem.id,
|
||||
const prepared = await this.prepareSentence(nextItem, {
|
||||
blocking: false,
|
||||
prefetch: true
|
||||
prefetch: true,
|
||||
queueIndex: index
|
||||
});
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
|
||||
if (!this.isSpeechItem(nextItem)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.prepareSpeechMetadata(nextItem.text || '', {
|
||||
sentenceId: nextItem.id,
|
||||
blockId: nextItem.blockId ?? null,
|
||||
turnId: nextItem.turnId ?? null,
|
||||
ttsInstructions: Array.isArray(nextItem.ttsInstructions) ? nextItem.ttsInstructions : [],
|
||||
queueIndex: index,
|
||||
prefetch: true,
|
||||
blocking: false
|
||||
await this.prefetchWebGLBookPresentation(prepared, {
|
||||
queueGeneration,
|
||||
queueIndex: index
|
||||
});
|
||||
if (queueGeneration !== this.queueGeneration) return null;
|
||||
this.preparedSentenceCache.set(nextCacheKey, prepared);
|
||||
return prepared;
|
||||
})()
|
||||
.then(() => {
|
||||
.then((prepared) => {
|
||||
if (queueGeneration !== this.queueGeneration) return false;
|
||||
console.log('SentenceQueue: Prefetched queued speech/media', { sentenceId: nextItem.id, queueIndex: index });
|
||||
document.dispatchEvent(new CustomEvent('story:process-state', {
|
||||
detail: { state: 'playing-ready', reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index }
|
||||
}));
|
||||
console.log('Process state: playing-ready', { reason: 'prefetch-complete', sentenceId: nextItem.id, queueIndex: index });
|
||||
return true;
|
||||
return prepared || true;
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('SentenceQueue: Prefetch failed:', err);
|
||||
@@ -1341,6 +1412,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.cancelGenerationRequests('sentence-queue-cleared');
|
||||
this.cancelAssetPreloads('sentence-queue-cleared');
|
||||
this.prefetchingSpeech.clear();
|
||||
this.preparedSentenceCache.clear();
|
||||
this.pauseBeforeNextReason = null;
|
||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||
detail: { reason: 'sentence-queue-cleared' }
|
||||
|
||||
Reference in New Issue
Block a user