Add ink integration UI and media playback

This commit is contained in:
2026-05-15 21:23:46 +02:00
parent 44dc64f830
commit f2e786d5bc
89 changed files with 6561 additions and 556 deletions
+73 -90
View File
@@ -18,6 +18,8 @@ class MarkupParserModule extends BaseModule {
'parse',
'parseParagraph',
'parseInline',
'parseImageOptions',
'parseSfxOptions',
'parseMusicOptions',
'markdownToHtml',
'markdownToPlainText',
@@ -38,7 +40,6 @@ class MarkupParserModule extends BaseModule {
const text = String(input || '').replace(/\r\n?/g, '\n');
const blocks = [];
let paragraphBuffer = [];
let nextParagraphRole = null;
const flushParagraph = () => {
if (paragraphBuffer.length === 0) return;
@@ -48,8 +49,7 @@ class MarkupParserModule extends BaseModule {
const paragraph = this.parseParagraph(raw);
if (!paragraph.text) return;
const role = nextParagraphRole || 'body';
nextParagraphRole = null;
const role = 'body';
blocks.push(this.buildParagraphBlock(paragraph, role));
};
@@ -61,65 +61,6 @@ class MarkupParserModule extends BaseModule {
return;
}
const chapter = trimmed.match(/^::chapter(?:\[(.*?)\]|\s+(.+))$/i);
if (chapter) {
flushParagraph();
const heading = (chapter[1] || chapter[2] || '').trim();
if (heading) {
const normalizedHeading = this.normalizeParagraph(heading);
blocks.push({
type: 'heading',
text: this.markdownToPlainText(normalizedHeading),
layoutText: this.markdownToHtml(normalizedHeading),
role: 'chapter-heading'
});
}
nextParagraphRole = 'chapter-first';
return;
}
const section = trimmed.match(/^::(?:section|textblock)(?:\[(.*?)\]|\s+(.+))?$/i);
if (section) {
flushParagraph();
const heading = (section[1] || section[2] || '').trim();
if (heading) {
const normalizedHeading = this.normalizeParagraph(heading);
blocks.push({
type: 'heading',
text: this.markdownToPlainText(normalizedHeading),
layoutText: this.markdownToHtml(normalizedHeading),
role: 'section-heading'
});
}
nextParagraphRole = 'textblock-first';
return;
}
const image = trimmed.match(/^::image\[(widescreen|portrait)\]\(([^)]+)\)$/i);
if (image) {
flushParagraph();
blocks.push({
type: 'image',
size: image[1].toLowerCase(),
filename: image[2].trim(),
url: this.resolveAssetUrl('images', image[2].trim())
});
return;
}
const music = trimmed.match(/^::music(?:\[([^\]]*)\])?\(([^)]+)\)$/i);
if (music) {
flushParagraph();
const options = this.parseMusicOptions(music[1] || 'crossfade');
blocks.push({
type: 'music',
...options,
filename: music[2].trim(),
url: this.resolveAssetUrl('music', music[2].trim())
});
return;
}
paragraphBuffer.push(line);
});
@@ -128,6 +69,35 @@ class MarkupParserModule extends BaseModule {
return blocks;
}
parseImageOptions(optionText) {
const options = {
size: 'landscape',
leadInSeconds: 0
};
String(optionText || 'landscape')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach((token, index) => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['landscape', 'widescreen', 'portrait', 'square'].includes(lower)) {
options.size = lower === 'widescreen' ? 'landscape' : lower;
} else if (['lead', 'lead-in', 'leadins', 'lead-in-seconds', 'delay', 'intro', 'pause', 'wait', 'hold'].includes(key)) {
const seconds = Number(value);
options.leadInSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.leadInSeconds = Number(lower.replace(/s$/, ''));
} else if (index === 0) {
console.warn(`MarkupParser: Unknown image size "${token}", using landscape`);
}
});
return options;
}
parseMusicOptions(optionText) {
const options = {
mode: 'crossfade',
@@ -162,6 +132,45 @@ class MarkupParserModule extends BaseModule {
return options;
}
parseSfxOptions(optionText) {
const options = {
maxDurationSeconds: 0,
endMode: 'stop',
fadeDurationSeconds: 2
};
String(optionText || '')
.split(/[,\s]+/)
.map(token => token.trim())
.filter(Boolean)
.forEach(token => {
const lower = token.toLowerCase();
const [key, value] = lower.split('=');
if (['fade', 'fadeout', 'fade-out'].includes(lower)) {
options.endMode = 'fade';
} else if (['stop', 'cut', 'halt'].includes(lower)) {
options.endMode = 'stop';
} else if (['max', 'duration', 'max-duration', 'limit', 'stop-after', 'fade-after'].includes(key)) {
const seconds = Number(value);
options.maxDurationSeconds = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
if (key === 'fade-after') options.endMode = 'fade';
} else if (/^\d+(\.\d+)?s?$/.test(lower)) {
options.maxDurationSeconds = Number(lower.replace(/s$/, ''));
} else if (key === 'mode' && ['fade', 'fadeout', 'fade-out', 'stop', 'cut'].includes(value)) {
options.endMode = value.startsWith('fade') ? 'fade' : 'stop';
} else if (['fade-duration', 'fade-time', 'fade'].includes(key)) {
const seconds = Number(value);
if (Number.isFinite(seconds)) {
options.fadeDurationSeconds = Math.max(0.1, seconds);
options.endMode = 'fade';
}
}
});
return options;
}
parseParagraph(rawText) {
const inline = this.parseInline(this.normalizeParagraph(rawText));
return {
@@ -185,35 +194,9 @@ class MarkupParserModule extends BaseModule {
}
parseInline(text) {
const cueMarkers = [];
let output = '';
let cursor = 0;
const markerPattern = /\{\{\s*(sfx|music)\s*:\s*(?:(queue|crossfade|cut)\s*:\s*)?([^}]+?)\s*\}\}/gi;
for (const match of text.matchAll(markerPattern)) {
output += text.slice(cursor, match.index);
const charIndex = output.length;
const wordIndex = this.countWords(output);
const type = match[1].toLowerCase();
const mode = type === 'music' ? (match[2] || 'crossfade').toLowerCase() : null;
cueMarkers.push({
type,
mode,
filename: match[3].trim(),
url: this.resolveAssetUrl(type === 'sfx' ? 'sounds' : 'music', match[3].trim()),
charIndex,
wordIndex
});
cursor = match.index + match[0].length;
}
output += text.slice(cursor);
return {
text: output.replace(/\s{2,}/g, ' ').trim(),
cueMarkers
text: String(text || '').replace(/\s{2,}/g, ' ').trim(),
cueMarkers: []
};
}