Refine line-based story scrolling

This commit is contained in:
2026-05-16 21:40:36 +02:00
parent b9ae7f71c5
commit e368d252ad
10 changed files with 1238 additions and 755 deletions
+28 -167
View File
@@ -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' }
}));