Files
SubMiner/src/core/services/subtitle-cue-parser.test.ts
T
sudacode b7358507b1 fix(subtitles): suppress ASS texture layers and advance canonical lyrics
- Remove clipped and transparent ASS texture fragments
- Show canonical lyrics when their animations begin
2026-08-24 02:59:39 -07:00

1779 lines
69 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser';
import type { SubtitleCue } from '../../types';
test('parseSrtCues parses basic SRT content', () => {
const content = [
'1',
'00:00:01,000 --> 00:00:04,000',
'こんにちは',
'',
'2',
'00:00:05,000 --> 00:00:08,500',
'元気ですか',
'',
].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues.length, 2);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 4.0);
assert.equal(cues[0]!.text, 'こんにちは');
assert.equal(cues[1]!.startTime, 5.0);
assert.equal(cues[1]!.endTime, 8.5);
assert.equal(cues[1]!.text, '元気ですか');
});
test('parseSrtCues handles multi-line subtitle text', () => {
const content = ['1', '00:01:00,000 --> 00:01:05,000', 'これは', 'テストです', ''].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues.length, 1);
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',
'00:01:00,000 --> 00:01:05,000',
'<font color="japanese">これは</font>',
'<font color="japanese">テストです</font>',
'',
].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'これは\nテストです');
});
test('parseSrtCues handles hours in timestamps', () => {
const content = ['1', '01:30:00,000 --> 01:30:05,000', 'テスト', ''].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues[0]!.startTime, 5400.0);
assert.equal(cues[0]!.endTime, 5405.0);
});
test('parseSrtCues handles VTT-style dot separator', () => {
const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTスタイル', ''].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
});
test('parseSrtCues returns empty array for empty content', () => {
assert.deepEqual(parseSrtCues(''), []);
assert.deepEqual(parseSrtCues(' \n\n '), []);
});
test('parseSrtCues skips malformed timing lines gracefully', () => {
const content = [
'1',
'NOT A TIMING LINE',
'テスト',
'',
'2',
'00:00:01,000 --> 00:00:02,000',
'有効',
'',
].join('\n');
const cues = parseSrtCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, '有効');
});
test('parseSubtitleCues strips complete brace blocks from SRT and VTT text', () => {
const content = ['1', '00:00:01,000 --> 00:00:02,000', '彼は{謎}と言った', ''].join('\n');
for (const filename of ['test.srt', 'test.vtt']) {
const cues = parseSubtitleCues(content, filename);
assert.equal(cues.length, 1, filename);
assert.equal(cues[0]!.text, '彼はと言った', filename);
}
});
test('parseAssCues parses basic ASS dialogue lines', () => {
const content = [
'[Script Info]',
'Title: Test',
'',
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,こんにちは',
'Dialogue: 0,0:00:05.00,0:00:08.50,Default,,0,0,0,,元気ですか',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 2);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 4.0);
assert.equal(cues[0]!.text, 'こんにちは');
assert.equal(cues[1]!.startTime, 5.0);
assert.equal(cues[1]!.endTime, 8.5);
assert.equal(cues[1]!.text, '元気ですか');
});
test('parseAssCues strips override tags from text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\b1}太字{\\b0}テスト',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '太字テスト');
});
test('parseAssCues handles text containing commas', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,はい、そうです、ね',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, 'はい、そうです、ね');
});
test('parseAssCues decodes \\N line breaks into real newlines', () => {
// ASS is decoded once, here at ingestion, so cue text matches what mpv hands over for
// the same line played live.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,一行目\\N二行目',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '一行目\n二行目');
});
test('parseAssCues strips HTML-like markup while preserving ASS line breaks', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,<font color="japanese">一行目</font>\\N<font color="japanese">二行目</font>',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '一行目\n二行目');
});
test('parseAssCues drops vector drawing runs enabled by \\p', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'これは字幕');
});
test('parseAssCues keeps text that follows a \\p0 reset on the same line', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, '本文続き');
});
test('parseAssCues leaves \\pos untouched when no drawing mode is active', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\pos(960,1068)\\bord3}位置指定',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.text, '位置指定');
});
test('parseAssCues returns empty for content without Events section', () => {
const content = ['[Script Info]', 'Title: Test'].join('\n');
assert.deepEqual(parseAssCues(content), []);
});
test('parseAssCues skips Comment lines', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,これはコメント',
'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'これは字幕');
});
test('parseAssCues handles hour timestamps', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,1:30:00.00,1:30:05.00,Default,,0,0,0,,テスト',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues[0]!.startTime, 5400.0);
assert.equal(cues[0]!.endTime, 5405.0);
});
test('parseAssCues respects dynamic field ordering from the Format row', () => {
const content = [
'[Events]',
'Format: Layer, Style, Start, End, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,Default,0:00:01.00,0:00:04.00,,0,0,0,,順番が違う',
].join('\n');
const cues = parseAssCues(content);
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 4.0);
assert.equal(cues[0]!.text, '順番が違う');
});
test('parseSubtitleCues auto-detects SRT format', () => {
const content = ['1', '00:00:01,000 --> 00:00:04,000', 'SRTテスト', ''].join('\n');
const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'SRTテスト');
});
test('parseSubtitleCues auto-detects ASS format', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,ASSテスト',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'ASSテスト');
});
test('parseSubtitleCues auto-detects VTT format', () => {
const content = ['1', '00:00:01.000 --> 00:00:04.000', 'VTTテスト', ''].join('\n');
const cues = parseSubtitleCues(content, 'test.vtt');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.text, 'VTTテスト');
});
test('parseSubtitleCues returns empty for unknown format', () => {
assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []);
});
test('parseSubtitleCues returns cues sorted by start time', () => {
const content = [
'1',
'00:00:10,000 --> 00:00:14,000',
'二番目',
'',
'2',
'00:00:01,000 --> 00:00:04,000',
'一番目',
'',
].join('\n');
const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues[0]!.text, '一番目');
assert.equal(cues[1]!.text, '二番目');
});
test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}過ぎ去ってしまう瞬間を',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}過ぎ去ってしまう瞬間を',
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}過ぎ去ってしまう瞬間を',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 3.55);
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
});
test('parseSubtitleCues collapses long full-line color phases', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:03:49.75,0:03:51.21,OPJP,,0,0,0,,{\\blur0.6\\c&H312D38&\\4c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
'Dialogue: 1,0:03:51.21,0:03:52.25,OPJP,,0,0,0,,{\\blur0.6\\4c&H312D38&\\c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 229.75,
endTime: 232.25,
text: 'ちゃんと目を合わせてよ',
},
]);
});
test('parseSubtitleCues keeps ordinary repeated dialogue separate', () => {
// A single restyle tag on a repeated line is how ordinary dialogue gets decorated;
// it is not phase evidence, whatever the line length.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:02.00,OPJP,,0,0,0,,{\\c&H111111&}歌詞',
'Dialogue: 1,0:00:02.00,0:00:03.00,OPJP,,0,0,0,,{\\c&H222222&}歌詞',
'Dialogue: 1,0:00:04.00,0:00:05.00,OPJP,,0,0,0,,{\\c&H333333&}別の歌詞',
'Dialogue: 1,0:00:05.00,0:00:06.00,OPJP,,0,0,0,,{\\c&H444444&}別の歌詞',
'Dialogue: 8,0:00:07.00,0:00:08.00,Text - JP,,0,0,0,,えっ?',
'Dialogue: 8,0:00:08.00,0:00:09.00,Text - JP,,0,0,0,,えっ?',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: '歌詞' },
{ startTime: 2, endTime: 3, text: '歌詞' },
{ startTime: 4, endTime: 5, text: '別の歌詞' },
{ startTime: 5, endTime: 6, text: '別の歌詞' },
{ startTime: 7, endTime: 8, text: 'えっ?' },
{ startTime: 8, endTime: 9, text: 'えっ?' },
]);
});
test('parseSubtitleCues keeps separately positioned temporal signs separate', () => {
// Two flush signs with the same text but different \move paths are separate authored
// occurrences, not phases of one redraw: temporal evidence alone must not merge them.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Sign,,0,0,0,,{\\move(100,100,200,100)}立入禁止',
'Dialogue: 0,0:00:02.00,0:00:03.00,Sign,,0,0,0,,{\\move(500,400,600,400)}立入禁止',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: '立入禁止' },
{ startTime: 2, endTime: 3, text: '立入禁止' },
]);
});
test('parseSubtitleCues keeps richly styled ordinary repeats separate', () => {
// Blur plus a changing color is still an ordinary restyle. Phase redraws are
// recognized by the color/highlight boundary moving *within* the line, which these
// leading-block-only events do not have.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}待ってよ',
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}待ってよ',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: '待ってよ' },
{ startTime: 2, endTime: 3, text: '待ってよ' },
]);
});
test('parseSubtitleCues keeps canonical metadata when an identical plain cue exists', () => {
// A plain dialogue line can share exact timing and text with a recovered canonical
// cue from another style. The canonical copy must win the exact-duplicate collapse,
// or the live overlay loses the marker it substitutes on.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:05.00,0:00:08.00,Plain,,0,0,0,,ライン',
'Comment: 0,0:00:05.00,0:00:08.00,OP,,0,0,0,,ライン',
'Dialogue: 0,0:00:05.00,0:00:05.04,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}ライン',
'Dialogue: 0,0:00:05.04,0:00:05.08,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}ライン',
'Dialogue: 0,0:00:05.08,0:00:08.00,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}ライン',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 5,
endTime: 8,
text: 'ライン',
source: 'canonical-ass',
animationStartTime: 5,
animationEndTime: 8,
},
]);
});
test('parseSubtitleCues keeps short styled repeats separate even with richer styling', () => {
// Two ordinary えっ lines restyled with different colors are two utterances, not two
// phases of one lyric: short text never satisfies the changing-override evidence path.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}えっ',
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}えっ',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'えっ' },
{ startTime: 2, endTime: 3, text: 'えっ' },
]);
});
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
// Several characters greeting in turn: distinct utterances that happen to abut.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:04:05.67,0:04:06.82,Dial_JP,,0,0,0,,おはよう',
'Dialogue: 0,0:04:06.82,0:04:07.56,Dial_JP,,0,0,0,,おはよう',
'Dialogue: 0,0:04:07.56,0:04:08.78,Dial_JP,,0,0,0,,おはよう',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
assert.equal(cues[0]!.endTime, 246.82);
assert.equal(cues[2]!.startTime, 247.56);
});
test('parseSubtitleCues collapses exact duplicate cues even without effect tags', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
'Dialogue: 1,0:00:01.00,0:00:04.00,Default,,0,0,0,,重なった行',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
});
test('parseSubtitleCues replaces generated glyph animation with its timed canonical comment', () => {
// Aegisub automation commonly keeps the authored lyric as a Comment and emits
// multiple moving Dialogue layers for every glyph. This mirrors the MyGO ED script:
// three entrance copies followed by three exit copies for each character.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.20,0:00:03.80,ED_JP,,0,0,0,,{\\fad(480,480)}今 手にある',
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(10,20,100,200)\\t(0,600,\\fscx100)}今',
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(30,40,100,200)\\t(0,600,\\fscx100)}今',
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(50,60,100,200)\\t(0,600,\\fscx100)}今',
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,20,30)\\t(2000,2600,\\blur20)}今',
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,40,50)\\t(2000,2600,\\blur20)}今',
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,60,70)\\t(2000,2600,\\blur20)}今',
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(10,20,140,200)\\t(0,600,\\fscx100)}手',
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(30,40,140,200)\\t(0,600,\\fscx100)}手',
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(50,60,140,200)\\t(0,600,\\fscx100)}手',
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,20,30)\\t(2000,2600,\\blur20)}手',
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,40,50)\\t(2000,2600,\\blur20)}手',
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,60,70)\\t(2000,2600,\\blur20)}手',
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(10,20,180,200)\\t(0,600,\\fscx100)}にある',
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(30,40,180,200)\\t(0,600,\\fscx100)}にある',
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(50,60,180,200)\\t(0,600,\\fscx100)}にある',
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,20,30)\\t(2000,2600,\\blur20)}にある',
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,40,50)\\t(2000,2600,\\blur20)}にある',
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,60,70)\\t(2000,2600,\\blur20)}にある',
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial_JP,,0,0,0,,普通の会話',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(cues, [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
// Entrance frames start before and exit frames end after the authored timing.
animationStartTime: 0.8,
animationEndTime: 4.32,
},
{ startTime: 6, endTime: 8, text: '普通の会話' },
]);
});
test('parseSubtitleCues recovers a full Dialogue line surrounding generated fragments', () => {
// Some scripts do not retain the authored line as a Comment. Instead, brief entrance
// and exit events contain the complete line around a long run of generated syllables.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:01.15,ED Romaji,,0,0,0,fx,{\\move(100,40,60,40)}toki yo ugokidase',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(0,300,\\c&HFFFFFF&)}to',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(300,500,\\c&HFFFFFF&)}ki',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(500,700,\\c&HFFFFFF&)}yo',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(700,900,\\c&HFFFFFF&)}u',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(900,1100,\\c&HFFFFFF&)}go',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1100,1300,\\c&HFFFFFF&)}ki',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1300,1500,\\c&HFFFFFF&)}da',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1500,1800,\\c&HFFFFFF&)}se',
'Dialogue: 1,0:00:03.00,0:00:03.15,ED Romaji,,0,0,0,fx,{\\move(60,40,20,40)}toki yo ugokidase',
'Dialogue: 0,0:00:06.00,0:00:08.00,Default,,0,0,0,,Ordinary dialogue',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 3.15,
text: 'toki yo ugokidase',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 3.15,
},
{ startTime: 6, endTime: 8, text: 'Ordinary dialogue' },
]);
});
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(
cues.some((cue) => cue.source === 'canonical-ass'),
false,
);
});
test('parseSubtitleCues keeps short animated English dialogue as separate cues', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}Hi',
'Dialogue: 0,0:00:02.00,0:00:03.00,English Dialogue,,0,0,0,,{\\t(0,100,\\fscx110)}No',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'Hi' },
{ startTime: 2, endTime: 3, text: 'No' },
]);
});
test('parseSubtitleCues does not reconstruct an already canonical English cue', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\move(100,100,120,100)}POOF',
'Dialogue: 0,0:00:01.00,0:00:01.04,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 1 1)}POOF',
'Dialogue: 0,0:00:01.04,0:00:01.08,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 2 2)}POOF',
'Dialogue: 0,0:00:01.08,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\clip(m 3 3)}POOF',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 3,
text: 'POOF',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 3,
},
]);
});
test('parseSubtitleCues reconstructs a short positioned fragment without a lyric style name', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
'Dialogue: 1,0:00:01.00,0:00:03.00,Karaoke,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx110)}Oh',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 3,
text: 'Oh',
source: 'reconstructed-ass',
animationStartTime: 1,
animationEndTime: 3,
assStyle: 'Karaoke',
},
]);
});
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,編集メモ',
'Comment: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
'Dialogue: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,通常の字幕',
'Dialogue: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(cues, [
{ startTime: 1, endTime: 3, text: '通常の字幕' },
{ startTime: 4, endTime: 6, text: '別案の字幕' },
]);
});
test('parseAssCues returns recovered canonical cues in chronological order', () => {
// Recovery appends recovered cues after surviving dialogue; the bare parseAssCues
// export must still come back time-ordered.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial,,0,0,0,,あとのセリフ',
'Comment: 0,0:00:01.20,0:00:03.80,OP,,0,0,0,,雨が上がっても',
'Dialogue: 0,0:00:01.20,0:00:01.24,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}雨が上がっても',
'Dialogue: 0,0:00:01.24,0:00:01.28,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}雨が上がっても',
'Dialogue: 0,0:00:01.28,0:00:03.80,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}雨が上がっても',
].join('\n');
assert.deepEqual(
parseAssCues(content).map((cue) => cue.startTime),
[1.2, 6],
);
});
test('parseSubtitleCues withdraws a recovery whose owner is claimed by a later candidate', () => {
// The exit boundary event appears first in the file and recovers a canonical cue from
// its own small cluster. The entrance candidate then proves that exit event was a
// generated frame of the full animation; the earlier recovery is a duplicate of the
// same authored line and must not survive alongside it.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:14.00,0:00:14.20,ED,,0,0,0,,{\\move(100,200,20,30)}ABCDEFGH',
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(0,300,\\c&HFFFFFF&)}ABC',
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(300,600,\\c&HFFFFFF&)}DEF',
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(600,900,\\c&HFFFFFF&)}GH',
'Dialogue: 0,0:00:10.00,0:00:10.20,ED,,0,0,0,,{\\move(10,20,100,200)}ABCDEFGH',
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(0,300,\\fscx100)}ABC',
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(300,600,\\fscx100)}DEF',
'Dialogue: 0,0:00:10.00,0:00:13.40,ED,,0,0,0,,{\\t(600,900,\\fscx100)}GH',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 10,
endTime: 14.2,
text: 'ABCDEFGH',
source: 'canonical-ass',
animationStartTime: 10,
animationEndTime: 14.2,
},
]);
});
test('parseSubtitleCues recovers canonical comments from generated clip frames', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:03.00,OP_JP,,0,0,0,,雨が上がっても',
'Dialogue: 0,0:00:01.00,0:00:01.04,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 1 1)}雨が上がっても',
'Dialogue: 0,0:00:01.04,0:00:01.08,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 2 2)}雨が上がっても',
'Dialogue: 0,0:00:01.08,0:00:03.00,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 3 3)}雨が上がっても',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(cues, [
{
startTime: 1,
endTime: 3,
text: '雨が上がっても',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 3,
},
]);
});
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
for (let i = 1; i < 8; i++) {
const start = 7910 + (i - 1) * 40;
const end = start + 40;
const at = (ms: number) =>
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
lines.push(String(i + 1), `${at(start)} --> ${at(end)}`, 'Kaguya Wants to be Confessed to', '');
}
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 7.87);
});
test('parseSubtitleCues keeps identical lines that recur far apart', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,なんで',
'Dialogue: 0,0:05:00.00,0:05:01.00,Default,,0,0,0,,なんで',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[1]!.startTime, 300.0);
});
test('parseSubtitleCues keeps two positioned signs that repeat the same text', () => {
// Both carry override tags, but `\pos` and `\fad` are static placement, not animation.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:01:00.00,0:01:03.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(200,200)}第一話',
'Dialogue: 0,0:01:03.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,900)\\fad(200,200)}第一話',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[1]!.startTime, 63.0);
});
test('parseSubtitleCues keeps a run of ordinary positioned lines separate', () => {
// Three events is a sequence, but none of them runs at animation-frame speed.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:01:00.00,0:01:02.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
'Dialogue: 0,0:01:02.00,0:01:04.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
'Dialogue: 0,0:01:04.00,0:01:06.00,Sign,,0,0,0,,{\\pos(960,120)\\fad(100,100)}止まれ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues keeps a short repeated SRT pair without burst evidence', () => {
const content = [
'1',
'00:00:01,000 --> 00:00:01,200',
'えっ',
'',
'2',
'00:00:01,200 --> 00:00:01,400',
'えっ',
'',
].join('\n');
const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues.length, 2);
});
test('parseSubtitleCues collapses a burst marked only by the Effect column', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,Karaoke,歌詞',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,Karaoke,歌詞',
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,Karaoke,歌詞',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.endTime, 3.55);
});
test('parseSubtitleCues keeps a second karaoke burst that starts after a gap', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
'Dialogue: 0,0:00:01.09,0:00:03.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
'Dialogue: 0,0:00:20.00,0:00:20.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}リフレイン',
'Dialogue: 0,0:00:20.05,0:00:20.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}リフレイン',
'Dialogue: 0,0:00:20.09,0:00:22.00,OP_JP,,0,0,0,,{\\clip(m 3 3)}リフレイン',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[0]!.endTime, 3.0);
assert.equal(cues[1]!.startTime, 20.0);
assert.equal(cues[1]!.endTime, 22.0);
});
test('parseSubtitleCues does not merge a burst into unrelated dialogue between frames', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,OP_JP,,0,0,0,,{\\clip(m 1 1)}歌詞',
'Dialogue: 0,0:00:01.02,0:00:03.00,Dial_JP,,0,0,0,,別のセリフ',
'Dialogue: 0,0:00:01.05,0:00:01.09,OP_JP,,0,0,0,,{\\clip(m 2 2)}歌詞',
'Dialogue: 0,0:00:01.09,0:00:03.55,OP_JP,,0,0,0,,{\\clip(m 3 3)}歌詞',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.deepEqual(
cues.map((cue) => cue.text),
['歌詞', '別のセリフ'],
);
assert.equal(cues[0]!.endTime, 3.55);
});
test('parseSubtitleCues keeps rapid ASS lines from different actors separate', () => {
// Three 200ms `えっ` reactions traded between characters. Fast, adjacent and identical,
// but authored as three lines: different styles and different actors.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_A,アリス,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_B,ボブ,0,0,0,,えっ',
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_C,キャロル,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues reads the speaker column when it is spelled Actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Actor, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues does not treat a custom Effect name as animation', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,scrolling-credit,制作',
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,scrolling-credit,制作',
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,scrolling-credit,制作',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues keeps rapid ASS lines that share a style but not an actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Dial_JP,アリス,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.40,Dial_JP,ボブ,0,0,0,,えっ',
'Dialogue: 0,0:00:01.40,0:00:01.60,Dial_JP,キャロル,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues keeps untagged rapid ASS repeats separate', () => {
// No overrides at all: timing-only evidence is an SRT/VTT fallback and must not apply
// to ASS, where the absence of typesetting is itself evidence of plain dialogue.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.05,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.05,0:00:01.10,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.10,0:00:01.15,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.15,0:00:01.20,Dial_JP,,0,0,0,,えっ',
'Dialogue: 0,0:00:01.20,0:00:01.25,Dial_JP,,0,0,0,,えっ',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 5);
});
test('parseSubtitleCues keeps repeated signs sharing one static clip', () => {
// `\clip` is a static shape for the event. Three events with the identical clip were
// typeset the same way, so none of them is a frame of the others.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
'Dialogue: 0,0:00:01.40,0:00:01.60,Sign,,0,0,0,,{\\clip(0,0,100,100)}注意',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 3);
});
test('parseSubtitleCues collapses a sign animated through \\t', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
'Dialogue: 0,0:00:01.20,0:00:01.40,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
'Dialogue: 0,0:00:01.40,0:00:03.00,Sign,,0,0,0,,{\\pos(10,10)\\t(0,200,\\frz30)}回る',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.endTime, 3.0);
});
test('parseSubtitleCues keeps a short repeated SRT run above the frame threshold', () => {
// Five contiguous 200ms cues: a sequence, but nowhere near animation-frame speed.
const lines: string[] = [];
for (let i = 0; i < 5; i++) {
const start = 1000 + i * 200;
const at = (ms: number) =>
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
lines.push(String(i + 1), `${at(start)} --> ${at(start + 200)}`, 'えっ', '');
}
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
assert.equal(cues.length, 5);
});
test('parseSubtitleCues keeps a short SRT frame run below the minimum length', () => {
// Four 40ms frames: frame-speed, but too few to tell an animation from an artefact.
const lines: string[] = [];
for (let i = 0; i < 4; i++) {
const start = 7870 + i * 40;
const at = (ms: number) =>
`00:00:0${Math.floor(ms / 1000)},${String(ms % 1000).padStart(3, '0')}`;
lines.push(String(i + 1), `${at(start)} --> ${at(start + 40)}`, 'タイトル', '');
}
const cues = parseSubtitleCues(lines.join('\n'), 'test.srt');
assert.equal(cues.length, 4);
});
test('parseSubtitleCues applies ASS burst rules to ASS content behind an .srt filename', () => {
// The extension lies, so the SRT parser finds nothing and the content-sniffing fallback
// takes over -- which has to carry the `ass` source format with it, or the far stricter
// timing-only thresholds would let this karaoke burst through as three cues.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:01.20,Karaoke,,0,0,0,,{\\k20}歌詞',
'Dialogue: 0,0:00:01.20,0:00:01.40,Karaoke,,0,0,0,,{\\k20}歌詞',
'Dialogue: 0,0:00:01.40,0:00:03.00,Karaoke,,0,0,0,,{\\k20}歌詞',
].join('\n');
const cues = parseSubtitleCues(content, 'test.srt');
assert.equal(cues.length, 1);
assert.equal(cues[0]!.startTime, 1.0);
assert.equal(cues[0]!.endTime, 3.0);
assert.equal(cues[0]!.text, '歌詞');
});
test('parseSubtitleCues detects subtitle formats from remote URLs', () => {
const assContent = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,URLテスト',
].join('\n');
const cues = parseSubtitleCues(assContent, 'https://host/subs.ass?lang=ja#track');
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 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');
});
// Geometry taken from a real per-glyph ED line. The `waves within` gap crosses a wide
// `w`, so the width-normalized ratio reads it as a common advance; only the constant
// extra distance of the authored word space gives it away.
test('parseSubtitleCues recovers a word gap measured across a wide glyph', () => {
const text = 'youcanhearthesoundofthewaveswithinmyheart';
const positions = [
202, 223, 244, 274, 296, 317, 346, 367, 389, 408, 433, 450, 470, 499, 517, 538, 557, 578, 609,
627, 651, 668, 688, 723, 748, 770, 792, 811, 843, 863, 875, 892, 907, 922, 957, 983, 1013, 1034,
1055, 1075, 1090,
];
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,ED English,,0,0,0,,{\\pos(${positions[index]},687)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(
parseSubtitleCues(content, 'test.ass')[0]?.text,
'you can hear the sound of the waves within my heart',
);
});
// A single short word gives too few gap samples to trust the excess rule: its narrow
// glyphs skew the common advance low and `w e` would read as a word gap.
test('parseSubtitleCues does not split a short single positioned word', () => {
const text = 'Swelling';
const positions = [592, 613, 635, 647, 655, 662, 673, 689];
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,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Swelling');
});
// A capitalized word whose first letter sits before a wide glyph (`S|miles`) overruns
// the width table; the excess rule must not split a capital from its lowercase run.
test('parseSubtitleCues keeps a capitalized word intact under the excess rule', () => {
const text = 'Smilesarebudding';
const positions = [37, 63, 80, 88, 99, 113, 142, 157, 171, 201, 217, 235, 255, 269, 280, 295];
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,ED English,,0,0,0,,{\\pos(${positions[index]},682)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${glyph}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'Smiles are budding');
});
// Mirrors a real ED: per-syllable romaji at y=34 overlaid with animated single letters
// at y=29 rendered through `\fn` in a symbol font, where `a` draws as a sparkle. The
// letters must neither join the reconstructed line nor survive as their own cues.
test('parseSubtitleCues drops symbol-font glyph decoration from a reconstructed line', () => {
const syllables = [
['so', 479],
['t', 505],
['to', 529],
['mi', 577],
['mi', 618],
['ni', 663],
['a', 699],
['te', 728],
['ru', 764],
['to', 810],
] as const;
const decoration = [
['a', 479, '0:00:01.25'],
['z', 577, '0:00:02.51'],
['x', 618, '0:00:02.78'],
['q', 505, '0:00:04.20'],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
syllables.map(
([syllable, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:05.37,ED Romaji,,0,0,0,fx,{\\an5\\pos(${x},34)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${syllable}`,
),
),
...decoration.map(
([glyph, x, start]) =>
`Dialogue: 0,${start},0:00:05.37,ED Romaji,,0,0,0,fx,{\\pos(${x},29)\\fnSplit splat splodge\\fs28\\t(3870,3970,\\fscx105)}${glyph}`,
),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text, 'sotto mimi ni ateru to');
});
test('parseSubtitleCues drops clipped repeated-glyph texture text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 10,0:00:01.00,0:00:04.00,Default,,0,0,0,,I'm blocking them.",
'Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,80)\\fnSerangkaian Pattern Regular\\clip(800,20,1120,140)}LLLLLLLLLLLLLLLLLLLLLLLL',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,150)\\fnSF Pro Display}Enter a message',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
["I'm blocking them.", 'Enter a message'],
);
});
test('parseSubtitleCues drops clipped repeated-glyph texture text without a font override', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(736.49,152.99)\\fscy150\\fs10\\bord3\\c&H657BC8&\\3c&H657BC8&\\blur3\\clip}lllllllllllll',
'Dialogue: 0,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\an7\\pos(769.9,106.18)\\fscy150\\fs12\\bord3\\c&H66729F&\\3c&H66729F&\\blur5\\clip}llll',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)}Read',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read'],
);
});
test('parseSubtitleCues drops per-character alpha texture text', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 10,0:00:01.00,0:00:04.00,Default,Girl,0,0,0,,So Doloris was actually Uika-chan from sumimi! That's amazing!",
"Dialogue: 2,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,240)\\fnCinzel}Hanasakigawa Girl's School",
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,300)\\fnSplit splat splodge\\clip(800,200,1120,400)}d{\\2a1}s{\\2a0}h{\\2a1}f{\\2a0}k{\\2a1}h{\\2a0}f{\\2a1}s{\\2a0}d{\\2a1}f{\\2a0}e',
'Dialogue: 3,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(980,340)\\fnSplit splat splodge}f {\\2a1}a',
'Dialogue: 4,0:00:01.00,0:00:04.00,MarySigns,,0,0,0,,{\\pos(960,360)\\fnGrain SemiBold}5{\\2a1}X{\\2a0}N{\\2a1}T{\\2a0}f{\\2a1}I{\\2a0}g{\\2a1}F{\\2a0}B{\\2a1}?{\\2a0}k{\\2a1}u{\\2a0}C{\\2a1}m',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
"So Doloris was actually Uika-chan from sumimi! That's amazing!",
"Hanasakigawa Girl's School",
],
);
});
test('parseSubtitleCues drops transparent texture payloads across an animated sign', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
"Dialogue: 90,0:00:01.00,0:00:04.00,Alt,,0,0,0,,Even if you want to see her, she doesn't want to see you!",
'Dialogue: 0,0:00:01.00,0:00:01.08,FrogSigns,,0,0,0,,{\\pos(699,803)\\fnSerangkaian Pattern Regular\\clip(300,380,1130,1050)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a0}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\\\\\\\\\\\\\\\\\\\\\',
'Dialogue: 3,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,Street,0,0,0,,{\\pos(285,653)\\fnGrain\\alpha&HE0&}H1.4igcAhGYHVWD"kHcVlG2W9eKEWj"!X\\N\'uNVaEVpTXMd9rk7dnRX\'P!RhsS"Wn90k6',
'Dialogue: 6,0:00:01.00,0:00:01.08,FrogSigns,18K,0,0,0,,{\\pos(284,821)\\fnGrain\\alpha&HE0&}ou:QepiiPqQ.4n.IYbFaGHtPzWyKI9CUSq:',
'Dialogue: 1,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(581,921)\\fnSerangkaian Pattern Regular\\clip(195,495,986,1120)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HE0&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 5,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnRoboto Medium\\alpha&H00&}Street performance by Mortis from\\NMujica - Acting prodigy in action!',
'Dialogue: 3,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(151,769)\\fnGrain\\alpha&HF0&}9LF\'GpPCTlOkLxBLV:QN,8R8NUVM"ha.s\\NNUUPNTBdJih4jUthK34i,yYe;9EBgLXbET',
"Dialogue: 6,0:00:01.08,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(150,936)\\fnGrain\\alpha&HE0&}JS7vl:lD;'PzkCb!bGT;.7TbA.KCkEH0LOk",
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
[
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
"Even if you want to see her, she doesn't want to see you!",
'Street performance by Mortis from\nMujica - Acting prodigy in action!',
],
);
});
test('parseSubtitleCues does not reconstruct short texture pieces under another actor', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,bubble,0,0,0,,{\\pos(245,-102)\\fnSerangkaian Pattern Regular\\clip(224,-1,831,106)}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L{\\2a0}L{\\2a1}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(917,293)\\alpha&H20&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&H58&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 4,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(845,300)\\alpha&H00&\\fnSerangkaian Pattern Regular\\clip(904,289,1010,336)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(907,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(911,293)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,289,1010,338)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 7,0:00:01.00,0:00:04.00,FrogSigns,read,0,0,0,,{\\pos(922,130)\\alpha&HD0&\\fnSerangkaian Pattern Regular\\clip(891,120,1010,173)}L{\\2a0}L{\\2a1}L{\\2a0}L',
'Dialogue: 5,0:00:01.00,0:00:04.00,FrogSigns,,0,0,0,,{\\pos(893,311)\\fnSFProDisplay-Regular-STR}Read 3',
].join('\n');
assert.deepEqual(
parseSubtitleCues(content, 'test.ass').map((cue) => cue.text),
['Read 3'],
);
});
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 a staggered repeated token publishable', () => {
const fragments = [
['dreams', 300, 115, '0:00:01.00'],
['ju', 250, 39, '0:00:01.00'],
['n', 280, 39, '0:00:01.00'],
['jo', 300, 39, '0:00:01.00'],
['u', 330, 39, '0:00:01.00'],
['to', 360, 39, '0:00:01.00'],
['jo', 395, 39, '0:00:01.02'],
['u', 425, 39, '0:00:01.00'],
['ne', 455, 39, '0:00:01.00'],
['tsu!', 485, 39, '0:00:01.00'],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([text, x, y, start], index) =>
`Dialogue: ${layer},${start},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],
['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');
});
test('parseSubtitleCues suppresses a karaoke highlight sweep without publishing it', () => {
// Main lyric: per-glyph fragments alive together for the whole line.
const lineFragments = [
['to', 972],
['so', 1051],
['u', 1113],
['o', 1166],
['mo', 1204],
] as const;
// Highlight sweep: one syllable at a time over the same lyric, each event ending
// exactly as the next begins, so no two syllables are ever on screen together.
const sweepFragments = [
['to', 972, '0:00:01.00', '0:00:01.40'],
['so', 1051, '0:00:01.40', '0:00:01.80'],
['u', 1113, '0:00:01.80', '0:00:02.20'],
['o', 1166, '0:00:02.20', '0:00:02.60'],
['mo', 1204, '0:00:02.60', '0:00:03.00'],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...[0, 1].flatMap((layer) =>
lineFragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:00:01.00,0:00:04.00,ED Romaji,,0,0,0,fx,{\\pos(${x},60)\\t(${index * 2},${index * 2 + 100},\\fscx120)}${fragment}`,
),
),
...sweepFragments.flatMap(([fragment, x, start, end]) =>
[
[40, x, 60],
[41, x + 4, 64],
].map(
([layer, copyX, copyY]) =>
`Dialogue: ${layer},${start},${end},ED Romaji2,,0,0,0,fx,{\\an5\\pos(${copyX},${copyY})\\t(150,290,\\1a&HFF&)}${fragment}`,
),
),
'Dialogue: 42,0:00:01.20,0:00:01.30,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}a',
'Dialogue: 42,0:00:04.00,0:00:04.20,ED Romaji2,,0,0,0,fx,{\\fnWebdings\\pos(900,50)\\t(0,100,\\fscx120)}z',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 2);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'tosouomo');
assert.equal(cues[1]?.text, 'z');
});
test('parseSubtitleCues collapses drop-shadow layer copies offset by a few pixels', () => {
const fragments = [
['me', 580],
['no', 668],
['mae', 770],
['ni', 864],
['no', 939],
['bi', 996],
['ru', 1049],
] as const;
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
...fragments.flatMap(([fragment, x], index) => [
`Dialogue: 30,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x},25)\\bord0\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`,
// Shadow copy sits 4px off the base glyph and must not read as a second syllable.
`Dialogue: 29,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x + 4},29)\\c&HFFFFFF&\\t(${index * 2},${index * 2 + 120},\\blur9)}${fragment}`,
`Dialogue: 28,0:01:42.00,0:01:46.92,OP Romaji,,0,0,0,fx,{\\pos(${x},25)\\c&HFFFFFF&\\t(${index * 2},${index * 2 + 120},\\blur9)}${fragment}`,
]),
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(cues.length, 1);
assert.equal(cues[0]?.text.replace(/\s+/gu, ''), 'menomaeninobiru');
});
test('parseSubtitleCues recovers positional word gaps beside an authored space', () => {
// Real ED line: every glyph is placed by `\move`, but the `star` fragment alone carries
// a literal leading space. The authored space must not disable positional recovery for
// the rest of the line.
const fragments = [
['s', 633],
['e', 665],
['a', 697],
['r', 723],
['c', 747],
['h', 774],
['i', 793],
['n', 813],
['g', 838],
['f', 884],
['o', 911],
['r', 937],
['a', 986],
['s', 1041],
['h', 1070],
['o', 1098],
['o', 1128],
['t', 1153],
['i', 1169],
['n', 1188],
['g', 1214],
[' s', 1264],
['t', 1290],
['a', 1316],
['r', 1342],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:22:44.83,0:22:47.70,ED English,,0,0,0,fx,{\\move(${x},1020,${x},1020,0,300)\\t(${index * 2},${index * 2 + 300},\\fs90)}${fragment}`,
),
),
].join('\n');
assert.equal(parseSubtitleCues(content, 'test.ass')[0]?.text, 'searching for a shooting star');
});
test('parseSubtitleCues splits chunked words whose gap only the excess rule catches', () => {
// `Choices|presumably` normalizes to just under the ratio threshold because both
// neighbors are wide three-letter chunks; its constant word-space excess still shows.
const fragments = [
['Ch', 526],
['oi', 584],
['ces', 648],
['pre', 747],
['su', 819],
['mab', 904],
['ly', 981],
['ma', 1059],
['de', 1128],
['by', 1203],
['cha', 1295],
['nce', 1385],
] as const;
const content = [
...eventsHeader,
...[0, 1].flatMap((layer) =>
fragments.map(
([fragment, x], index) =>
`Dialogue: ${layer},0:01:53.01,0:01:55.52,OP English,,0,0,0,fx,{\\pos(${x},1055)\\t(${index * 2},${index * 2 + 120},\\blur0.5)}${fragment}`,
),
),
].join('\n');
assert.equal(
parseSubtitleCues(content, 'test.ass')[0]?.text,
'Choices presumably made by chance',
);
});