fix(subtitles): collapse layered ASS lyrics across startup and seeks

- Reconstruct fragment-only karaoke while preserving canonical animated signs
- Reprocess live subtitles after initial playback and seek updates
- Add a development launcher for playable media files
This commit is contained in:
2026-08-19 22:55:10 -07:00
parent 695613b1e7
commit c01bcd9d0f
16 changed files with 830 additions and 55 deletions
+18 -17
View File
@@ -25,7 +25,7 @@ function nearbyCanonicalCues(
currentTimeSec: number,
): SubtitleCue[] {
return (cues ?? []).filter((cue) => {
if (cue.source !== 'canonical-ass') {
if (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') {
return false;
}
const span = animationSpan(cue);
@@ -40,6 +40,21 @@ function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
}
// ASS layers can encode the same visible spacing with ordinary, hard, or
// ideographic spaces. Matching and emission must use the same identity or each
// layer reappears as a copy.
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of cues) {
const compactText = compactWhitespace(cue.text);
if (seen.has(compactText)) continue;
seen.add(compactText);
texts.push(cue.text);
}
return texts;
}
function compactLineSegments(text: string): string[] {
return text.split('\n').map(compactWhitespace).filter(Boolean);
}
@@ -83,14 +98,7 @@ function resolveActiveParsedPrimarySubtitle(options: {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
const texts = uniqueCueTexts(selected);
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
@@ -159,14 +167,7 @@ export function resolveCanonicalPrimarySubtitle(options: {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
const texts = uniqueCueTexts(selected);
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),