mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-01 23:54:31 -07:00
fix(subtitles): merge wrapped positioned caption rows (#234)
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Broadcast-style Japanese caption tracks (Crunchyroll JA subs) that split one sentence across two positioned events now publish it as a single line, so `preserveLineBreaks: false` flattens it, the sidebar lists it once, and mined sentences are whole. Rows from two different speakers, sound effects, and labeled turns still stay on separate lines.
|
||||||
@@ -134,6 +134,16 @@ coming and prefetching would otherwise idle for the rest of the cue.
|
|||||||
mpv's raw live text can be reconciled without displaying or mining the reading. The
|
mpv's raw live text can be reconciled without displaying or mining the reading. The
|
||||||
timing tracker (clipboard copy, recent-line mining) and immersion recorders run the same
|
timing tracker (clipboard copy, recent-line mining) and immersion recorders run the same
|
||||||
reconciliation on the `sub-start`/`sub-end` sample, so they record what the overlay shows.
|
reconciliation on the `sub-start`/`sub-end` sample, so they record what the overlay shows.
|
||||||
|
- Broadcast-caption rows that spell one utterance across several same-timed positioned events
|
||||||
|
(same style, layer, and vertical band, stacked at most two text rows apart) are joined into
|
||||||
|
one cue with a single line break, so `preserveLineBreaks` treats them like an authored `\N`,
|
||||||
|
and the recorders above see the whole sentence. A row continues the one above it when that row
|
||||||
|
is a bare speaker label, ends without terminal punctuation, or leaves a ≪…≫ / ⸨…⸩ span open; a
|
||||||
|
lower row that opens its own label or span always starts a new cue, which keeps two speakers
|
||||||
|
sharing the screen on separate lines. The pass runs only on scripts that read as broadcast
|
||||||
|
captions (a meaningful share of events carry speaker labels or ≪…≫ / ⸨…⸩ spans) and only on
|
||||||
|
rows containing Japanese, because fansub typesetting stacks positioned rows for signs, chat
|
||||||
|
bubbles, and headlines where that punctuation convention does not hold.
|
||||||
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
- Fragment-only ASS karaoke is reconstructed per style before publication. Explicit spaces
|
||||||
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
survive concatenation. Latin fragment typesetting with no literal spaces also recovers word
|
||||||
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
boundaries represented only by materially larger horizontal `\pos` or `\move` gaps within that
|
||||||
|
|||||||
@@ -1449,15 +1449,17 @@ test('parseSubtitleCues keeps tall CC-style base dialogue publishable after remo
|
|||||||
'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(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(332,443)\\fscx50\\fscy50}ともり',
|
||||||
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
'Dialogue: 0,0:00:06.11,0:00:10.11,Default,,0,0,0,,{\\pos(192,497)}お前…{\\fscx50} {\\fscx100}燈をバンドに誘ったの?',
|
||||||
|
// A second labeled turn, so the script reads as broadcast captions.
|
||||||
|
'Dialogue: 0,0:00:10.11,0:00:12.00,Default,,0,0,0,,{\\pos(192,497)\\fscx50}({\\fscx100}燈{\\fscx50}){\\fscx100}うん。',
|
||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
const cues = parseSubtitleCues(content, 'test.ass');
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
// The bare speaker label row joins the dialogue row beneath it as one cue.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
cues.map((cue) => cue.text),
|
cues.map((cue) => cue.text),
|
||||||
['(立希)', 'お前… 燈をバンドに誘ったの?'],
|
['(立希)\nお前… 燈をバンドに誘ったの?', '(燈)うん。'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
|
assert.deepEqual(cues[0]?.assFurigana, ['たき', 'ともり']);
|
||||||
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
|
|
||||||
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1485,14 +1487,184 @@ test('parseSubtitleCues removes half-size positioned furigana from broadcast cap
|
|||||||
[
|
[
|
||||||
'(山田)ごめん 結局 ぬれたな。',
|
'(山田)ごめん 結局 ぬれたな。',
|
||||||
'大丈夫。',
|
'大丈夫。',
|
||||||
'(山田の母)ほんなら',
|
'(山田の母)ほんなら\n隠し貯蔵のミルクまんじゅう➡',
|
||||||
'隠し貯蔵のミルクまんじゅう➡',
|
|
||||||
'絶対違う',
|
'絶対違う',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
assert.deepEqual(cues[1]?.assFurigana, ['だいじょうぶ']);
|
||||||
assert.deepEqual(cues[3]?.assFurigana, ['かく', 'ちょぞう']);
|
assert.deepEqual(cues[2]?.assFurigana, ['かく', 'ちょぞう']);
|
||||||
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
|
assert.deepEqual(cues[3]?.assFurigana, ['ぜったい ちが']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast-caption rows from You and I Are Polar Opposites S02E09. Every pair shares
|
||||||
|
// timing, style, and the bottom band; only the text tells a wrap from a second speaker.
|
||||||
|
const captionRowsHeader = ['[Script Info]', 'PlayResY: 540', '', ...eventsHeader];
|
||||||
|
|
||||||
|
function captionRow(start: string, end: string, x: number, y: number, text: string): string {
|
||||||
|
return `Dialogue: 0,${start},${end},Default,,0,0,0,,{\\pos(${x},${y})}${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('parseSubtitleCues joins caption rows that wrap one sentence across two events', () => {
|
||||||
|
const content = [
|
||||||
|
...captionRowsHeader,
|
||||||
|
captionRow('0:00:19.08', '0:00:22.66', 172, 437, '⸨ぶっちゃけ'),
|
||||||
|
captionRow(
|
||||||
|
'0:00:19.08',
|
||||||
|
'0:00:22.66',
|
||||||
|
172,
|
||||||
|
497,
|
||||||
|
'早く{\\fscx50} {\\fscx100}この勉強生活 終えたいし⸩',
|
||||||
|
),
|
||||||
|
// No bracket at all: the upper row simply has not reached sentence punctuation.
|
||||||
|
captionRow('0:02:42.33', '0:02:44.43', 232, 437, '(東)≪好きだと'),
|
||||||
|
captionRow('0:02:42.33', '0:02:44.43', 232, 497, '自覚してしまったものの➡'),
|
||||||
|
// Rows are centred independently, so a wrap can change x between rows.
|
||||||
|
captionRow('0:00:42.21', '0:00:45.21', 252, 407, '≪ちょっとしたことで'),
|
||||||
|
captionRow('0:00:42.21', '0:00:45.21', 292, 497, '勝手に落ち込んだり➡'),
|
||||||
|
// A quote closed with 」 inside a still-open ≪…≫ span is not the end of the line.
|
||||||
|
captionRow('0:19:02.84', '0:19:05.00', 212, 437, '≪「つきあえる自信がない」'),
|
||||||
|
captionRow('0:19:02.84', '0:19:05.00', 452, 497, 'じゃない≫'),
|
||||||
|
// An in-sentence 「 quote on the lower row is not a new turn.
|
||||||
|
captionRow('0:18:35.55', '0:18:38.00', 232, 437, '今 「好きだ」と'),
|
||||||
|
captionRow('0:18:35.55', '0:18:38.00', 192, 497, '「心地いい」と感じてるのも➡'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
[
|
||||||
|
'⸨ぶっちゃけ\n早く この勉強生活 終えたいし⸩',
|
||||||
|
'≪ちょっとしたことで\n勝手に落ち込んだり➡',
|
||||||
|
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||||
|
'今 「好きだ」と\n「心地いい」と感じてるのも➡',
|
||||||
|
'≪「つきあえる自信がない」\nじゃない≫',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
assert.ok(cues.every((cue) => cue.assLayout?.kind === 'positioned'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps simultaneous caption rows from two speakers separate', () => {
|
||||||
|
const content = [
|
||||||
|
...captionRowsHeader,
|
||||||
|
// Both unlabeled: the upper row finished its sentence.
|
||||||
|
captionRow('0:03:56.10', '0:04:00.04', 172, 437, 'なあ 車両 変えね?'),
|
||||||
|
captionRow('0:03:56.10', '0:04:00.04', 632, 497, 'えっ?➡'),
|
||||||
|
// Lower row opens a labeled turn.
|
||||||
|
captionRow('0:03:38.48', '0:03:42.05', 592, 437, 'おはよう!'),
|
||||||
|
captionRow('0:03:38.48', '0:03:42.05', 272, 497, '(平)あっ 声 でかっ。'),
|
||||||
|
// A closed monologue span above a sound effect.
|
||||||
|
captionRow('0:08:16.83', '0:08:19.50', 372, 437, '≪落ち着け 落ち着け≫'),
|
||||||
|
captionRow('0:08:16.83', '0:08:19.50', 272, 497, 'ドクン ドクン ドクン…'),
|
||||||
|
// Two labeled speakers.
|
||||||
|
captionRow('0:09:27.90', '0:09:31.07', 312, 437, '(平)ぐぅ…。'),
|
||||||
|
captionRow('0:09:27.90', '0:09:31.07', 352, 497, '(東)≪ちくしょう~!≫'),
|
||||||
|
// A bare label never swallows a differently labeled row.
|
||||||
|
captionRow('0:11:43.24', '0:11:45.00', 212, 437, '(長谷川)'),
|
||||||
|
captionRow('0:11:43.24', '0:11:45.00', 412, 497, '(早乙女)ん?'),
|
||||||
|
// A short sentence-final 。 closes the upper row like any other.
|
||||||
|
captionRow('0:12:31.55', '0:12:33.55', 172, 437, '⚞(東)平。'),
|
||||||
|
captionRow('0:12:31.55', '0:12:33.55', 532, 497, 'あっ。'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'polar-opposites-s02e09.ass');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
[
|
||||||
|
'おはよう!',
|
||||||
|
'(平)あっ 声 でかっ。',
|
||||||
|
'なあ 車両 変えね?',
|
||||||
|
'えっ?➡',
|
||||||
|
'≪落ち着け 落ち着け≫',
|
||||||
|
'ドクン ドクン ドクン…',
|
||||||
|
'(平)ぐぅ…。',
|
||||||
|
'(東)≪ちくしょう~!≫',
|
||||||
|
'(長谷川)',
|
||||||
|
'(早乙女)ん?',
|
||||||
|
'⚞(東)平。',
|
||||||
|
'あっ。',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues keeps caption rows apart across styles, bands, and timing', () => {
|
||||||
|
const content = [
|
||||||
|
...captionRowsHeader,
|
||||||
|
// Same wording as a wrap, but the rows sit in different vertical bands.
|
||||||
|
captionRow('0:01:00.00', '0:01:02.00', 172, 77, '≪ちょっとしたことで'),
|
||||||
|
captionRow('0:01:00.00', '0:01:02.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||||
|
// Same band, but a sign style beside dialogue.
|
||||||
|
'Dialogue: 0,0:01:05.00,0:01:07.00,Sign,,0,0,0,,{\\pos(172,437)}ちょっとしたことで',
|
||||||
|
captionRow('0:01:05.00', '0:01:07.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||||
|
// Same rows, but the lower one ends later.
|
||||||
|
captionRow('0:01:10.00', '0:01:12.00', 172, 437, '≪ちょっとしたことで'),
|
||||||
|
captionRow('0:01:10.00', '0:01:13.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||||
|
// Style-aligned rows without \pos are never caption rows.
|
||||||
|
'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an8}≪ちょっとしたことで',
|
||||||
|
'Dialogue: 0,0:01:15.00,0:01:17.00,Default,,0,0,0,,{\\an2}勝手に落ち込んだり➡',
|
||||||
|
// Same height: the events sit side by side, not one above the other.
|
||||||
|
captionRow('0:01:20.00', '0:01:22.00', 172, 497, '≪ちょっとしたことで'),
|
||||||
|
captionRow('0:01:20.00', '0:01:22.00', 612, 497, '勝手に落ち込んだり➡'),
|
||||||
|
// Same bottom band, but further apart than two text rows.
|
||||||
|
captionRow('0:01:25.00', '0:01:27.00', 172, 367, '≪ちょっとしたことで'),
|
||||||
|
captionRow('0:01:25.00', '0:01:27.00', 172, 497, '勝手に落ち込んだり➡'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.equal(cues.length, 12);
|
||||||
|
assert.ok(cues.every((cue) => !cue.text.includes('\n')));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues leaves typeset rows alone in scripts that are not broadcast captions', () => {
|
||||||
|
// Fansub typesetting stacks positioned rows for signs, chat bubbles, and headlines. Such
|
||||||
|
// text carries no caption punctuation, so without the script-level gate every stacked
|
||||||
|
// pair here would read as an unfinished sentence and merge.
|
||||||
|
const content = [
|
||||||
|
...captionRowsHeader,
|
||||||
|
captionRow('0:00:10.00', '0:00:14.00', 640, 200, 'Shocking Statement Leaves'),
|
||||||
|
captionRow('0:00:10.00', '0:00:14.00', 640, 260, 'Listeners Speechless!'),
|
||||||
|
captionRow('0:01:00.00', '0:01:04.00', 400, 300, 'shes here AGAIN'),
|
||||||
|
captionRow('0:01:00.00', '0:01:04.00', 400, 360, 'make sakiko-chan go home'),
|
||||||
|
// Japanese typesetting in the same script is held back by the same gate.
|
||||||
|
captionRow('0:02:00.00', '0:02:04.00', 300, 400, '定休日'),
|
||||||
|
captionRow('0:02:00.00', '0:02:04.00', 300, 460, '毎週水曜日'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
[
|
||||||
|
'Shocking Statement Leaves',
|
||||||
|
'Listeners Speechless!',
|
||||||
|
'shes here AGAIN',
|
||||||
|
'make sakiko-chan go home',
|
||||||
|
'定休日',
|
||||||
|
'毎週水曜日',
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseSubtitleCues never joins caption rows that carry no Japanese', () => {
|
||||||
|
// Even inside a caption script, romaji or English rows are not the wrapped Japanese
|
||||||
|
// sentences this pass targets.
|
||||||
|
const content = [
|
||||||
|
...captionRowsHeader,
|
||||||
|
captionRow('0:00:10.00', '0:00:13.00', 172, 437, '(東)≪好きだと'),
|
||||||
|
captionRow('0:00:10.00', '0:00:13.00', 172, 497, '自覚してしまったものの➡'),
|
||||||
|
captionRow('0:00:20.00', '0:00:23.00', 172, 437, '(平)ん?'),
|
||||||
|
captionRow('0:00:30.00', '0:00:34.00', 640, 437, 'NOW LOADING'),
|
||||||
|
captionRow('0:00:30.00', '0:00:34.00', 640, 497, 'please wait'),
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
const cues = parseSubtitleCues(content, 'test.ass');
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
cues.map((cue) => cue.text),
|
||||||
|
['(東)≪好きだと\n自覚してしまったものの➡', '(平)ん?', 'NOW LOADING', 'please wait'],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
test('parseSubtitleCues scales furigana geometry by PlayResY', () => {
|
||||||
|
|||||||
@@ -2342,6 +2342,185 @@ function removeAssFuriganaEvents(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast-caption converters give every visual row of one utterance its own positioned
|
||||||
|
// event, so a sentence that wraps arrives as two simultaneous cues with the same timing,
|
||||||
|
// style, and vertical band. Captions punctuate every finished utterance, and each turn
|
||||||
|
// opens with a speaker label or a ≪…≫ / ⸨…⸩ span, which is what tells a wrapped sentence
|
||||||
|
// apart from two speakers sharing the screen.
|
||||||
|
const CAPTION_SPEAKER_LABEL_ONLY_PATTERN = /^([^()]*)$/u;
|
||||||
|
const CAPTION_SPEAKER_LABEL_PATTERN = /^(/u;
|
||||||
|
const CAPTION_TURN_OPENER_PATTERN = /^[≪⸨(]/u;
|
||||||
|
const CAPTION_TERMINAL_PATTERN = /[。?!?!…‥~〜➡⁉⁈≫⸩)」』]$/u;
|
||||||
|
const CAPTION_SPANS: ReadonlyArray<readonly [open: string, close: string]> = [
|
||||||
|
['≪', '≫'],
|
||||||
|
['⸨', '⸩'],
|
||||||
|
];
|
||||||
|
// Rows of one utterance sit one text row apart (about 60 units in the 540-line space the
|
||||||
|
// furigana geometry is tuned for), or two when a ruby row lies between them. Rows at the
|
||||||
|
// same height sit side by side, and rows further apart are separate placements.
|
||||||
|
const MAX_CAPTION_ROW_GAP = 120;
|
||||||
|
// Only a broadcast-caption script gets rows joined. Typesetters position rows for signs,
|
||||||
|
// chat bubbles, and lyric stacks too, and there the continuation rule below has no
|
||||||
|
// convention to read: sign text rarely carries sentence punctuation, so unrelated rows
|
||||||
|
// would run together. A caption script announces itself by labelling speakers (名) and
|
||||||
|
// bracketing off-screen speech in ≪…≫ / ⸨…⸩; typeset scripts use those in a handful of
|
||||||
|
// lines at most. Measured over local tracks, caption scripts sit near 25% and every typeset
|
||||||
|
// script below 1%, so the threshold has room on both sides. It is deliberately strict: a
|
||||||
|
// caption script wrongly held back just keeps one sentence on two rows, while a typeset
|
||||||
|
// script wrongly let through concatenates unrelated signs.
|
||||||
|
const MIN_CAPTION_EVIDENCE_EVENTS = 2;
|
||||||
|
const MIN_CAPTION_EVIDENCE_RATIO = 0.05;
|
||||||
|
const CAPTION_EVIDENCE_PATTERN = /^([^()]{1,14})|[≪⸨]/u;
|
||||||
|
// Rows that carry no Japanese are not the broadcast captions this pass targets.
|
||||||
|
const JAPANESE_SCRIPT_PATTERN = /[\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Han}]/u;
|
||||||
|
|
||||||
|
function hasBroadcastCaptionConventions(cues: readonly AnnotatedSubtitleCue[]): boolean {
|
||||||
|
let evidence = 0;
|
||||||
|
let published = 0;
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (!cue.text.trim()) continue;
|
||||||
|
published += 1;
|
||||||
|
if (CAPTION_EVIDENCE_PATTERN.test(cue.text)) evidence += 1;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
evidence >= MIN_CAPTION_EVIDENCE_EVENTS && evidence >= published * MIN_CAPTION_EVIDENCE_RATIO
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionSpanDepth(text: string, [open, close]: readonly [string, string]): number {
|
||||||
|
let depth = 0;
|
||||||
|
for (const char of text) {
|
||||||
|
if (char === open) depth += 1;
|
||||||
|
else if (char === close) depth -= 1;
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether `lower` continues the utterance `upper` started, both being simultaneous
|
||||||
|
* caption rows. A bare speaker label labels the row beneath it. Otherwise the upper row
|
||||||
|
* must not have finished: it ends without terminal punctuation, or a ≪…≫ / ⸨…⸩ span it
|
||||||
|
* opened is still open (closing 」 inside such a span is not an ending). A lower row that
|
||||||
|
* opens its own turn is always a different line.
|
||||||
|
*/
|
||||||
|
function isCaptionRowContinuation(upper: string, lower: string): boolean {
|
||||||
|
if (CAPTION_SPEAKER_LABEL_ONLY_PATTERN.test(upper)) {
|
||||||
|
return !CAPTION_SPEAKER_LABEL_PATTERN.test(lower);
|
||||||
|
}
|
||||||
|
if (CAPTION_TURN_OPENER_PATTERN.test(lower)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const spanContinues = CAPTION_SPANS.some(
|
||||||
|
(span) => captionSpanDepth(upper, span) > 0 || captionSpanDepth(lower, span) < 0,
|
||||||
|
);
|
||||||
|
return spanContinues || !CAPTION_TERMINAL_PATTERN.test(upper);
|
||||||
|
}
|
||||||
|
|
||||||
|
// A half-height row is ruby or a whispered aside, not a row of the utterance.
|
||||||
|
function isCaptionRowCandidate(cue: AnnotatedSubtitleCue): boolean {
|
||||||
|
const scaleY = staticAssScalePercent(cue, 'fscy');
|
||||||
|
return (
|
||||||
|
cue.source === undefined &&
|
||||||
|
cue.assLayout?.kind === 'positioned' &&
|
||||||
|
cue.effect.trim() === '' &&
|
||||||
|
!cue.text.includes('\n') &&
|
||||||
|
!hasAssTemporalOverride(cue.overrides) &&
|
||||||
|
(scaleY === null || scaleY > MAX_ASS_FURIGANA_SCALE_PERCENT) &&
|
||||||
|
JAPANESE_SCRIPT_PATTERN.test(cue.text)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function captionRowGroupKey(cue: AnnotatedSubtitleCue): string {
|
||||||
|
return [
|
||||||
|
cue.startTime,
|
||||||
|
cue.endTime,
|
||||||
|
cue.style,
|
||||||
|
cue.layer,
|
||||||
|
cue.name,
|
||||||
|
cue.assLayout?.verticalBand ?? '',
|
||||||
|
].join('\0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeCaptionRows(rows: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue {
|
||||||
|
const [first] = rows;
|
||||||
|
if (!first) throw new Error('mergeCaptionRows requires at least one row');
|
||||||
|
const overrides = rows.flatMap((row) => row.overrides);
|
||||||
|
const assFurigana = [...new Set(rows.flatMap((row) => row.assFurigana ?? []))];
|
||||||
|
return {
|
||||||
|
...first,
|
||||||
|
text: rows.map((row) => row.text).join('\n'),
|
||||||
|
rawText: rows.map((row) => row.rawText).join('\\N'),
|
||||||
|
overrides,
|
||||||
|
overrideSignature: assOverrideSignature(overrides),
|
||||||
|
...(assFurigana.length === 0 ? {} : { assFurigana }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join simultaneous caption rows that spell one utterance into a single cue, so the
|
||||||
|
* overlay can wrap or flatten it like an authored `\N` line and the sidebar and mining
|
||||||
|
* paths see the whole sentence. Rows stack top to bottom; each row joins the cue above it
|
||||||
|
* only while `isCaptionRowContinuation` holds, so a second speaker starts a new cue. The
|
||||||
|
* whole pass is skipped unless the script reads as broadcast captions.
|
||||||
|
*/
|
||||||
|
function mergeAssCaptionRows(
|
||||||
|
cues: AnnotatedSubtitleCue[],
|
||||||
|
playResY: number | null,
|
||||||
|
): AnnotatedSubtitleCue[] {
|
||||||
|
if (!hasBroadcastCaptionConventions(cues)) return cues;
|
||||||
|
|
||||||
|
const groups = new Map<string, AnnotatedSubtitleCue[]>();
|
||||||
|
for (const cue of cues) {
|
||||||
|
if (!isCaptionRowCandidate(cue)) continue;
|
||||||
|
const key = captionRowGroupKey(cue);
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) group.push(cue);
|
||||||
|
else groups.set(key, [cue]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxRowGap = MAX_CAPTION_ROW_GAP * assFuriganaGeometryScale(playResY);
|
||||||
|
const rowY = (cue: AnnotatedSubtitleCue): number =>
|
||||||
|
cue.assLayout?.kind === 'positioned' ? cue.assLayout.y : 0;
|
||||||
|
const replacements = new Map<AnnotatedSubtitleCue, AnnotatedSubtitleCue>();
|
||||||
|
const removed = new Set<AnnotatedSubtitleCue>();
|
||||||
|
for (const group of groups.values()) {
|
||||||
|
if (group.length < 2) continue;
|
||||||
|
const rows = [...group].sort((a, b) => rowY(a) - rowY(b) || a.order - b.order);
|
||||||
|
let run: AnnotatedSubtitleCue[] = [];
|
||||||
|
const flush = (): void => {
|
||||||
|
if (run.length < 2) return;
|
||||||
|
const anchor = run.reduce((lowest, row) => (row.order < lowest.order ? row : lowest));
|
||||||
|
replacements.set(anchor, mergeCaptionRows(run));
|
||||||
|
for (const row of run) {
|
||||||
|
if (row !== anchor) removed.add(row);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const row of rows) {
|
||||||
|
const previous = run.at(-1);
|
||||||
|
const gap = previous ? rowY(row) - rowY(previous) : 0;
|
||||||
|
if (
|
||||||
|
previous &&
|
||||||
|
gap > 0 &&
|
||||||
|
gap <= maxRowGap &&
|
||||||
|
previous.text !== row.text &&
|
||||||
|
isCaptionRowContinuation(previous.text, row.text)
|
||||||
|
) {
|
||||||
|
run.push(row);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
run = [row];
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacements.size === 0) return cues;
|
||||||
|
return cues.flatMap((cue) => {
|
||||||
|
if (removed.has(cue)) return [];
|
||||||
|
return [replacements.get(cue) ?? cue];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
function parseAnnotatedAssEvents(content: string, placement: AssPlacementContext): ParsedAssEvents {
|
||||||
const cues: AnnotatedSubtitleCue[] = [];
|
const cues: AnnotatedSubtitleCue[] = [];
|
||||||
const comments: AnnotatedSubtitleCue[] = [];
|
const comments: AnnotatedSubtitleCue[] = [];
|
||||||
@@ -2476,7 +2655,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
|
|||||||
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
|
||||||
placement.playResY,
|
placement.playResY,
|
||||||
);
|
);
|
||||||
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
|
return mergeAssCaptionRows(
|
||||||
|
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
|
||||||
|
placement.playResY,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseAssCues(content: string): SubtitleCue[] {
|
export function parseAssCues(content: string): SubtitleCue[] {
|
||||||
|
|||||||
@@ -674,3 +674,31 @@ test('resolvePrimarySubtitleText keeps source order when no cue declares a place
|
|||||||
'First line\n\nSecond line',
|
'First line\n\nSecond line',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('resolvePrimarySubtitleText publishes a wrapped caption sentence as one cue', () => {
|
||||||
|
// mpv still reports the two source rows (plus the ruby row) as separate live lines, so
|
||||||
|
// the merged cue must explain all of them and come back as a single-break line the
|
||||||
|
// display layer may flatten, not as a two-cue boundary.
|
||||||
|
const ass = [
|
||||||
|
'[Script Info]',
|
||||||
|
'PlayResY: 540',
|
||||||
|
'',
|
||||||
|
'[Events]',
|
||||||
|
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||||
|
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,437)\\fscx50}({\\fscx100}東{\\fscx50}){\\fscx100}≪好きだと',
|
||||||
|
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(292,443)\\fscx50\\fscy50}じかく',
|
||||||
|
'Dialogue: 0,0:02:42.33,0:02:44.43,Default,,0,0,0,,{\\pos(232,497)}自覚してしまったものの➡',
|
||||||
|
// A second labeled turn, so the script reads as broadcast captions.
|
||||||
|
'Dialogue: 0,0:02:44.43,0:02:47.37,Default,,0,0,0,,{\\pos(212,497)\\fscx50}({\\fscx100}平{\\fscx50}){\\fscx100}どうした?',
|
||||||
|
].join('\n');
|
||||||
|
const cues = parseSubtitleCues(ass, 'polar-opposites-s02e09.ass');
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
resolvePrimarySubtitleText({
|
||||||
|
liveText: '(東)≪好きだと\nじかく\n自覚してしまったものの➡',
|
||||||
|
currentTimeSec: 163,
|
||||||
|
cues,
|
||||||
|
}),
|
||||||
|
'(東)≪好きだと\n自覚してしまったものの➡',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user