mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-24 12:15:27 -07:00
fix(subtitles): keep dialogue and wrapped lyrics out of ASS fragment gri
- Only classify tall positioned ASS blocks as fragment grids when they read like tiling (sign walls, re-shown countdown frames, scattered single glyphs, or table columns), keeping CC-style dialogue blocks and wrapped lyric rows publishable - Widen Latin word-gap heuristics for per-glyph typesetting runs and short capitalized words so proportional-font variation and two-letter words no longer get split - Add changelog fragment documenting the stats database busy-timeout fix
This commit is contained in:
@@ -1083,6 +1083,190 @@ test('parseSubtitleCues recovers spaces encoded only by positioned Latin glyph g
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'The stars I see');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues does not split narrow letters inside positioned English words', () => {
|
||||
const text = 'carryinghappiness';
|
||||
const positions = [
|
||||
323, 341, 356, 369, 383, 396, 410, 428, 456, 474, 493, 512, 526, 540, 558, 575, 590,
|
||||
];
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
[...text].map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,OP English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'carrying happiness');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps proportional-font variation inside positioned English words', () => {
|
||||
const text = 'sendsripplesacrossthestillnessofyourheart';
|
||||
const positions = [
|
||||
32, 46, 60, 78, 95, 121, 131, 144, 163, 177, 189, 203, 232, 248, 261, 274, 288, 302, 329, 346,
|
||||
363, 390, 405, 416, 423, 432, 443, 457, 471, 485, 512, 525, 551, 564, 579, 593, 622, 639, 655,
|
||||
670, 684,
|
||||
];
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
[...text].map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,Insert English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(
|
||||
parseSubtitleCues(content, 'test.ass')[0]?.text,
|
||||
'sends ripples across the stillness of your heart',
|
||||
);
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a short capitalized word when the following gap is larger', () => {
|
||||
const text = 'IfIgrow';
|
||||
const positions = [347, 365, 397, 430, 446, 463, 485];
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
[...text].map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,Insert English,,0,0,0,,{\\pos(${positions[index]},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'If I grow');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues separates overlapping positioned English lyric sequences', () => {
|
||||
const fragments = [
|
||||
['my', 642, '0:00:01.00', '0:00:04.05'],
|
||||
['song!', 713, '0:00:01.00', '0:00:04.05'],
|
||||
['I', 533, '0:00:01.67', '0:00:04.09'],
|
||||
['h', 557, '0:00:01.67', '0:00:04.09'],
|
||||
['u', 575, '0:00:01.67', '0:00:04.09'],
|
||||
['m', 597, '0:00:01.67', '0:00:04.09'],
|
||||
] as const;
|
||||
const content = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
...[0, 1].flatMap((layer) =>
|
||||
fragments.map(
|
||||
([text, x, start, end], index) =>
|
||||
`Dialogue: ${layer},${start},${end},OP English,,0,0,0,,{\\pos(${x},110)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'my song! I hum');
|
||||
});
|
||||
|
||||
const eventsHeader = [
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
];
|
||||
|
||||
test('parseSubtitleCues keeps a tall CC-style dialogue block publishable, not a fragment grid', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(212,383)\\fscx50\\fscy50}たき',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(172,437)\\fscx50}({\\fscx100}立希{\\fscx50})',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(332,443)\\fscx50\\fscy50}ともり',
|
||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||
].join('\n');
|
||||
|
||||
const cue = parseSubtitleCues(content, 'test.ass')[0];
|
||||
assert.equal(cue?.text, 'たき(立希)ともりお前… 燈をバンドに誘ったの?');
|
||||
assert.equal(cue?.assLayout?.kind, 'positioned');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues marks re-shown countdown frames as a fragment grid', () => {
|
||||
const rows = [
|
||||
['juu', '10'],
|
||||
['juu', '10'],
|
||||
['kyuu', '9'],
|
||||
['kyuu', '9'],
|
||||
['hachi', '8'],
|
||||
['hachi', '8'],
|
||||
] as const;
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...rows.flatMap(([word, num], index) => {
|
||||
const timestamp = (seconds: number) => `0:00:${seconds.toFixed(2).padStart(5, '0')}`;
|
||||
const start = timestamp(6 + index * 0.4);
|
||||
const end = timestamp(6 + index * 0.4 + 0.4);
|
||||
return [0, 1].flatMap((layer) => [
|
||||
`Dialogue: ${layer},${start},${end},ED Romaji,,0,0,0,,{\\pos(${300 + index * 8},40)\\t(0,100,\\fscx120)}${word}`,
|
||||
`Dialogue: ${layer},${start},${end},ED Romaji,,0,0,0,,{\\pos(${300 + index * 8},93)\\t(0,100,\\fscx120)}${num}`,
|
||||
]);
|
||||
}),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues marks scattered single-glyph typesetting as a fragment grid', () => {
|
||||
const glyphs = ['の', 'こ', '部', 'そ', '屋'];
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...[0, 1].flatMap((layer) =>
|
||||
glyphs.map(
|
||||
(glyph, index) =>
|
||||
`Dialogue: ${layer},0:00:06.00,0:00:09.00,OP-JP,,0,0,0,,{\\pos(${500 + index * 30},${-30 + index * 35})\\t(0,100,\\fscx120)}${glyph}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues marks a repeated-token sign wall as a fragment grid', () => {
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...[0, 1].flatMap((layer) =>
|
||||
Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) =>
|
||||
`Dialogue: ${layer},0:00:06.00,0:00:09.00,Sign,,0,0,0,,{\\pos(${200 + index * 60},${100 + index * 30})\\t(0,100,\\fscx120)}${index % 2 === 0 ? 'Maid' : 'Cafe'}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.assLayout?.kind, 'fragment-grid');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues keeps a wrapped lyric with repeated syllables publishable', () => {
|
||||
const fragments = [
|
||||
['dreams', 300, 115],
|
||||
['ju', 250, 39],
|
||||
['n', 280, 39],
|
||||
['jo', 300, 39],
|
||||
['u', 330, 39],
|
||||
['to', 360, 39],
|
||||
['jo', 395, 39],
|
||||
['u', 425, 39],
|
||||
['ne', 455, 39],
|
||||
['tsu!', 485, 39],
|
||||
] as const;
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
...[0, 1].flatMap((layer) =>
|
||||
fragments.map(
|
||||
([text, x, y], index) =>
|
||||
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,,{\\pos(${x},${y})\\t(${index * 2},${index * 2 + 100},\\fscx120)}${text}`,
|
||||
),
|
||||
),
|
||||
].join('\n');
|
||||
|
||||
const cue = parseSubtitleCues(content, 'test.ass')[0];
|
||||
assert.notEqual(cue?.assLayout?.kind, 'fragment-grid');
|
||||
});
|
||||
|
||||
test('parseSubtitleCues adds a missing word space after positioned punctuation', () => {
|
||||
const fragments = [
|
||||
['H', 100],
|
||||
|
||||
@@ -398,7 +398,9 @@ interface AssFragmentPosition {
|
||||
}
|
||||
|
||||
const MIN_LATIN_POSITION_GAP_SAMPLES = 4;
|
||||
const LATIN_WORD_GAP_RATIO = 1.2;
|
||||
const LATIN_FRAGMENT_WORD_GAP_RATIO = 1.16;
|
||||
const LATIN_GLYPH_WORD_GAP_RATIO = 1.4;
|
||||
const LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO = 1.2;
|
||||
|
||||
function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null {
|
||||
for (const command of cue.overrides) {
|
||||
@@ -425,7 +427,8 @@ function fragmentPosition(cue: AnnotatedSubtitleCue): AssFragmentPosition | null
|
||||
}
|
||||
|
||||
function latinGlyphWidthWeight(glyph: string): number {
|
||||
if (/[ilIjtfr]/u.test(glyph)) return 0.6;
|
||||
if (/[ilIj]/u.test(glyph)) return 0.6;
|
||||
if (/[tfr]/u.test(glyph)) return 0.8;
|
||||
if (/[mwMW]/u.test(glyph)) return 1.4;
|
||||
if (/[A-Z]/u.test(glyph)) return 1.1;
|
||||
return 1;
|
||||
@@ -441,6 +444,10 @@ function latinFragmentWidthWeight(text: string): number | null {
|
||||
);
|
||||
}
|
||||
|
||||
function isSingleLatinGlyphFragment(text: string): boolean {
|
||||
return [...text].filter((glyph) => /[A-Za-z0-9]/u.test(glyph)).length <= 1;
|
||||
}
|
||||
|
||||
function normalizedLatinFragmentGap(
|
||||
previous: AssFragmentPart,
|
||||
current: AssFragmentPart,
|
||||
@@ -463,6 +470,21 @@ function normalizedLatinFragmentGap(
|
||||
return positionDistance / ((previousWeight + currentWeight) / 2);
|
||||
}
|
||||
|
||||
function startsNewPositionedFragmentSequence(
|
||||
previous: AssFragmentPart,
|
||||
current: AssFragmentPart,
|
||||
): boolean {
|
||||
const previousPosition = fragmentPosition(previous.cue);
|
||||
const currentPosition = fragmentPosition(current.cue);
|
||||
return Boolean(
|
||||
previousPosition &&
|
||||
currentPosition &&
|
||||
Math.abs(currentPosition.y - previousPosition.y) <= 2 &&
|
||||
currentPosition.x <= previousPosition.x &&
|
||||
current.cue.startTime > previous.cue.startTime,
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -471,12 +493,40 @@ function commonLatinFragmentGap(values: readonly number[]): number {
|
||||
return sorted[Math.floor((sorted.length - 1) * 0.35)]!;
|
||||
}
|
||||
|
||||
function isLikelyTwoGlyphCapitalizedWord(options: {
|
||||
parts: readonly AssFragmentPart[];
|
||||
index: number;
|
||||
gap: number;
|
||||
wordGapThreshold: number;
|
||||
}): boolean {
|
||||
const first = options.parts[options.index - 1]!;
|
||||
const second = options.parts[options.index]!;
|
||||
if (!/^[A-Z]$/u.test(first.text) || !/^[a-z]$/u.test(second.text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const precedingGap =
|
||||
options.index > 1 ? normalizedLatinFragmentGap(options.parts[options.index - 2]!, first) : null;
|
||||
const following = options.parts[options.index + 1];
|
||||
const followingGap = following ? normalizedLatinFragmentGap(second, following) : null;
|
||||
const startsAtWordBoundary =
|
||||
options.index === 1 || (precedingGap !== null && precedingGap > options.wordGapThreshold);
|
||||
|
||||
return (
|
||||
startsAtWordBoundary &&
|
||||
followingGap !== null &&
|
||||
followingGap > options.wordGapThreshold &&
|
||||
followingGap > options.gap * LATIN_TWO_GLYPH_WORD_NEXT_GAP_RATIO
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* and multi-character karaoke syllables without guessing from the text itself. Per-glyph
|
||||
* runs use a wider safety margin because proportional fonts vary more than syllable chunks.
|
||||
*/
|
||||
function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||
if (parts.some((part) => /\s/u.test(part.text))) {
|
||||
@@ -492,7 +542,10 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||
}
|
||||
const wordGapThreshold =
|
||||
normalizedGaps.length >= MIN_LATIN_POSITION_GAP_SAMPLES
|
||||
? commonLatinFragmentGap(normalizedGaps) * LATIN_WORD_GAP_RATIO
|
||||
? commonLatinFragmentGap(normalizedGaps) *
|
||||
(parts.every((part) => isSingleLatinGlyphFragment(part.text))
|
||||
? LATIN_GLYPH_WORD_GAP_RATIO
|
||||
: LATIN_FRAGMENT_WORD_GAP_RATIO)
|
||||
: Infinity;
|
||||
|
||||
let text = parts[0]?.text ?? '';
|
||||
@@ -501,7 +554,16 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||
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;
|
||||
const hasPositionedWordGap =
|
||||
startsNewPositionedFragmentSequence(previous, current) ||
|
||||
(normalizedGap !== null &&
|
||||
normalizedGap > wordGapThreshold &&
|
||||
!isLikelyTwoGlyphCapitalizedWord({
|
||||
parts,
|
||||
index,
|
||||
gap: normalizedGap,
|
||||
wordGapThreshold,
|
||||
}));
|
||||
if (!hasAuthoredSpace && hasPositionedWordGap) {
|
||||
text += ' ';
|
||||
}
|
||||
@@ -510,6 +572,62 @@ function joinAssFragmentParts(parts: readonly AssFragmentPart[]): string {
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
// A tall multi-part layout is only a visual grid when its parts read like tiling
|
||||
// rather than prose: a couple of texts repeated across many fragments (sign walls),
|
||||
// the same text re-shown at the same spot over time (countdown/animation frames),
|
||||
// nothing but scattered single glyphs, or cells aligned into table columns. Wrapped
|
||||
// lyric rows with repeated karaoke syllables and CC-style dialogue blocks (speaker
|
||||
// labels plus a sentence) share the same tall geometry but stay publishable.
|
||||
function looksLikeFragmentGridParts(parts: readonly AssFragmentPart[]): boolean {
|
||||
const positioned = parts
|
||||
.map((part) => ({
|
||||
text: part.text.trim(),
|
||||
layout: part.cue.assLayout,
|
||||
position: fragmentPosition(part.cue),
|
||||
startTime: part.cue.startTime,
|
||||
}))
|
||||
.filter((part) => part.text && part.layout?.kind === 'positioned');
|
||||
if (positioned.length === 0) return true;
|
||||
|
||||
const uniqueTexts = new Set(positioned.map((part) => part.text));
|
||||
if (uniqueTexts.size * 3 <= positioned.length) return true;
|
||||
|
||||
if (positioned.every((part) => [...part.text].length <= 1)) return true;
|
||||
|
||||
const seenPlacements = new Map<string, number>();
|
||||
for (const part of positioned) {
|
||||
if (part.layout?.kind !== 'positioned') continue;
|
||||
const placement = `${part.text}@${Math.round(part.layout.y)}`;
|
||||
const earlierStart = seenPlacements.get(placement);
|
||||
if (earlierStart !== undefined && Math.abs(part.startTime - earlierStart) > 0.01) {
|
||||
return true;
|
||||
}
|
||||
seenPlacements.set(placement, part.startTime);
|
||||
}
|
||||
|
||||
// Table cells align into columns: several x values each reused on multiple rows.
|
||||
// Requiring two such columns holding at least half the parts keeps a wrapped lyric
|
||||
// whose rows accidentally share one x coordinate out of the grid bucket.
|
||||
const columnRows = new Map<number, Set<number>>();
|
||||
for (const part of positioned) {
|
||||
if (!part.position) continue;
|
||||
const x = Math.round(part.position.x);
|
||||
const rows = columnRows.get(x) ?? new Set<number>();
|
||||
rows.add(Math.round(part.position.y));
|
||||
columnRows.set(x, rows);
|
||||
}
|
||||
let alignedColumns = 0;
|
||||
let alignedParts = 0;
|
||||
for (const part of positioned) {
|
||||
if (!part.position) continue;
|
||||
if ((columnRows.get(Math.round(part.position.x))?.size ?? 0) >= 2) alignedParts += 1;
|
||||
}
|
||||
for (const rows of columnRows.values()) {
|
||||
if (rows.size >= 2) alignedColumns += 1;
|
||||
}
|
||||
return alignedColumns >= 2 && alignedParts * 2 >= positioned.length;
|
||||
}
|
||||
|
||||
function reconstructedAssFragmentLayout(
|
||||
parts: readonly AssFragmentPart[],
|
||||
owner: AnnotatedSubtitleCue,
|
||||
@@ -527,7 +645,8 @@ function reconstructedAssFragmentLayout(
|
||||
|
||||
if (
|
||||
positionedPartCount >= MIN_FRAGMENT_LINE_PARTS &&
|
||||
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN
|
||||
maximumY - minimumY > MAX_FRAGMENT_LINE_VERTICAL_SPAN &&
|
||||
looksLikeFragmentGridParts(parts)
|
||||
) {
|
||||
return { kind: 'fragment-grid', sourceOrder: owner.order };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user