Refine line-based story scrolling
This commit is contained in:
@@ -16,10 +16,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
this.isProcessing = false;
|
||||
this.onSentenceReadyCallback = null;
|
||||
|
||||
// Cache for prefetched sentences
|
||||
this.preparedCache = new Map();
|
||||
this.prefetchingCache = new Map();
|
||||
this.activeImageWrap = null;
|
||||
// Cache in-flight TTS prefetches only. Layout belongs to the renderer.
|
||||
this.prefetchingSpeech = new Map();
|
||||
this.autoplay = true;
|
||||
this.inputMode = 'text';
|
||||
this.lastContinueAt = 0;
|
||||
@@ -44,7 +42,6 @@ class SentenceQueueModule extends BaseModule {
|
||||
'waitForManualContinue',
|
||||
'prepareSentence',
|
||||
'prepareLayout',
|
||||
'prepareImageLayout',
|
||||
'extractWords',
|
||||
'getDropCapText',
|
||||
'extractDropCapText',
|
||||
@@ -279,9 +276,8 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a complete sentence object with TTS and layout
|
||||
* @param {string} text - Text to prepare
|
||||
* @returns {Promise<Object>} - Complete sentence object
|
||||
* Prepare queue metadata. This module intentionally does not create layout:
|
||||
* live rendering and history rendering must go through the same renderer.
|
||||
*/
|
||||
async prepareSentence(item) {
|
||||
const text = typeof item === 'string' ? item : item.text;
|
||||
@@ -297,10 +293,6 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
}
|
||||
|
||||
const imageLayout = metadata.type === 'image'
|
||||
? await this.prepareImageLayout(metadata)
|
||||
: null;
|
||||
|
||||
return {
|
||||
id,
|
||||
kind: metadata.type,
|
||||
@@ -309,7 +301,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
blockId: metadata.blockId ?? null,
|
||||
gameId: metadata.gameId ?? null,
|
||||
status: 'ready',
|
||||
metadata: imageLayout ? { ...metadata, imageLayout } : metadata,
|
||||
metadata,
|
||||
tts: { duration: 0, provider: null, audioData: null, play: null, stop: null, enabled: false },
|
||||
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
|
||||
element: null,
|
||||
@@ -322,17 +314,9 @@ class SentenceQueueModule extends BaseModule {
|
||||
await audioManager.preloadMediaCues(metadata.cueMarkers || []);
|
||||
}
|
||||
|
||||
// Prepare TTS and layout in parallel
|
||||
const [ttsData, layoutData] = await Promise.all([
|
||||
this.prepareSpeechMetadata(text),
|
||||
this.prepareLayout(text, metadata)
|
||||
]);
|
||||
const ttsData = await this.prepareSpeechMetadata(text);
|
||||
|
||||
// Calculate animation timing based on TTS duration
|
||||
const words = this.extractWords(layoutData.nodes);
|
||||
const animation = this.calculateAnimationTiming(words, ttsData.duration, metadata.cueMarkers || []);
|
||||
|
||||
console.log(`SentenceQueue: Prepared sentence "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms, Words: ${words.length}, Animation total: ${animation.totalDuration}ms, Layout breaks: ${layoutData.breaks.length}`);
|
||||
console.log(`SentenceQueue: Prepared speech "${text.substring(0, 50)}..." - TTS duration: ${ttsData.duration}ms`);
|
||||
|
||||
return {
|
||||
id,
|
||||
@@ -348,7 +332,6 @@ class SentenceQueueModule extends BaseModule {
|
||||
addTopSpace: Boolean(metadata.addTopSpace),
|
||||
cueMarkers: metadata.cueMarkers || [],
|
||||
status: 'ready',
|
||||
layout: layoutData,
|
||||
tts: {
|
||||
duration: ttsData.duration,
|
||||
provider: ttsData.handler,
|
||||
@@ -357,7 +340,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
stop: ttsData.stop,
|
||||
enabled: ttsData.isTtsEnabled
|
||||
},
|
||||
animation: animation,
|
||||
animation: { wordTimings: [], cueTimings: [], totalDuration: 0 },
|
||||
element: null,
|
||||
onComplete: null
|
||||
};
|
||||
@@ -415,27 +398,10 @@ class SentenceQueueModule extends BaseModule {
|
||||
const indentWidth = (isHeading || metadata.isFirstParagraphInChapter || metadata.addTopSpace) ? 0 : lineHeight * 1.5;
|
||||
const layoutText = metadata.layoutText || text;
|
||||
const layoutPlainText = metadata.dropCap ? this.extractDropCapText(layoutText) : layoutText;
|
||||
const wrap = this.consumeImageWrap();
|
||||
|
||||
// Measures are consumed in line order by the line breaker.
|
||||
const wrappedWidth = wrap ? Math.max(120, containerWidth - wrap.width) : containerWidth;
|
||||
const imageLeftOffset = wrap && wrap.side !== 'right' ? wrap.width : 0;
|
||||
const imageRightOffset = wrap && wrap.side === 'right' ? wrap.width : 0;
|
||||
const measures = isHeading
|
||||
const measures = Array.isArray(metadata.measures) && metadata.measures.length > 0
|
||||
? metadata.measures
|
||||
: isHeading
|
||||
? [containerWidth]
|
||||
: wrap && metadata.dropCap
|
||||
? [
|
||||
Math.max(120, wrappedWidth - dropCapWidth),
|
||||
Math.max(120, wrappedWidth - dropCapWidth),
|
||||
...Array(Math.max(0, wrap.lines - dropCapLines)).fill(wrappedWidth),
|
||||
containerWidth
|
||||
]
|
||||
: wrap
|
||||
? [
|
||||
Math.max(120, wrappedWidth - indentWidth),
|
||||
...Array(Math.max(0, wrap.lines - 1)).fill(wrappedWidth),
|
||||
containerWidth
|
||||
]
|
||||
: metadata.dropCap
|
||||
? [
|
||||
Math.max(120, containerWidth - dropCapWidth),
|
||||
@@ -447,21 +413,10 @@ class SentenceQueueModule extends BaseModule {
|
||||
containerWidth,
|
||||
containerWidth
|
||||
];
|
||||
const lineOffsets = isHeading
|
||||
const lineOffsets = Array.isArray(metadata.lineOffsets) && metadata.lineOffsets.length > 0
|
||||
? metadata.lineOffsets
|
||||
: isHeading
|
||||
? [0]
|
||||
: wrap && metadata.dropCap
|
||||
? [
|
||||
imageLeftOffset + dropCapWidth,
|
||||
imageLeftOffset + dropCapWidth,
|
||||
...Array(Math.max(0, wrap.lines - dropCapLines)).fill(imageLeftOffset),
|
||||
0
|
||||
]
|
||||
: wrap
|
||||
? [
|
||||
imageLeftOffset + indentWidth,
|
||||
...Array(Math.max(0, wrap.lines - 1)).fill(imageLeftOffset),
|
||||
0
|
||||
]
|
||||
: metadata.dropCap
|
||||
? [
|
||||
dropCapWidth,
|
||||
@@ -474,7 +429,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
0
|
||||
];
|
||||
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, imageRightOffset: ${imageRightOffset.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
console.log(`SentenceQueue: Layout calculation - indentWidth: ${indentWidth.toFixed(1)}px, measures: [${measures.map(m => m.toFixed(1)).join(', ')}], offsets: [${lineOffsets.map(m => m.toFixed(1)).join(', ')}]`);
|
||||
|
||||
const layout = paragraphLayout.calculateLayout(layoutPlainText, {
|
||||
measures,
|
||||
@@ -488,14 +443,6 @@ class SentenceQueueModule extends BaseModule {
|
||||
throw new Error('Paragraph layout calculation failed');
|
||||
}
|
||||
|
||||
if (wrap) {
|
||||
const usedLines = Math.max(0, (layout.breaks?.length || 1) - 1);
|
||||
const remainingLines = Math.max(0, wrap.lines - usedLines);
|
||||
this.activeImageWrap = remainingLines > 0
|
||||
? { ...wrap, lines: remainingLines }
|
||||
: null;
|
||||
}
|
||||
|
||||
return {
|
||||
breaks: layout.breaks,
|
||||
nodes: layout.nodes,
|
||||
@@ -504,7 +451,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
measures,
|
||||
lineOffsets,
|
||||
indentWidth,
|
||||
imageWrap: wrap,
|
||||
imageWrap: metadata.imageWrap || null,
|
||||
dropCap: Boolean(metadata.dropCap),
|
||||
dropCapText: metadata.dropCap ? this.getDropCapText(layoutText) : '',
|
||||
dropCapLines,
|
||||
@@ -574,25 +521,9 @@ class SentenceQueueModule extends BaseModule {
|
||||
}
|
||||
|
||||
async getPreparedSentence(item) {
|
||||
const cacheKey = this.getCacheKey(item);
|
||||
const cached = this.preparedCache.get(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
console.log('SentenceQueue: Using cached sentence');
|
||||
this.preparedCache.delete(cacheKey);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const pending = this.prefetchingCache.get(cacheKey);
|
||||
const pending = this.prefetchingSpeech.get(this.getCacheKey(item));
|
||||
if (pending) {
|
||||
console.log('SentenceQueue: Awaiting active prefetch');
|
||||
try {
|
||||
const prepared = await pending;
|
||||
return prepared || await this.prepareSentence(item);
|
||||
} finally {
|
||||
this.prefetchingCache.delete(cacheKey);
|
||||
this.preparedCache.delete(cacheKey);
|
||||
}
|
||||
await pending.catch(() => null);
|
||||
}
|
||||
|
||||
return this.prepareSentence(item);
|
||||
@@ -614,7 +545,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
for (let index = 1; index < limit; index += 1) {
|
||||
const nextItem = this.sentenceQueue[index];
|
||||
const nextCacheKey = this.getCacheKey(nextItem);
|
||||
if (this.preparedCache.has(nextCacheKey) || this.prefetchingCache.has(nextCacheKey)) {
|
||||
if (this.prefetchingSpeech.has(nextCacheKey)) {
|
||||
if (this.isSpeechItem(nextItem)) spokenPrepared += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -625,25 +556,26 @@ class SentenceQueueModule extends BaseModule {
|
||||
}));
|
||||
console.log(`Process state: ${state}`, { reason: 'prefetch-start', sentenceId: nextItem.id, queueIndex: index });
|
||||
|
||||
const promise = this.prepareSentence(nextItem)
|
||||
.then(prepared => {
|
||||
this.preparedCache.set(nextCacheKey, prepared);
|
||||
console.log('SentenceQueue: Prefetched queued item', { sentenceId: nextItem.id, queueIndex: index });
|
||||
const promise = (this.isSpeechItem(nextItem)
|
||||
? this.prepareSpeechMetadata(nextItem.text || '')
|
||||
: Promise.resolve(null))
|
||||
.then(() => {
|
||||
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 prepared;
|
||||
return true;
|
||||
})
|
||||
.catch(err => {
|
||||
console.warn('SentenceQueue: Prefetch failed:', err);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
this.prefetchingCache.delete(nextCacheKey);
|
||||
this.prefetchingSpeech.delete(nextCacheKey);
|
||||
});
|
||||
|
||||
this.prefetchingCache.set(nextCacheKey, promise);
|
||||
this.prefetchingSpeech.set(nextCacheKey, promise);
|
||||
started += 1;
|
||||
|
||||
if (this.isSpeechItem(nextItem)) {
|
||||
@@ -741,76 +673,6 @@ class SentenceQueueModule extends BaseModule {
|
||||
});
|
||||
}
|
||||
|
||||
async prepareImageLayout(metadata = {}) {
|
||||
const storyElement = document.getElementById('story');
|
||||
if (!storyElement) {
|
||||
throw new Error("Story container not found");
|
||||
}
|
||||
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
await document.fonts.ready;
|
||||
}
|
||||
|
||||
const computedStyle = window.getComputedStyle(storyElement);
|
||||
const lineHeight = parseFloat(computedStyle.lineHeight) || 24;
|
||||
|
||||
const pageWidth = storyElement.clientWidth;
|
||||
const requestedSize = String(metadata.size || 'landscape').toLowerCase();
|
||||
const size = requestedSize === 'widescreen' ? 'landscape' : requestedSize;
|
||||
const isPortrait = size === 'portrait';
|
||||
const aspect = isPortrait ? (9 / 16) : size === 'square' ? 1 : (16 / 9);
|
||||
const imageGap = lineHeight;
|
||||
const maxOuterWidth = isPortrait ? pageWidth * 0.5 : pageWidth;
|
||||
const maxImageWidth = isPortrait
|
||||
? Math.max(lineHeight * 4, maxOuterWidth - imageGap)
|
||||
: maxOuterWidth;
|
||||
const naturalHeight = maxImageWidth / aspect;
|
||||
const imageLineCount = Math.max(1, Math.floor(naturalHeight / lineHeight));
|
||||
const verticalMargin = isPortrait ? lineHeight / 2 : 0;
|
||||
const lineCount = isPortrait ? imageLineCount + 1 : imageLineCount;
|
||||
const height = isPortrait
|
||||
? Math.max(lineHeight, (lineCount * lineHeight) - (verticalMargin * 2))
|
||||
: imageLineCount * lineHeight;
|
||||
const width = Math.min(maxImageWidth, height * aspect);
|
||||
|
||||
if (isPortrait) {
|
||||
this.activeImageWrap = {
|
||||
lines: lineCount,
|
||||
width: width + imageGap,
|
||||
imageWidth: width,
|
||||
gap: imageGap,
|
||||
height,
|
||||
lineHeight,
|
||||
side: metadata.floatSide || 'left'
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
size,
|
||||
aspect,
|
||||
width,
|
||||
height,
|
||||
gap: imageGap,
|
||||
lineCount,
|
||||
imageLineCount,
|
||||
lineHeight,
|
||||
verticalMargin,
|
||||
floatSide: metadata.floatSide || 'left',
|
||||
pageWidth
|
||||
};
|
||||
}
|
||||
|
||||
consumeImageWrap() {
|
||||
if (!this.activeImageWrap || this.activeImageWrap.lines <= 0) {
|
||||
this.activeImageWrap = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
const wrap = { ...this.activeImageWrap };
|
||||
this.activeImageWrap = null;
|
||||
return wrap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract words from layout nodes
|
||||
* @param {Array} nodes - Layout nodes from Knuth-Plass algorithm
|
||||
@@ -919,8 +781,7 @@ class SentenceQueueModule extends BaseModule {
|
||||
clear() {
|
||||
this.sentenceQueue = [];
|
||||
this.isProcessing = false;
|
||||
this.preparedCache.clear();
|
||||
this.activeImageWrap = null;
|
||||
this.prefetchingSpeech.clear();
|
||||
document.dispatchEvent(new CustomEvent('tts:queue-empty', {
|
||||
detail: { reason: 'sentence-queue-cleared' }
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user