Add ink integration UI and media playback
This commit is contained in:
@@ -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: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user