mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 12:15:26 -07:00
fix(subtitles): recover positioned ASS word spacing and drop control deb
- Recover Latin/romaji word spaces encoded only by positioned `\pos`/`\move` fragment gaps in ASS karaoke - Drop malformed ASS rotation-reset spacer lines and skip zero-duration metadata events - Dedupe duplicate lines across multiline primary/secondary cues, including full-width variants - Treat dense fragment-grid sign layouts as visual typesetting, not publishable text, during primary subtitle resolution
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: overlay
|
area: overlay
|
||||||
|
|
||||||
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries instead of joining every word together. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become one concatenated secondary line. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
|
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Fragmented ASS karaoke keeps spaces authored at event boundaries and recovers Latin word spaces encoded only by positioned fragment gaps. Long ASS lines repeated as dialogue and positioned signs are also collapsed when they differ only in whitespace or terminal punctuation. Dense multi-row sign layouts no longer become concatenated primary or secondary lines. Live mpv text remains the fallback for unreadable tracks and applies full-line duplicate filtering before display.
|
||||||
|
|||||||
@@ -120,10 +120,12 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
- Simultaneous ASS lines are flattened in top-to-bottom positioned order, falling back to their
|
||||||
authored source order when no usable position exists.
|
authored source order when no usable position exists.
|
||||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||||
survive concatenation, while scripts that discarded their word boundaries remain compact
|
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
||||||
instead of gaining false spaces between syllables. Short runs qualify only when overlapping
|
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
||||||
positioned events also show changing overrides or repeated layer copies; an English or romaji
|
line. Unpositioned fragments stay compact instead of gaining guessed spaces between syllables.
|
||||||
style name alone never turns ordinary dialogue into a lyric.
|
Short runs qualify only when overlapping positioned events also show changing overrides or
|
||||||
|
repeated layer copies; an English or romaji style name alone never turns ordinary dialogue into
|
||||||
|
a lyric.
|
||||||
- Recovered canonical ASS text remains active for the generated animation envelope. For
|
- Recovered canonical ASS text remains active for the generated animation envelope. For
|
||||||
reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit
|
reconstructed lyric styles, the longest-lived active line wins over brief entrance and exit
|
||||||
fragments from the same style.
|
fragments from the same style.
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
isAssTemporalCommand,
|
isAssTemporalCommand,
|
||||||
normalizePlainSubtitleText,
|
normalizePlainSubtitleText,
|
||||||
parseAssEffectField,
|
parseAssEffectField,
|
||||||
|
removeAssControlDebrisLines,
|
||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
|
|
||||||
test('assToPlainText drops vector drawing runs', () => {
|
test('assToPlainText drops vector drawing runs', () => {
|
||||||
@@ -74,6 +75,14 @@ test('assToPlainText normalizes CRLF before converting', () => {
|
|||||||
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removeAssControlDebrisLines drops malformed spacer resets without eating dialogue', () => {
|
||||||
|
assert.equal(
|
||||||
|
removeAssControlDebrisLines('Visible line\n\\\n{\\fr0\n\\{\\frz287.5'),
|
||||||
|
'Visible line',
|
||||||
|
);
|
||||||
|
assert.equal(removeAssControlDebrisLines('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
});
|
||||||
|
|
||||||
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
||||||
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||||
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||||
|
|||||||
@@ -91,6 +91,23 @@ export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): st
|
|||||||
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MALFORMED_ASS_ROTATION_RESET = /^\\?\{\\(?:fr|frx|fry|frz|fax|fay)[-+.0-9]*$/u;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop non-rendering spacer events left as literal text by a malformed, unclosed ASS
|
||||||
|
* rotation reset. These events otherwise become repeated `\\` or `{\\fr0` subtitle
|
||||||
|
* lines after mpv-compatible decoding.
|
||||||
|
*/
|
||||||
|
export function removeAssControlDebrisLines(text: string): string {
|
||||||
|
return text
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => {
|
||||||
|
const compact = line.replace(/\s+/gu, '');
|
||||||
|
return compact !== '\\' && !MALFORMED_ASS_ROTATION_RESET.test(compact);
|
||||||
|
})
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export interface NormalizePlainSubtitleTextOptions {
|
export interface NormalizePlainSubtitleTextOptions {
|
||||||
/** Fold every line break into a single space. */
|
/** Fold every line break into a single space. */
|
||||||
collapseLineBreaks?: boolean;
|
collapseLineBreaks?: boolean;
|
||||||
|
|||||||
@@ -35,6 +35,12 @@ test('parseSrtCues handles multi-line subtitle text', () => {
|
|||||||
assert.equal(cues[0]!.text, 'これは\nテストです');
|
assert.equal(cues[0]!.text, 'これは\nテストです');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSrtCues preserves lines that only resemble malformed ASS controls', () => {
|
||||||
|
const content = ['1', '00:01:00,000 --> 00:01:05,000', '\\', '{\\fr0', ''].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSrtCues(content)[0]?.text, '\\\n{\\fr0');
|
||||||
|
});
|
||||||
|
|
||||||
test('parseSrtCues strips HTML-like markup while preserving line breaks', () => {
|
test('parseSrtCues strips HTML-like markup while preserving line breaks', () => {
|
||||||
const content = [
|
const content = [
|
||||||
'1',
|
'1',
|
||||||
@@ -1021,3 +1027,215 @@ test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
|||||||
assert.equal(cues.length, 1);
|
assert.equal(cues.length, 1);
|
||||||
assert.equal(cues[0]!.text, 'URLテスト');
|
assert.equal(cues[0]!.text, 'URLテスト');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues skips zero-duration ASS metadata events', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:00.00,0:00:00.00,Default,,0,0,0,,[Script Info]',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,Real subtitle',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'Real subtitle' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues drops malformed ASS spacer reset debris', () => {
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:00:01.00,0:00:02.00,Background,,0,0,0,,{\\pos(10,10)}\\h\\h\\h\\{\\fr0',
|
||||||
|
'Dialogue: 1,0:00:01.00,0:00:02.00,Default,,0,0,0,,Visible line',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
|
||||||
|
{ startTime: 1, endTime: 2, text: 'Visible line' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph gaps', () => {
|
||||||
|
const glyphs = [
|
||||||
|
['T', 100],
|
||||||
|
['h', 118],
|
||||||
|
['e', 136],
|
||||||
|
['s', 164],
|
||||||
|
['t', 178],
|
||||||
|
['a', 194],
|
||||||
|
['r', 210],
|
||||||
|
['s', 227],
|
||||||
|
['I', 255],
|
||||||
|
['s', 275],
|
||||||
|
['e', 293],
|
||||||
|
['e', 311],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
glyphs.map(
|
||||||
|
([glyph, x], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'The stars I see');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues adds a missing word space after positioned punctuation', () => {
|
||||||
|
const fragments = [
|
||||||
|
['H', 100],
|
||||||
|
['i,', 119],
|
||||||
|
['t', 153],
|
||||||
|
['h', 168],
|
||||||
|
['e', 186],
|
||||||
|
['r', 202],
|
||||||
|
['e', 216],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
fragments.map(
|
||||||
|
([fragment, x], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Hi, there');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues does not split a positioned thousands separator', () => {
|
||||||
|
const fragments = [
|
||||||
|
['1,', 100],
|
||||||
|
['000', 145],
|
||||||
|
['0', 185],
|
||||||
|
['0', 205],
|
||||||
|
['0', 225],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
fragments.map(
|
||||||
|
([fragment, x], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, '1,000000');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues does not split a wide glyph from its punctuated suffix', () => {
|
||||||
|
const fragments = [
|
||||||
|
['v', 904],
|
||||||
|
['o', 924],
|
||||||
|
['i', 939],
|
||||||
|
['c', 955],
|
||||||
|
['e', 976],
|
||||||
|
['r', 1004],
|
||||||
|
['e', 1021],
|
||||||
|
['a', 1042],
|
||||||
|
['c', 1063],
|
||||||
|
['h', 1083],
|
||||||
|
['e', 1104],
|
||||||
|
['d', 1125],
|
||||||
|
['m', 1161],
|
||||||
|
['e,', 1193],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
fragments.map(
|
||||||
|
([fragment, x], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'voice reached me,');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues spaces positioned lyric fragments across authored rows', () => {
|
||||||
|
const fragments = [
|
||||||
|
['My', 472, 39],
|
||||||
|
['song!', 543, 39],
|
||||||
|
['My', 507, 78],
|
||||||
|
['song!', 578, 78],
|
||||||
|
['ku', 643, 39],
|
||||||
|
['chi', 683, 39],
|
||||||
|
['zu', 722, 39],
|
||||||
|
['sa', 757, 39],
|
||||||
|
['n', 783, 39],
|
||||||
|
['de', 811, 39],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
fragments.map(
|
||||||
|
([fragment, x, y], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'My song! My song! kuchizusande');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues recovers positioned word gaps between romaji fragments', () => {
|
||||||
|
const fragments = [
|
||||||
|
['sa', 380],
|
||||||
|
['ga', 421],
|
||||||
|
['shi', 467],
|
||||||
|
['te', 510],
|
||||||
|
['ta', 545],
|
||||||
|
['ha', 593],
|
||||||
|
['ji', 624],
|
||||||
|
['ke', 655],
|
||||||
|
['ta', 693],
|
||||||
|
['i', 726],
|
||||||
|
['ro', 749],
|
||||||
|
['no', 798],
|
||||||
|
['yu', 849],
|
||||||
|
['me', 895],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
fragments.map(
|
||||||
|
([fragment, x], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'sagashiteta hajiketa iro no yume');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues recovers clear word gaps in a short romaji line', () => {
|
||||||
|
const fragments = [
|
||||||
|
['bo', 542],
|
||||||
|
['ku', 584],
|
||||||
|
['wo', 640],
|
||||||
|
['yo', 697],
|
||||||
|
['bu', 738],
|
||||||
|
] as const;
|
||||||
|
const content = [
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
...[0, 1].flatMap((layer) =>
|
||||||
|
fragments.map(
|
||||||
|
([fragment, x], index) =>
|
||||||
|
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP Romaji,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'boku wo yobu');
|
||||||
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
assToPlainText,
|
assToPlainText,
|
||||||
collectAssOverrideCommands,
|
collectAssOverrideCommands,
|
||||||
parseAssEffectField,
|
parseAssEffectField,
|
||||||
|
removeAssControlDebrisLines,
|
||||||
type AssEffectKind,
|
type AssEffectKind,
|
||||||
type AssOverrideCommand,
|
type AssOverrideCommand,
|
||||||
} from './ass-text';
|
} from './ass-text';
|
||||||
@@ -91,6 +92,10 @@ function sanitizeSubtitleCueText(text: string): string {
|
|||||||
return decodeSubtitleCueText(text).trim();
|
return decodeSubtitleCueText(text).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeAssCueText(text: string): string {
|
||||||
|
return removeAssControlDebrisLines(decodeSubtitleCueText(text)).trim();
|
||||||
|
}
|
||||||
|
|
||||||
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
function attachAssLayout<T extends SubtitleCue>(cue: T, assLayout: AssCueLayout | undefined): T {
|
||||||
if (assLayout) {
|
if (assLayout) {
|
||||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||||
@@ -387,6 +392,124 @@ interface AssFragmentPart {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AssFragmentPosition {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIN_LATIN_POSITION_GAP_SAMPLES = 4;
|
||||||
|
const LATIN_WORD_GAP_RATIO = 1.2;
|
||||||
|
|
||||||
|
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
|
||||||
|
for (const command of cue.overrides) {
|
||||||
|
if (command.animated) continue;
|
||||||
|
const name = command.name.toLowerCase();
|
||||||
|
const args = command.args.split(',').map((value) => Number(value.trim()));
|
||||||
|
if (
|
||||||
|
name === 'pos' &&
|
||||||
|
args.length >= 2 &&
|
||||||
|
Number.isFinite(args[0]) &&
|
||||||
|
Number.isFinite(args[1])
|
||||||
|
) {
|
||||||
|
return { x: args[0]!, y: args[1]! };
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
name === 'move' &&
|
||||||
|
args.length >= 4 &&
|
||||||
|
args.slice(0, 4).every((value) => Number.isFinite(value))
|
||||||
|
) {
|
||||||
|
return { x: (args[0]! + args[2]!) / 2, y: (args[1]! + args[3]!) / 2 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latinGlyphWidthWeight(glyph: string): number {
|
||||||
|
if (/[ilIjtfr]/u.test(glyph)) return 0.6;
|
||||||
|
if (/[mwMW]/u.test(glyph)) return 1.4;
|
||||||
|
if (/[A-Z]/u.test(glyph)) return 1.1;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function latinFragmentWidthWeight(text: string): number | null {
|
||||||
|
if (!/^[A-Za-z0-9'’.,!?;:-]+$/u.test(text)) return null;
|
||||||
|
const punctuationWeight = /^[A-Za-z0-9]['’.,!?;:-]$/u.test(text) ? 0.5 : 0.25;
|
||||||
|
return [...text].reduce(
|
||||||
|
(width, glyph) =>
|
||||||
|
width + (/['’.,!?;:-]/u.test(glyph) ? punctuationWeight : latinGlyphWidthWeight(glyph)),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedLatinFragmentGap(
|
||||||
|
previous: AssFragmentPart,
|
||||||
|
current: AssFragmentPart,
|
||||||
|
): number | null {
|
||||||
|
const previousWeight = latinFragmentWidthWeight(previous.text);
|
||||||
|
const currentWeight = latinFragmentWidthWeight(current.text);
|
||||||
|
const previousPosition = fragmentPosition(previous.cue);
|
||||||
|
const currentPosition = fragmentPosition(current.cue);
|
||||||
|
if (previousWeight === null || currentWeight === null || !previousPosition || !currentPosition) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const xDistance = currentPosition.x - previousPosition.x;
|
||||||
|
const yDistance = Math.abs(currentPosition.y - previousPosition.y);
|
||||||
|
if (yDistance <= 2 && xDistance <= 0) return null;
|
||||||
|
|
||||||
|
// A wrapped authored line can return to the left on its next visual row. Preserve
|
||||||
|
// that measured row transition as a separator without treating backwards movement
|
||||||
|
// on the same row as a word gap.
|
||||||
|
const positionDistance = yDistance <= 2 ? xDistance : Math.abs(xDistance) + yDistance;
|
||||||
|
return positionDistance / ((previousWeight + currentWeight) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commonLatinFragmentGap(values: readonly number[]): number {
|
||||||
|
const sorted = [...values].sort((left, right) => left - right);
|
||||||
|
// Romaji lines contain many short particles, so real word gaps can outnumber
|
||||||
|
// within-word transitions. A lower quantile still represents ordinary glyph advance
|
||||||
|
// while ignoring the narrowest character pair as an outlier.
|
||||||
|
return sorted[Math.floor((sorted.length - 1) * 0.35)]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Character-by-character typesetting often omits literal spaces because the authored
|
||||||
|
* word gap exists only in each glyph's `\pos`. Estimate the normal adjacent-glyph
|
||||||
|
* advance within that one line, then preserve only materially larger horizontal gaps.
|
||||||
|
* Normalizing each gap by the neighboring fragment widths supports both single glyphs
|
||||||
|
* and multi-character karaoke syllables without guessing from the text itself.
|
||||||
|
*/
|
||||||
|
function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||||
|
if (parts.some((part) => /\s/u.test(part.text))) {
|
||||||
|
return parts
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join('')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
const normalizedGaps: number[] = [];
|
||||||
|
for (let index = 1; index < parts.length; index += 1) {
|
||||||
|
const gap = normalizedLatinFragmentGap(parts[index - 1]!, parts[index]!);
|
||||||
|
if (gap !== null) normalizedGaps.push(gap);
|
||||||
|
}
|
||||||
|
const wordGapThreshold =
|
||||||
|
normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES
|
||||||
|
? commonLatinFragmentGap(normalizedGaps) * LATIN_WORD_GAP_RATIO
|
||||||
|
: Infinity;
|
||||||
|
|
||||||
|
let text = parts[0]?.text ?? '';
|
||||||
|
for (let index = 1; index < parts.length; index += 1) {
|
||||||
|
const previous = parts[index - 1]!;
|
||||||
|
const current = parts[index]!;
|
||||||
|
const hasAuthoredSpace = /\s$/u.test(previous.text) || /^\s/u.test(current.text);
|
||||||
|
const normalizedGap = normalizedLatinFragmentGap(previous, current);
|
||||||
|
const hasPositionedWordGap = normalizedGap !== null && normalizedGap > wordGapThreshold;
|
||||||
|
if (!hasAuthoredSpace && hasPositionedWordGap) {
|
||||||
|
text += ' ';
|
||||||
|
}
|
||||||
|
text += current.text;
|
||||||
|
}
|
||||||
|
return text.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function reconstructedAssFragmentLayout(
|
function reconstructedAssFragmentLayout(
|
||||||
parts: readonly AssFragmentPart[],
|
parts: readonly AssFragmentPart[],
|
||||||
owner: AnnotatedSubtitleCue,
|
owner: AnnotatedSubtitleCue,
|
||||||
@@ -522,10 +645,7 @@ function reconstructAssFragmentLine(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = parts
|
const text = joinAssFragmentParts(parts);
|
||||||
.map((part) => part.text)
|
|
||||||
.join('')
|
|
||||||
.trim();
|
|
||||||
if (!text) {
|
if (!text) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -911,12 +1031,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
|||||||
|
|
||||||
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
|
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
|
||||||
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
|
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
|
||||||
if (startTime === null || endTime === null) {
|
if (startTime === null || endTime === null || endTime <= startTime) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawText = fields.slice(fieldIndex.text).join(',');
|
const rawText = fields.slice(fieldIndex.text).join(',');
|
||||||
const text = sanitizeSubtitleCueText(rawText);
|
const text = sanitizeAssCueText(rawText);
|
||||||
if (!text) {
|
if (!text) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,6 +426,13 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
|
|||||||
text: '飛び越えてみたくて',
|
text: '飛び越えてみたくて',
|
||||||
source: 'canonical-ass',
|
source: 'canonical-ass',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
startTime: 10,
|
||||||
|
endTime: 12,
|
||||||
|
text: 'MaidCafeMaidCafe',
|
||||||
|
source: 'reconstructed-ass',
|
||||||
|
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
currentMediaPath: '/video.mkv',
|
currentMediaPath: '/video.mkv',
|
||||||
currentSubText: '',
|
currentSubText: '',
|
||||||
@@ -503,6 +510,11 @@ test('canonical ASS cues replace live glyph spam for display, history, and immer
|
|||||||
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
handlers.recordSubtitleTiming('今', 0.8, 1.5);
|
||||||
|
|
||||||
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
|
||||||
|
|
||||||
|
handlers.recordImmersionSubtitleLine('Maid\nCafe', 10, 12);
|
||||||
|
handlers.recordSubtitleTiming('Maid\nCafe', 10, 12);
|
||||||
|
assert.equal(immersion.length, 3);
|
||||||
|
assert.equal(timing.length, 5);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
|
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
|
||||||
|
|||||||
@@ -218,6 +218,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
text = stripFragmentsForRecording(text, start);
|
text = stripFragmentsForRecording(text, start);
|
||||||
|
if (!text.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -228,8 +231,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
|||||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||||
const canonical = resolveCanonicalSample(text, start);
|
const canonical = resolveCanonicalSample(text, start);
|
||||||
if (!canonical) {
|
if (!canonical) {
|
||||||
|
const recordableText = stripFragmentsForRecording(text, start);
|
||||||
|
if (!recordableText.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||||
stripFragmentsForRecording(text, start),
|
recordableText,
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
secondaryText,
|
secondaryText,
|
||||||
|
|||||||
@@ -64,6 +64,34 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText removes duplicate lines across multiline parsed cues', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePrimarySubtitleText({
|
||||||
|
liveText: 'First line\nSecond line\nFirst line',
|
||||||
|
currentTimeSec: 2,
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 3, text: 'First line\nSecond line' },
|
||||||
|
{ startTime: 1, endTime: 3, text: 'First line' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'First line\nSecond line',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText removes equivalent full-width duplicate lines', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePrimarySubtitleText({
|
||||||
|
liveText: '20分53秒\n20分53秒',
|
||||||
|
currentTimeSec: 2,
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 3, text: '20分53秒' },
|
||||||
|
{ startTime: 1, endTime: 3, text: '20分53秒' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
'20分53秒',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => {
|
test('resolvePrimarySubtitleText collapses whitespace variants of one ASS lyric', () => {
|
||||||
const ass = [
|
const ass = [
|
||||||
'[Events]',
|
'[Events]',
|
||||||
@@ -152,6 +180,54 @@ test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of t
|
|||||||
assert.equal(text, '普通のセリフ\n今\n手にある');
|
assert.equal(text, '普通のセリフ\n今\n手にある');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed lyric', () => {
|
||||||
|
const text = resolvePrimarySubtitleText({
|
||||||
|
liveText: '普通のセリフ\n今\n今\n手\n手\nにある\nにある',
|
||||||
|
currentTimeSec: 2,
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 3, text: '普通のセリフ' },
|
||||||
|
{
|
||||||
|
startTime: 1.2,
|
||||||
|
endTime: 3.8,
|
||||||
|
text: '今 手にある',
|
||||||
|
source: 'reconstructed-ass',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(text, '普通のセリフ\n今 手にある');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
|
||||||
|
const text = resolvePrimarySubtitleText({
|
||||||
|
liveText: 'Ordinary dialogue\nMaid\nCafe',
|
||||||
|
currentTimeSec: 2,
|
||||||
|
cues: [
|
||||||
|
{ startTime: 1, endTime: 3, text: 'Ordinary dialogue' },
|
||||||
|
{
|
||||||
|
startTime: 1,
|
||||||
|
endTime: 3,
|
||||||
|
text: 'MaidCafeMaidCafe',
|
||||||
|
source: 'reconstructed-ass',
|
||||||
|
assLayout: { kind: 'fragment-grid', sourceOrder: 2 },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(text, 'Ordinary dialogue');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText drops malformed ASS control debris from live text', () => {
|
||||||
|
assert.equal(
|
||||||
|
resolvePrimarySubtitleText({
|
||||||
|
liveText: 'Visible line\n\\\n{\\fr0',
|
||||||
|
currentTimeSec: 2,
|
||||||
|
cues: null,
|
||||||
|
}),
|
||||||
|
'Visible line',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
|
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
|
||||||
const text = resolvePrimarySubtitleText({
|
const text = resolvePrimarySubtitleText({
|
||||||
liveText: '次のセリフ',
|
liveText: '次のセリフ',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { SubtitleCue } from '../../types';
|
import type { SubtitleCue } from '../../types';
|
||||||
|
import { removeAssControlDebrisLines } from '../../core/services/ass-text';
|
||||||
|
|
||||||
// Slack on top of each cue's recorded animation envelope, for time-pos observation
|
// Slack on top of each cue's recorded animation envelope, for time-pos observation
|
||||||
// staleness and small user sub-delay offsets. The envelope itself covers how far
|
// staleness and small user sub-delay offsets. The envelope itself covers how far
|
||||||
@@ -23,9 +24,13 @@ function animationSpan(cue: SubtitleCue): { start: number; end: number } {
|
|||||||
function nearbyCanonicalCues(
|
function nearbyCanonicalCues(
|
||||||
cues: readonly SubtitleCue[] | null | undefined,
|
cues: readonly SubtitleCue[] | null | undefined,
|
||||||
currentTimeSec: number,
|
currentTimeSec: number,
|
||||||
|
includeFragmentGrids = false,
|
||||||
): SubtitleCue[] {
|
): SubtitleCue[] {
|
||||||
return (cues ?? []).filter((cue) => {
|
return (cues ?? []).filter((cue) => {
|
||||||
if (cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') {
|
if (
|
||||||
|
(cue.source !== 'canonical-ass' && cue.source !== 'reconstructed-ass') ||
|
||||||
|
(!includeFragmentGrids && cue.assLayout?.kind === 'fragment-grid')
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const span = animationSpan(cue);
|
const span = animationSpan(cue);
|
||||||
@@ -37,7 +42,7 @@ function nearbyCanonicalCues(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function compactWhitespace(text: string): string {
|
function compactWhitespace(text: string): string {
|
||||||
return text.replace(/\s+/gu, '');
|
return text.normalize('NFKC').replace(/\s+/gu, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ASS layers can encode the same visible spacing with ordinary, hard, or
|
// ASS layers can encode the same visible spacing with ordinary, hard, or
|
||||||
@@ -47,10 +52,12 @@ function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
|
|||||||
const texts: string[] = [];
|
const texts: string[] = [];
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const cue of cues) {
|
for (const cue of cues) {
|
||||||
const compactText = compactWhitespace(cue.text);
|
for (const line of cue.text.split('\n')) {
|
||||||
if (seen.has(compactText)) continue;
|
const compactText = compactWhitespace(line);
|
||||||
seen.add(compactText);
|
if (!compactText || seen.has(compactText)) continue;
|
||||||
texts.push(cue.text);
|
seen.add(compactText);
|
||||||
|
texts.push(line);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return texts;
|
return texts;
|
||||||
}
|
}
|
||||||
@@ -87,18 +94,37 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const cueSegments = compactLineSegments(cue.text);
|
const cueSegments = compactLineSegments(cue.text);
|
||||||
return cueSegments.length > 0 && cueSegments.every((segment) => liveSegmentSet.has(segment));
|
if (cueSegments.length === 0) return false;
|
||||||
|
if (cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass') {
|
||||||
|
return liveSegments.some((segment) =>
|
||||||
|
cueSegments.some((cueSegment) => cueSegment.includes(segment)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return cueSegments.every((segment) => liveSegmentSet.has(segment));
|
||||||
});
|
});
|
||||||
if (selected.length === 0) {
|
if (selected.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedSegmentSet = new Set(selected.flatMap((cue) => compactLineSegments(cue.text)));
|
const parsedSegments = selected.flatMap((cue) =>
|
||||||
if (!liveSegments.every((segment) => parsedSegmentSet.has(segment))) {
|
compactLineSegments(cue.text).map((segment) => ({
|
||||||
|
segment,
|
||||||
|
recovered: cue.source === 'canonical-ass' || cue.source === 'reconstructed-ass',
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
!liveSegments.every((liveSegment) =>
|
||||||
|
parsedSegments.some(({ segment, recovered }) =>
|
||||||
|
recovered ? segment.includes(liveSegment) : segment === liveSegment,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const texts = uniqueCueTexts(selected);
|
// Dense sign grids still explain their raw mpv fragments, but are visual
|
||||||
|
// typesetting rather than a publishable subtitle line.
|
||||||
|
const texts = uniqueCueTexts(selected.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'));
|
||||||
return {
|
return {
|
||||||
text: texts.join('\n'),
|
text: texts.join('\n'),
|
||||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||||
@@ -179,8 +205,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
|||||||
/**
|
/**
|
||||||
* Live text with generated-animation fragment lines removed. Recording paths use this
|
* Live text with generated-animation fragment lines removed. Recording paths use this
|
||||||
* when full canonical substitution declined -- concurrent dialogue during an insert
|
* when full canonical substitution declined -- concurrent dialogue during an insert
|
||||||
* song: the dialogue is worth recording, the glyph fragments beside it are not. Returns
|
* song: the dialogue is worth recording, the glyph fragments beside it are not. An
|
||||||
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
|
* all-fragment visual grid becomes empty; other all-matched input remains unchanged as a
|
||||||
|
* defensive fallback.
|
||||||
*/
|
*/
|
||||||
export function stripCanonicalFragmentLines(options: {
|
export function stripCanonicalFragmentLines(options: {
|
||||||
liveText: string;
|
liveText: string;
|
||||||
@@ -190,7 +217,7 @@ export function stripCanonicalFragmentLines(options: {
|
|||||||
if (!Number.isFinite(options.currentTimeSec)) {
|
if (!Number.isFinite(options.currentTimeSec)) {
|
||||||
return options.liveText;
|
return options.liveText;
|
||||||
}
|
}
|
||||||
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
|
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec, true);
|
||||||
if (nearby.length === 0) {
|
if (nearby.length === 0) {
|
||||||
return options.liveText;
|
return options.liveText;
|
||||||
}
|
}
|
||||||
@@ -199,7 +226,8 @@ export function stripCanonicalFragmentLines(options: {
|
|||||||
const compact = compactWhitespace(line);
|
const compact = compactWhitespace(line);
|
||||||
return compact && !compactCues.some((cueText) => cueText.includes(compact));
|
return compact && !compactCues.some((cueText) => cueText.includes(compact));
|
||||||
});
|
});
|
||||||
return kept.length > 0 ? kept.join('\n') : options.liveText;
|
if (kept.length > 0) return kept.join('\n');
|
||||||
|
return nearby.some((cue) => cue.assLayout?.kind === 'fragment-grid') ? '' : options.liveText;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolvePrimarySubtitleText(options: {
|
export function resolvePrimarySubtitleText(options: {
|
||||||
@@ -207,16 +235,17 @@ export function resolvePrimarySubtitleText(options: {
|
|||||||
currentTimeSec: number;
|
currentTimeSec: number;
|
||||||
cues: readonly SubtitleCue[] | null | undefined;
|
cues: readonly SubtitleCue[] | null | undefined;
|
||||||
}): string {
|
}): string {
|
||||||
if (!options.liveText.trim()) {
|
const liveText = removeAssControlDebrisLines(options.liveText);
|
||||||
return options.liveText;
|
if (!liveText.trim()) {
|
||||||
|
return liveText;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
resolveCanonicalPrimarySubtitle({
|
resolveCanonicalPrimarySubtitle({
|
||||||
liveText: options.liveText,
|
liveText,
|
||||||
currentTimeSec: options.currentTimeSec,
|
currentTimeSec: options.currentTimeSec,
|
||||||
cues: options.cues,
|
cues: options.cues,
|
||||||
})?.text ??
|
})?.text ??
|
||||||
resolveActiveParsedPrimarySubtitle(options)?.text ??
|
resolveActiveParsedPrimarySubtitle({ ...options, liveText })?.text ??
|
||||||
options.liveText
|
liveText
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,32 @@ test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('findActiveSubtitleText removes duplicate lines across multiline cues', () => {
|
||||||
|
assert.equal(
|
||||||
|
findActiveSubtitleText(
|
||||||
|
[
|
||||||
|
{ startTime: 1, endTime: 3, text: 'First line\nSecond line' },
|
||||||
|
{ startTime: 1, endTime: 3, text: 'First line' },
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'First line\nSecond line',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('findActiveSubtitleText removes equivalent full-width duplicate lines', () => {
|
||||||
|
assert.equal(
|
||||||
|
findActiveSubtitleText(
|
||||||
|
[
|
||||||
|
{ startTime: 1, endTime: 3, text: '真白~' },
|
||||||
|
{ startTime: 1, endTime: 3, text: '真白~' },
|
||||||
|
],
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
'真白~',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => {
|
test('findActiveSubtitleText collapses whitespace variants of one ASS lyric', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
findActiveSubtitleText(
|
findActiveSubtitleText(
|
||||||
@@ -380,6 +406,23 @@ test('secondary track controller falls back to live mpv text without a readable
|
|||||||
assert.deepEqual(broadcasts, ['live fallback']);
|
assert.deepEqual(broadcasts, ['live fallback']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('secondary live fallback drops malformed ASS control debris', () => {
|
||||||
|
const broadcasts: string[] = [];
|
||||||
|
const controller = createSecondarySubtitleTrackController({
|
||||||
|
getMpvClient: () => null,
|
||||||
|
getCurrentTimePos: () => 2,
|
||||||
|
resolveSubtitleSource: async () => null,
|
||||||
|
loadSubtitleSourceText: async () => '',
|
||||||
|
parseSubtitleCues: () => [],
|
||||||
|
setCurrentSecondaryText: () => {},
|
||||||
|
broadcastSecondaryText: (text) => broadcasts.push(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
controller.handleLiveText('Visible line\n\\\n{\\fr0');
|
||||||
|
|
||||||
|
assert.deepEqual(broadcasts, ['Visible line']);
|
||||||
|
});
|
||||||
|
|
||||||
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
|
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
|
||||||
let resolveCalls = 0;
|
let resolveCalls = 0;
|
||||||
let parseCalls = 0;
|
let parseCalls = 0;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { SubtitleCue } from '../../types/subtitle';
|
import type { SubtitleCue } from '../../types/subtitle';
|
||||||
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
|
import { flattenedSecondarySubtitleLineIdentity } from '../../core/services/secondary-subtitle-line-identity';
|
||||||
|
import { removeAssControlDebrisLines } from '../../core/services/ass-text';
|
||||||
|
|
||||||
type SecondarySubtitleMpvClient = {
|
type SecondarySubtitleMpvClient = {
|
||||||
connected?: boolean;
|
connected?: boolean;
|
||||||
@@ -153,15 +154,17 @@ export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds
|
|||||||
activeCues.sort(compareAuthoredSubtitleOrder);
|
activeCues.sort(compareAuthoredSubtitleOrder);
|
||||||
|
|
||||||
for (const { cue } of activeCues) {
|
for (const { cue } of activeCues) {
|
||||||
const text = cue.text.trim();
|
for (const line of cue.text.split('\n')) {
|
||||||
const compactText = text.replace(/\s+/gu, '');
|
const text = line.trim();
|
||||||
if (!compactText || seenExact.has(compactText)) continue;
|
const compactText = text.normalize('NFKC').replace(/\s+/gu, '');
|
||||||
seenExact.add(compactText);
|
if (!compactText || seenExact.has(compactText)) continue;
|
||||||
|
seenExact.add(compactText);
|
||||||
|
|
||||||
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
|
const flattenedIdentity = flattenedSecondarySubtitleLineIdentity(text);
|
||||||
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
|
if (flattenedIdentity && seenFlattened.has(flattenedIdentity)) continue;
|
||||||
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
|
if (flattenedIdentity) seenFlattened.add(flattenedIdentity);
|
||||||
activeText.push(text);
|
activeText.push(text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return activeText.join('\n');
|
return activeText.join('\n');
|
||||||
}
|
}
|
||||||
@@ -305,7 +308,7 @@ export function createSecondarySubtitleTrackController(deps: {
|
|||||||
refresh,
|
refresh,
|
||||||
scheduleRefresh,
|
scheduleRefresh,
|
||||||
handleLiveText(text: string): void {
|
handleLiveText(text: string): void {
|
||||||
lastLiveText = text;
|
lastLiveText = removeAssControlDebrisLines(text);
|
||||||
publish(resolveAtTime(deps.getCurrentTimePos()));
|
publish(resolveAtTime(deps.getCurrentTimePos()));
|
||||||
},
|
},
|
||||||
handleTimePos(timeSeconds: number): void {
|
handleTimePos(timeSeconds: number): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user