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:
2026-08-23 19:16:55 -07:00
parent c4284d1dd4
commit 6f52008e5d
5 changed files with 320 additions and 10 deletions
@@ -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],
+125 -6
View File
@@ -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 };
}