mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-25 00:15:27 -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:
@@ -10,6 +10,7 @@ import {
|
||||
isAssTemporalCommand,
|
||||
normalizePlainSubtitleText,
|
||||
parseAssEffectField,
|
||||
removeAssControlDebrisLines,
|
||||
} from './ass-text';
|
||||
|
||||
test('assToPlainText drops vector drawing runs', () => {
|
||||
@@ -74,6 +75,14 @@ test('assToPlainText normalizes CRLF before converting', () => {
|
||||
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', () => {
|
||||
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
/** Fold every line break into a single space. */
|
||||
collapseLineBreaks?: boolean;
|
||||
|
||||
@@ -35,6 +35,12 @@ test('parseSrtCues handles multi-line subtitle text', () => {
|
||||
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', () => {
|
||||
const content = [
|
||||
'1',
|
||||
@@ -1021,3 +1027,215 @@ test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
|
||||
assert.equal(cues.length, 1);
|
||||
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,
|
||||
collectAssOverrideCommands,
|
||||
parseAssEffectField,
|
||||
removeAssControlDebrisLines,
|
||||
type AssEffectKind,
|
||||
type AssOverrideCommand,
|
||||
} from './ass-text';
|
||||
@@ -91,6 +92,10 @@ function sanitizeSubtitleCueText(text: string): string {
|
||||
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 {
|
||||
if (assLayout) {
|
||||
Object.defineProperty(cue, 'assLayout', { value: assLayout, enumerable: false });
|
||||
@@ -387,6 +392,124 @@ interface AssFragmentPart {
|
||||
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(
|
||||
parts: readonly AssFragmentPart[],
|
||||
owner: AnnotatedSubtitleCue,
|
||||
@@ -522,10 +645,7 @@ function reconstructAssFragmentLine(
|
||||
return null;
|
||||
}
|
||||
|
||||
const text = parts
|
||||
.map((part) => part.text)
|
||||
.join('')
|
||||
.trim();
|
||||
const text = joinAssFragmentParts(parts);
|
||||
if (!text) {
|
||||
return null;
|
||||
}
|
||||
@@ -911,12 +1031,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
|
||||
const startTime = parseAssTimestamp(fields[fieldIndex.start]!);
|
||||
const endTime = parseAssTimestamp(fields[fieldIndex.end]!);
|
||||
if (startTime === null || endTime === null) {
|
||||
if (startTime === null || endTime === null || endTime <= startTime) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawText = fields.slice(fieldIndex.text).join(',');
|
||||
const text = sanitizeSubtitleCueText(rawText);
|
||||
const text = sanitizeAssCueText(rawText);
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user