mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-28 12:15:27 -07:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e356f53f
|
||
|
|
fa73aea2f9
|
||
|
|
f8ca8681dc
|
@@ -1,5 +0,0 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Subtitle lines that start while another line is still on screen now appear alongside it instead of staying hidden until a track switch or seek (#220).
|
||||
- Two subtitles shown at the same time now always render on separate lines, stacked by their authored screen position (top signs and song lines above bottom dialogue), so lines no longer merge into one sentence or swap rows mid-display.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: youtube
|
||||
|
||||
- YouTube auto-generated captions now follow their intended timing and two-row roll-up layout: long speech is paged instead of covering the video with a wall of text, while explicitly timed sound cues such as `[音楽]` no longer cover later dialogue.
|
||||
@@ -218,18 +218,3 @@ test('removeLiveGlyphFragmentLines leaves ordinary short lines alone', () => {
|
||||
const text = 'え\nはい。\nそうだな';
|
||||
assert.equal(removeLiveGlyphFragmentLines(text), text);
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText folds cue-boundary blank lines for text consumers', () => {
|
||||
// The display layer splits on the blank line before normalizing; everyone else --
|
||||
// tokenizer, cache key, dedup gate, mined sentence -- wants the plain line form.
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee'),
|
||||
'\u4e00\u884c\u76ee\n\u4e8c\u884c\u76ee',
|
||||
);
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee', {
|
||||
collapseLineBreaks: true,
|
||||
}),
|
||||
'\u4e00\u884c\u76ee \u4e8c\u884c\u76ee',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -153,10 +153,6 @@ export function normalizePlainSubtitleText(
|
||||
);
|
||||
if (collapseLineBreaks) {
|
||||
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||
} else {
|
||||
// Simultaneous cues reach the display layer separated by a blank line; every other
|
||||
// consumer wants the plain one-break-per-line form.
|
||||
normalized = normalized.replace(/\n{2,}/g, '\n');
|
||||
}
|
||||
|
||||
return trim ? normalized.trim() : normalized;
|
||||
|
||||
@@ -1927,7 +1927,9 @@ test('parseSubtitleCues does not double a line rendered whole beside its glyph s
|
||||
['わ', 1022],
|
||||
['ね', 1064],
|
||||
] as const;
|
||||
const wholeLine = glyphs.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`).join('');
|
||||
const wholeLine = glyphs
|
||||
.map(([glyph]) => `{\\an5\\fad(300,500)\\pos(960,50)}${glyph}`)
|
||||
.join('');
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
`Dialogue: 1,0:00:17.29,0:00:18.99,OP - JP,,0,0,0,,${wholeLine}`,
|
||||
@@ -1950,7 +1952,7 @@ test('parseSubtitleCues drops a wall of near-invisible positioned texture string
|
||||
// faint translation is one or two events and stays published.
|
||||
const content = [
|
||||
...eventsHeader,
|
||||
"Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We'll play as a band, and then...",
|
||||
'Dialogue: 90,0:00:12.66,0:00:14.91,Default,,0,0,0,,We\'ll play as a band, and then...',
|
||||
...Array.from(
|
||||
{ length: 12 },
|
||||
(_, index) =>
|
||||
@@ -1995,25 +1997,3 @@ test('parseSubtitleCues keeps hidden events hidden when a transform animates an
|
||||
['grows into view', 'wipes into view'],
|
||||
);
|
||||
});
|
||||
|
||||
test('parseAssCues records the vertical band from style alignment, overrides, and \\pos', () => {
|
||||
const ass = [
|
||||
'[Script Info]',
|
||||
'PlayResY: 720',
|
||||
'',
|
||||
'[V4+ Styles]',
|
||||
'Format: Name, Fontname, Fontsize, PrimaryColour, Bold, Alignment, MarginV, Encoding',
|
||||
'Style: Bottom,Arial,54,&H00FFFFFF,0,2,30,1',
|
||||
'Style: TopSong,Arial,54,&H00FFFFFF,0,9,12,1',
|
||||
'',
|
||||
'[Events]',
|
||||
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,\u4e0b\u306e\u30bb\u30ea\u30d5',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,TopSong,,0,0,0,,\u6b4c\u8a5e\u306e\u884c',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\an8}\u4e0a\u66f8\u304d\u306e\u884c',
|
||||
'Dialogue: 0,0:00:01.00,0:00:03.00,Bottom,,0,0,0,,{\\pos(640,20)}\u770b\u677f\u306e\u884c',
|
||||
].join('\n');
|
||||
|
||||
const bands = parseAssCues(ass).map((cue) => cue.assLayout?.verticalBand);
|
||||
assert.deepEqual(bands, ['bottom', 'top', 'top', 'top']);
|
||||
});
|
||||
|
||||
@@ -10,13 +10,10 @@ import {
|
||||
} from './ass-text';
|
||||
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
|
||||
|
||||
/** Vertical third of the screen a cue is authored to occupy. */
|
||||
export type AssVerticalBand = 'top' | 'middle' | 'bottom';
|
||||
|
||||
export type AssCueLayout =
|
||||
| { kind: 'positioned'; sourceOrder: number; y: number; verticalBand?: AssVerticalBand }
|
||||
| { kind: 'fragment-grid'; sourceOrder: number; verticalBand?: AssVerticalBand }
|
||||
| { kind: 'source-order'; sourceOrder: number; verticalBand?: AssVerticalBand };
|
||||
| { kind: 'positioned'; sourceOrder: number; y: number }
|
||||
| { kind: 'fragment-grid'; sourceOrder: number }
|
||||
| { kind: 'source-order'; sourceOrder: number };
|
||||
|
||||
export interface SubtitleCue {
|
||||
startTime: number;
|
||||
@@ -484,7 +481,9 @@ function structuralOverrideSignature(cue: AnnotatedSubtitleCue): string {
|
||||
let signature = structuralSignatureCache.get(cue);
|
||||
if (signature === undefined) {
|
||||
const names = new Set(
|
||||
cue.overrides.map((command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`),
|
||||
cue.overrides.map(
|
||||
(command) => `${command.animated ? '~' : ''}${command.name.toLowerCase()}`,
|
||||
),
|
||||
);
|
||||
signature = [...names].sort().join(',');
|
||||
structuralSignatureCache.set(cue, signature);
|
||||
@@ -545,7 +544,9 @@ function buildCoalescedCopy(members: readonly AnnotatedSubtitleCue[]): Annotated
|
||||
* while the anchor says one glyph. Merging each stack into a single presence spanning
|
||||
* the union window lets timing clusters see the authored line instead of its phases.
|
||||
*/
|
||||
function coalesceAssAnchorCopies(events: readonly AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
|
||||
function coalesceAssAnchorCopies(
|
||||
events: readonly AnnotatedSubtitleCue[],
|
||||
): AnnotatedSubtitleCue[] {
|
||||
const buckets = new Map<string, number[]>();
|
||||
const anchorPoints: (AssFragmentPosition[] | null)[] = events.map(() => null);
|
||||
events.forEach((event, index) => {
|
||||
@@ -1361,7 +1362,8 @@ function isRepeatedGlyphText(cue: AnnotatedSubtitleCue): boolean {
|
||||
|
||||
function isClippedRepeatedGlyphFragment(cue: AnnotatedSubtitleCue): boolean {
|
||||
return (
|
||||
isRepeatedGlyphText(cue) && (hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||
isRepeatedGlyphText(cue) &&
|
||||
(hasStaticOverride(cue, 'clip') || hasStaticOverride(cue, 'iclip'))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2053,111 +2055,6 @@ function recoverCanonicalAssEvents({
|
||||
);
|
||||
}
|
||||
|
||||
function bandFromNumpadAlignment(alignment: number): AssVerticalBand | null {
|
||||
if (alignment >= 7 && alignment <= 9) return 'top';
|
||||
if (alignment >= 4 && alignment <= 6) return 'middle';
|
||||
if (alignment >= 1 && alignment <= 3) return 'bottom';
|
||||
return null;
|
||||
}
|
||||
|
||||
// SSA v4 alignment reuses the legacy `\a` codes: 1-3 bottom, +4 top, +8 middle.
|
||||
function bandFromLegacyAlignment(alignment: number): AssVerticalBand | null {
|
||||
if (alignment >= 9 && alignment <= 11) return 'middle';
|
||||
if (alignment >= 5 && alignment <= 7) return 'top';
|
||||
if (alignment >= 1 && alignment <= 3) return 'bottom';
|
||||
return null;
|
||||
}
|
||||
|
||||
interface AssPlacementContext {
|
||||
playResY: number | null;
|
||||
/** Lowercased style name -> vertical band from the style's Alignment column. */
|
||||
styleBands: Map<string, AssVerticalBand>;
|
||||
}
|
||||
|
||||
const EMPTY_PLACEMENT_CONTEXT: AssPlacementContext = { playResY: null, styleBands: new Map() };
|
||||
|
||||
function parseAssPlacementContext(content: string): AssPlacementContext {
|
||||
const styleBands = new Map<string, AssVerticalBand>();
|
||||
let playResY: number | null = null;
|
||||
let section: 'info' | 'v4plus' | 'v4' | null = null;
|
||||
let alignmentIndex = -1;
|
||||
let nameIndex = -1;
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
|
||||
const sectionName = trimmed.toLowerCase();
|
||||
section =
|
||||
sectionName === '[script info]'
|
||||
? 'info'
|
||||
: sectionName === '[v4+ styles]'
|
||||
? 'v4plus'
|
||||
: sectionName === '[v4 styles]'
|
||||
? 'v4'
|
||||
: null;
|
||||
alignmentIndex = -1;
|
||||
nameIndex = -1;
|
||||
continue;
|
||||
}
|
||||
if (section === 'info') {
|
||||
const resMatch = trimmed.match(/^playresy\s*:\s*(\d+(?:\.\d+)?)\s*$/i);
|
||||
if (resMatch) playResY = Number(resMatch[1]);
|
||||
continue;
|
||||
}
|
||||
if (section !== 'v4plus' && section !== 'v4') continue;
|
||||
const separator = trimmed.indexOf(':');
|
||||
if (separator < 0) continue;
|
||||
const key = trimmed.slice(0, separator).trim().toLowerCase();
|
||||
const fields = trimmed.slice(separator + 1).split(',');
|
||||
if (key === 'format') {
|
||||
const names = fields.map((field) => field.trim().toLowerCase());
|
||||
alignmentIndex = names.indexOf('alignment');
|
||||
nameIndex = names.indexOf('name');
|
||||
continue;
|
||||
}
|
||||
if (key !== 'style' || alignmentIndex < 0 || nameIndex < 0) continue;
|
||||
const styleName = fields[nameIndex]?.trim().toLowerCase();
|
||||
const alignment = Number(fields[alignmentIndex]?.trim());
|
||||
if (!styleName || !Number.isFinite(alignment)) continue;
|
||||
const band =
|
||||
section === 'v4plus'
|
||||
? bandFromNumpadAlignment(alignment)
|
||||
: bandFromLegacyAlignment(alignment);
|
||||
if (band) styleBands.set(styleName, band);
|
||||
}
|
||||
|
||||
return { playResY, styleBands };
|
||||
}
|
||||
|
||||
/**
|
||||
* Where on screen mpv will draw this event: an explicit `\pos`/`\move` coordinate when
|
||||
* the script declares its coordinate space, else an `\an`/`\a` override, else the
|
||||
* style's Alignment. Constant for the life of the event, which is what lets simultaneous
|
||||
* lines keep a stable stacking order in the overlay.
|
||||
*/
|
||||
function resolveVerticalBand(
|
||||
overrides: readonly AssOverrideCommand[],
|
||||
y: number | null,
|
||||
style: string,
|
||||
context: AssPlacementContext,
|
||||
): AssVerticalBand | undefined {
|
||||
if (y !== null && context.playResY && context.playResY > 0) {
|
||||
const ratio = y / context.playResY;
|
||||
return ratio < 1 / 3 ? 'top' : ratio < 2 / 3 ? 'middle' : 'bottom';
|
||||
}
|
||||
for (const command of overrides) {
|
||||
if (command.animated) continue;
|
||||
const name = command.name.toLowerCase();
|
||||
if (name !== 'an' && name !== 'a') continue;
|
||||
const band =
|
||||
name === 'an'
|
||||
? bandFromNumpadAlignment(Number(command.args))
|
||||
: bandFromLegacyAlignment(Number(command.args));
|
||||
if (band) return band;
|
||||
}
|
||||
return context.styleBands.get(style.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseAssCoordinate(value: string | undefined): number | null {
|
||||
if (!value?.trim()) return null;
|
||||
const coordinate = Number(value.trim());
|
||||
@@ -2167,8 +2064,6 @@ function parseAssCoordinate(value: string | undefined): number | null {
|
||||
function buildAssCueLayout(
|
||||
overrides: readonly AssOverrideCommand[],
|
||||
sourceOrder: number,
|
||||
style: string,
|
||||
placement: AssPlacementContext,
|
||||
): AssCueLayout {
|
||||
let y: number | null = null;
|
||||
for (const command of overrides) {
|
||||
@@ -2186,18 +2081,14 @@ function buildAssCueLayout(
|
||||
y = (startY + endY) / 2;
|
||||
}
|
||||
}
|
||||
const verticalBand = resolveVerticalBand(overrides, y, style, placement);
|
||||
const base: AssCueLayout =
|
||||
y === null ? { kind: 'source-order', sourceOrder } : { kind: 'positioned', sourceOrder, y };
|
||||
return verticalBand ? { ...base, verticalBand } : base;
|
||||
return y === null
|
||||
? { kind: 'source-order', sourceOrder }
|
||||
: { kind: 'positioned', sourceOrder, y };
|
||||
}
|
||||
|
||||
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const cues: AnnotatedSubtitleCue[] = [];
|
||||
const comments: AnnotatedSubtitleCue[] = [];
|
||||
const placement = content.includes('[')
|
||||
? parseAssPlacementContext(content)
|
||||
: EMPTY_PLACEMENT_CONTEXT;
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inEventsSection = false;
|
||||
let eventOrder = 0;
|
||||
@@ -2294,13 +2185,12 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
const effect = readField(fields, fieldIndex.effect);
|
||||
const layer = Number(readField(fields, fieldIndex.layer));
|
||||
const overrides = collectAssOverrideCommands(rawText);
|
||||
const style = readField(fields, fieldIndex.style);
|
||||
const cue: AnnotatedSubtitleCue = {
|
||||
startTime,
|
||||
endTime,
|
||||
text,
|
||||
rawText,
|
||||
style,
|
||||
style: readField(fields, fieldIndex.style),
|
||||
layer: Number.isFinite(layer) ? layer : 0,
|
||||
name: readField(fields, fieldIndex.name),
|
||||
effect,
|
||||
@@ -2308,7 +2198,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
|
||||
overrides,
|
||||
overrideSignature: assOverrideSignature(overrides),
|
||||
order: eventOrder,
|
||||
assLayout: buildAssCueLayout(overrides, eventOrder, style, placement),
|
||||
assLayout: buildAssCueLayout(overrides, eventOrder),
|
||||
};
|
||||
eventOrder += 1;
|
||||
if (eventPrefix === ASS_COMMENT_PREFIX) {
|
||||
|
||||
@@ -134,7 +134,7 @@ export function createSubtitleProcessingController(
|
||||
try {
|
||||
const cachedTokenized = getCachedTokenization(text);
|
||||
if (cachedTokenized) {
|
||||
output = { ...cachedTokenized, text };
|
||||
output = cachedTokenized;
|
||||
} else {
|
||||
// Cache miss: show the plain line on time; the tokenized payload
|
||||
// upgrades it once ready. Skipped on refreshes of an already
|
||||
@@ -266,7 +266,7 @@ export function createSubtitleProcessingController(
|
||||
lastEmittedText = text;
|
||||
lastEmittedGeneration = cacheGeneration;
|
||||
lastPlainEmittedText = null;
|
||||
return { ...cached, text };
|
||||
return cached;
|
||||
},
|
||||
hasCachedSubtitle: (text: string) => {
|
||||
const cacheKey = normalizeSubtitleCacheKey(text);
|
||||
|
||||
@@ -84,17 +84,6 @@ function createDeferred<T>() {
|
||||
};
|
||||
}
|
||||
|
||||
test('tokenizeSubtitle keeps the blank line separating simultaneous cues', async () => {
|
||||
// The tokenized payload's text drives display; folding the cue boundary would merge
|
||||
// two speakers back onto one line the moment tokenization upgrades the plain emit.
|
||||
const result = await tokenizeSubtitle(
|
||||
'\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee',
|
||||
makeDeps({ getYomitanExt: () => null }),
|
||||
);
|
||||
|
||||
assert.equal(result.text, '\u4e00\u884c\u76ee\n\n\u4e8c\u884c\u76ee');
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle splits same-line grammar endings before applying annotations', async () => {
|
||||
const result = await tokenizeSubtitle(
|
||||
'猫です',
|
||||
@@ -1693,12 +1682,6 @@ test('tokenizeSubtitle normalizes newlines before Yomitan parse request', async
|
||||
assert.equal(result.tokens, null);
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle preserves CRLF boundaries between simultaneous cues', async () => {
|
||||
const result = await tokenizeSubtitle('a\r\n\r\nb', makeDeps());
|
||||
|
||||
assert.deepEqual(result, { text: 'a\n\nb', tokens: null });
|
||||
});
|
||||
|
||||
test('tokenizeSubtitle collapses zero-width separators before Yomitan parse request', async () => {
|
||||
let parseInput = '';
|
||||
const result = await tokenizeSubtitle(
|
||||
|
||||
@@ -887,15 +887,7 @@ export async function tokenizeSubtitle(
|
||||
text: string,
|
||||
deps: TokenizerServiceDeps,
|
||||
): Promise<SubtitleData> {
|
||||
// Normalize per cue group: the blank line separating simultaneous cues is display
|
||||
// structure the payload text must keep, or the tokenized upgrade re-merges lines the
|
||||
// provisional plain emit already showed apart.
|
||||
const displayText = text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((part) => normalizePlainSubtitleText(part))
|
||||
.filter(Boolean)
|
||||
.join('\n\n');
|
||||
const displayText = normalizePlainSubtitleText(text);
|
||||
|
||||
// ASS decoding already happened upstream (cue parser for files, mpv for live text), so
|
||||
// all this drops is whitespace -- but a whitespace-only line still normalizes to empty.
|
||||
|
||||
@@ -39,6 +39,118 @@ test('convertYoutubeTimedTextToVtt does not swallow text after zero-length overl
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt extends rolling captions to the next window event', () => {
|
||||
// Real-world shape of YouTube's sentence-level auto captions: window-append
|
||||
// filler rows (a="1", sometimes without d) mark the display timeline, while
|
||||
// long text rows carry a placeholder d="3000" far shorter than the speech.
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="98550" d="3010" w="1" a="1">\n</p>',
|
||||
'<p t="98560" d="3000" w="1"><s ac="0">ありがとうって言えないよね。こんなんじゃ。</s></p>',
|
||||
'<p t="106950" w="1" a="1">\n</p>',
|
||||
'<p t="106960" d="3799" w="1"><s ac="0">私だったら無理だよ。</s></p>',
|
||||
'</body></timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:01:38.560 --> 00:01:46.950',
|
||||
'ありがとうって言えないよね。こんなんじゃ。',
|
||||
'',
|
||||
'00:01:46.960 --> 00:01:50.759',
|
||||
'私だったら無理だよ。',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt pages oversized two-row rolling captions', () => {
|
||||
const text =
|
||||
'あの西に結構こう山田がスーパーアプローチしてるんだけど西気づかないからちょっとこっちも気づかない感じでこう接してあげようかなて思ってんだけどあの唇巻き込んじゃうしあの思ってることも全部縁に出ちゃって自分であちゃったって言っちゃうタイプなんで結構なんかこうドライなんだけどそこがおもろいよねみたいな';
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext format="3">',
|
||||
'<head>',
|
||||
'<ws id="1" mh="2" ju="0" sd="3"/>',
|
||||
'<wp id="1" ap="6" ah="20" av="100" rc="2" cc="40"/>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<w t="0" id="1" wp="1" ws="1"/>',
|
||||
`<p t="60440" d="3000" w="1"><s ac="0">${text}</s></p>`,
|
||||
'<p t="72695" w="1" a="1">\n</p>',
|
||||
'</body>',
|
||||
'</timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
const cues = result
|
||||
.trim()
|
||||
.split(/\n\n/)
|
||||
.filter((block) => block.includes('-->'));
|
||||
const cueText = cues.map((cue) => cue.split('\n').slice(1).join('\n'));
|
||||
|
||||
assert.equal(cues.length, 2);
|
||||
assert.deepEqual(
|
||||
cues.map((cue) => cue.split('\n')[0]),
|
||||
['00:01:00.440 --> 00:01:07.064', '00:01:07.064 --> 00:01:12.695'],
|
||||
);
|
||||
assert.ok(cueText.every((page) => [...page].length <= 80));
|
||||
assert.equal(cueText.join(''), text);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt leaves pop-on captions intact', () => {
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext format="3">',
|
||||
'<head>',
|
||||
'<ws id="1" mh="0"/>',
|
||||
'<wp id="1" rc="2" cc="4"/>',
|
||||
'</head>',
|
||||
'<body>',
|
||||
'<w t="0" id="1" wp="1" ws="1"/>',
|
||||
'<p t="1000" d="3000" w="1">abcdefghijklmnopqrst</p>',
|
||||
'</body>',
|
||||
'</timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
['WEBVTT', '', '00:00:01.000 --> 00:00:04.000', 'abcdefghijklmnopqrst', ''].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('convertYoutubeTimedTextToVtt keeps explicit 3000ms sound-cue durations in rolling documents', () => {
|
||||
const result = convertYoutubeTimedTextToVtt(
|
||||
[
|
||||
'<timedtext><body>',
|
||||
'<p t="20305" d="3000" w="1">[音楽]</p>',
|
||||
'<p t="26269" w="1" a="1">\n</p>',
|
||||
'<p t="26279" d="3000" w="1"><s ac="0">じゃあ、君からお願いします。</s></p>',
|
||||
'</body></timedtext>',
|
||||
].join('\n'),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
result,
|
||||
[
|
||||
'WEBVTT',
|
||||
'',
|
||||
'00:00:20.305 --> 00:00:23.305',
|
||||
'[音楽]',
|
||||
'',
|
||||
'00:00:26.279 --> 00:00:29.279',
|
||||
'じゃあ、君からお願いします。',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeYoutubeAutoVtt strips cumulative rolling-caption prefixes', () => {
|
||||
const result = normalizeYoutubeAutoVtt(
|
||||
[
|
||||
|
||||
@@ -2,9 +2,31 @@ interface YoutubeTimedTextRow {
|
||||
startMs: number;
|
||||
durationMs: number;
|
||||
text: string;
|
||||
isGenerated: boolean;
|
||||
rollingWindow: YoutubeRollingWindow | null;
|
||||
}
|
||||
|
||||
interface YoutubeRollingWindow {
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
}
|
||||
|
||||
interface YoutubeTimedTextWindowDefinitions {
|
||||
rollingStyleIds: Set<string>;
|
||||
positions: Map<string, YoutubeRollingWindow>;
|
||||
windows: Map<string, YoutubeRollingWindow>;
|
||||
}
|
||||
|
||||
interface YoutubeTimedTextDocument {
|
||||
rows: YoutubeTimedTextRow[];
|
||||
// Start times of every <p> event, including empty window-append fillers.
|
||||
// Rolling speech rows with a 3000ms placeholder display until the next event.
|
||||
eventStartsMs: number[];
|
||||
hasRollingWindowEvents: boolean;
|
||||
}
|
||||
|
||||
const YOUTUBE_TIMEDTEXT_EXTENSIONS = new Set(['srv1', 'srv2', 'srv3', 'ytsrv3']);
|
||||
const YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS = 3_000;
|
||||
|
||||
function decodeNumericEntity(match: string, codePoint: number): string {
|
||||
if (
|
||||
@@ -39,27 +61,129 @@ function parseAttributeMap(raw: string): Map<string, string> {
|
||||
return attrs;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextRows(xml: string): YoutubeTimedTextRow[] {
|
||||
function parsePositiveInteger(value: string | undefined): number | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : null;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextWindowDefinitions(xml: string): YoutubeTimedTextWindowDefinitions {
|
||||
const rollingStyleIds = new Set<string>();
|
||||
for (const match of xml.matchAll(/<ws\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const id = attrs.get('id');
|
||||
if (id !== undefined && attrs.get('mh') === '2') {
|
||||
rollingStyleIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
const positions = new Map<string, YoutubeRollingWindow>();
|
||||
for (const match of xml.matchAll(/<wp\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const id = attrs.get('id');
|
||||
const rowCount = parsePositiveInteger(attrs.get('rc'));
|
||||
const columnCount = parsePositiveInteger(attrs.get('cc'));
|
||||
if (id !== undefined && rowCount !== null && columnCount !== null) {
|
||||
positions.set(id, { rowCount, columnCount });
|
||||
}
|
||||
}
|
||||
|
||||
const windows = new Map<string, YoutubeRollingWindow>();
|
||||
for (const match of xml.matchAll(/<w\b([^>]*)\/?\s*>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const id = attrs.get('id');
|
||||
const styleId = attrs.get('ws');
|
||||
const positionId = attrs.get('wp');
|
||||
const position = positionId === undefined ? undefined : positions.get(positionId);
|
||||
if (
|
||||
id !== undefined &&
|
||||
styleId !== undefined &&
|
||||
rollingStyleIds.has(styleId) &&
|
||||
position !== undefined
|
||||
) {
|
||||
windows.set(id, position);
|
||||
}
|
||||
}
|
||||
|
||||
return { rollingStyleIds, positions, windows };
|
||||
}
|
||||
|
||||
function resolveRollingWindow(
|
||||
attrs: Map<string, string>,
|
||||
definitions: YoutubeTimedTextWindowDefinitions,
|
||||
): YoutubeRollingWindow | null {
|
||||
const windowId = attrs.get('w');
|
||||
if (windowId !== undefined) {
|
||||
return definitions.windows.get(windowId) ?? null;
|
||||
}
|
||||
|
||||
const styleId = attrs.get('ws');
|
||||
const positionId = attrs.get('wp');
|
||||
if (
|
||||
styleId === undefined ||
|
||||
positionId === undefined ||
|
||||
!definitions.rollingStyleIds.has(styleId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return definitions.positions.get(positionId) ?? null;
|
||||
}
|
||||
|
||||
function extractYoutubeTimedTextDocument(xml: string): YoutubeTimedTextDocument {
|
||||
const rows: YoutubeTimedTextRow[] = [];
|
||||
const eventStartsMs: number[] = [];
|
||||
let hasRollingWindowEvents = false;
|
||||
const windowDefinitions = extractYoutubeTimedTextWindowDefinitions(xml);
|
||||
|
||||
for (const match of xml.matchAll(/<p\b([^>]*)>([\s\S]*?)<\/p>/g)) {
|
||||
const attrs = parseAttributeMap(match[1] ?? '');
|
||||
const startMs = Number(attrs.get('t'));
|
||||
if (!Number.isFinite(startMs)) {
|
||||
continue;
|
||||
}
|
||||
eventStartsMs.push(startMs);
|
||||
if (attrs.get('a') === '1') {
|
||||
hasRollingWindowEvents = true;
|
||||
}
|
||||
|
||||
const durationMs = Number(attrs.get('d'));
|
||||
if (!Number.isFinite(startMs) || !Number.isFinite(durationMs)) {
|
||||
if (!Number.isFinite(durationMs)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const inner = (match[2] ?? '').replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
const rawInner = match[2] ?? '';
|
||||
const inner = rawInner.replace(/<br\s*\/?>/gi, '\n').replace(/<[^>]+>/g, '');
|
||||
const text = decodeHtmlEntities(inner).trim();
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rows.push({ startMs, durationMs, text });
|
||||
rows.push({
|
||||
startMs,
|
||||
durationMs,
|
||||
text,
|
||||
isGenerated: /<s\b/.test(rawInner),
|
||||
rollingWindow: resolveRollingWindow(attrs, windowDefinitions),
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
eventStartsMs.sort((a, b) => a - b);
|
||||
return { rows, eventStartsMs, hasRollingWindowEvents };
|
||||
}
|
||||
|
||||
function findNextEventStartMs(eventStartsMs: number[], afterMs: number): number | undefined {
|
||||
for (const startMs of eventStartsMs) {
|
||||
if (startMs > afterMs) {
|
||||
return startMs;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isGeneratedRollingCue(row: YoutubeTimedTextRow, hasRollingWindowEvents: boolean): boolean {
|
||||
return row.isGenerated && (row.rollingWindow !== null || hasRollingWindowEvents);
|
||||
}
|
||||
|
||||
function formatVttTimestamp(ms: number): string {
|
||||
@@ -71,6 +195,79 @@ function formatVttTimestamp(ms: number): string {
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}.${String(millis).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
const ROLLING_PAGE_BREAK_PATTERN = /[\s、。!?!?]/u;
|
||||
|
||||
// VTT cannot carry SRV3's row and column limits. Page only roll-up windows so
|
||||
// the overlay keeps their bounded presentation without changing authored cues.
|
||||
function splitRollingCaptionIntoPages(text: string, rollingWindow: YoutubeRollingWindow): string[] {
|
||||
const pageCapacity = rollingWindow.rowCount * rollingWindow.columnCount;
|
||||
const characters = [...text];
|
||||
if (
|
||||
!Number.isSafeInteger(pageCapacity) ||
|
||||
pageCapacity <= 0 ||
|
||||
characters.length <= pageCapacity
|
||||
) {
|
||||
return [text];
|
||||
}
|
||||
|
||||
const pages: string[] = [];
|
||||
let pageStart = 0;
|
||||
while (pageStart < characters.length) {
|
||||
let pageEnd = Math.min(pageStart + pageCapacity, characters.length);
|
||||
if (pageEnd < characters.length) {
|
||||
const earliestNaturalBreak = pageStart + Math.ceil(pageCapacity * 0.6);
|
||||
for (let index = pageEnd - 1; index >= earliestNaturalBreak; index -= 1) {
|
||||
if (ROLLING_PAGE_BREAK_PATTERN.test(characters[index]!)) {
|
||||
pageEnd = index + 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
pages.push(characters.slice(pageStart, pageEnd).join(''));
|
||||
pageStart = pageEnd;
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
interface TimedCaptionPage {
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
function timeCaptionPages(input: {
|
||||
text: string;
|
||||
pages: string[];
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
}): TimedCaptionPage[] {
|
||||
const durationMs = input.endMs - input.startMs;
|
||||
if (input.pages.length === 1 || durationMs < input.pages.length) {
|
||||
return [{ startMs: input.startMs, endMs: input.endMs, text: input.text }];
|
||||
}
|
||||
|
||||
const totalCharacters = [...input.text].length;
|
||||
const timedPages: TimedCaptionPage[] = [];
|
||||
let consumedCharacters = 0;
|
||||
let pageStartMs = input.startMs;
|
||||
// Automatic captions often omit span offsets, so distribute the known cue
|
||||
// duration by page length while guaranteeing every page at least one ms.
|
||||
for (let index = 0; index < input.pages.length; index += 1) {
|
||||
const page = input.pages[index]!;
|
||||
consumedCharacters += [...page].length;
|
||||
const remainingPages = input.pages.length - index - 1;
|
||||
const proportionalEndMs =
|
||||
input.startMs + Math.round((durationMs * consumedCharacters) / totalCharacters);
|
||||
const pageEndMs =
|
||||
remainingPages === 0
|
||||
? input.endMs
|
||||
: Math.min(Math.max(proportionalEndMs, pageStartMs + 1), input.endMs - remainingPages);
|
||||
timedPages.push({ startMs: pageStartMs, endMs: pageEndMs, text: page });
|
||||
pageStartMs = pageEndMs;
|
||||
}
|
||||
return timedPages;
|
||||
}
|
||||
|
||||
export function isYoutubeTimedTextExtension(value: string | undefined): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
@@ -79,7 +276,7 @@ export function isYoutubeTimedTextExtension(value: string | undefined): boolean
|
||||
}
|
||||
|
||||
export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
const rows = extractYoutubeTimedTextRows(xml);
|
||||
const { rows, eventStartsMs, hasRollingWindowEvents } = extractYoutubeTimedTextDocument(xml);
|
||||
if (rows.length === 0) {
|
||||
return 'WEBVTT\n';
|
||||
}
|
||||
@@ -90,10 +287,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
const row = rows[index]!;
|
||||
const nextRow = rows[index + 1];
|
||||
const unclampedEnd = row.startMs + row.durationMs;
|
||||
// YouTube uses exactly 3000ms as a placeholder for generated rolling speech.
|
||||
// Plain-text cues can explicitly use the same duration and must keep it.
|
||||
const nextEventStart =
|
||||
isGeneratedRollingCue(row, hasRollingWindowEvents) &&
|
||||
row.durationMs === YOUTUBE_ROLLING_PLACEHOLDER_DURATION_MS
|
||||
? findNextEventStartMs(eventStartsMs, row.startMs)
|
||||
: undefined;
|
||||
const clampedEnd =
|
||||
nextRow && unclampedEnd > nextRow.startMs
|
||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||
: unclampedEnd;
|
||||
nextEventStart !== undefined
|
||||
? nextEventStart
|
||||
: nextRow && unclampedEnd > nextRow.startMs
|
||||
? Math.max(row.startMs, nextRow.startMs - 1)
|
||||
: unclampedEnd;
|
||||
if (clampedEnd <= row.startMs) {
|
||||
continue;
|
||||
}
|
||||
@@ -106,9 +312,19 @@ export function convertYoutubeTimedTextToVtt(xml: string): string {
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
blocks.push(
|
||||
`${formatVttTimestamp(row.startMs)} --> ${formatVttTimestamp(clampedEnd)}\n${text}`,
|
||||
);
|
||||
const pages = row.rollingWindow
|
||||
? splitRollingCaptionIntoPages(text, row.rollingWindow)
|
||||
: [text];
|
||||
for (const page of timeCaptionPages({
|
||||
text,
|
||||
pages,
|
||||
startMs: row.startMs,
|
||||
endMs: clampedEnd,
|
||||
})) {
|
||||
blocks.push(
|
||||
`${formatVttTimestamp(page.startMs)} --> ${formatVttTimestamp(page.endMs)}\n${page.text}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return `WEBVTT\n\n${blocks.join('\n\n')}\n`;
|
||||
|
||||
@@ -60,7 +60,7 @@ test('resolvePrimarySubtitleText combines unique simultaneous parsed cues', () =
|
||||
{ startTime: 1, endTime: 3, text: '二行目' },
|
||||
],
|
||||
}),
|
||||
'一行目\n\n二行目',
|
||||
'一行目\n二行目',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@ test('resolvePrimarySubtitleText combines parsed dialogue with a reconstructed l
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, '普通のセリフ\n\n今 手にある');
|
||||
assert.equal(text, '普通のセリフ\n今 手にある');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText uses fragment grids only to account for live sign pieces', () => {
|
||||
@@ -300,32 +300,7 @@ test('resolvePrimarySubtitleText combines simultaneous canonical cues in source
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(text, 'first\n\nsecond');
|
||||
});
|
||||
|
||||
test('resolveCanonicalPrimarySubtitle orders active cues from top to bottom', () => {
|
||||
const resolved = resolveCanonicalPrimarySubtitle({
|
||||
liveText: 'bottom\ntop',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: 'bottom',
|
||||
source: 'canonical-ass',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' },
|
||||
},
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: 'top',
|
||||
source: 'canonical-ass',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(resolved?.text, 'top\n\nbottom');
|
||||
assert.equal(text, 'first\nsecond');
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText collapses whitespace variants of a canonical lyric', () => {
|
||||
@@ -523,23 +498,6 @@ test('stripCanonicalFragmentLines drops a live glyph wall with no nearby canonic
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps a line joining an active cue despite stale time-pos', () => {
|
||||
// Issue #220: mpv publishes the combined sub-text the moment a joining line's first
|
||||
// frame renders, while the observed time-pos still sits just before that line's
|
||||
// start. The joining cue must not be filtered out as inactive.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: 'Балда! Балда, балда, балда!\nСестренка не может остановиться',
|
||||
currentTimeSec: 767.78,
|
||||
cues: [
|
||||
{ startTime: 767.19, endTime: 772.78, text: 'Балда! Балда, балда, балда!' },
|
||||
{ startTime: 767.79, endTime: 771.15, text: 'Сестренка не может остановиться' },
|
||||
],
|
||||
}),
|
||||
'Балда! Балда, балда, балда!\n\nСестренка не может остановиться',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outlive it beside a raw line', () => {
|
||||
// The reconstructed lyric ended at 6.0 but its exit ghost glyphs stay in the live
|
||||
// text until 7.0, while the next authored line is a plain raw event. The retired cue
|
||||
@@ -566,85 +524,3 @@ test('resolvePrimarySubtitleText drops a finished lyric whose exit ghosts outliv
|
||||
'象徴的なパレード',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText stacks simultaneous cues by screen position, not start order', () => {
|
||||
// A top-anchored lyric and bottom dialogue: mpv draws the lyric above the dialogue for
|
||||
// the whole overlap. Whichever event started first must not decide the row, or the
|
||||
// pair swaps every time one side is replaced mid-overlap.
|
||||
const lyricLayout = { kind: 'source-order', sourceOrder: 0, verticalBand: 'top' } as const;
|
||||
const dialogueLayout = { kind: 'source-order', sourceOrder: 1, verticalBand: 'bottom' } as const;
|
||||
const dialogue = {
|
||||
startTime: 632.2,
|
||||
endTime: 634.8,
|
||||
text: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
||||
assLayout: dialogueLayout,
|
||||
};
|
||||
|
||||
// Lyric started before the dialogue...
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff21',
|
||||
currentTimeSec: 632.5,
|
||||
cues: [
|
||||
{ startTime: 629.5, endTime: 633.5, text: '\u6b4c\u8a5e\uff21', assLayout: lyricLayout },
|
||||
dialogue,
|
||||
],
|
||||
}),
|
||||
'\u6b4c\u8a5e\uff21\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
||||
);
|
||||
// ...and the next lyric starts after it: the rows must not swap.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b\n\u6b4c\u8a5e\uff22',
|
||||
currentTimeSec: 633.8,
|
||||
cues: [
|
||||
dialogue,
|
||||
{ startTime: 633.5, endTime: 637.0, text: '\u6b4c\u8a5e\uff22', assLayout: lyricLayout },
|
||||
],
|
||||
}),
|
||||
'\u6b4c\u8a5e\uff22\n\n\u30e9\u30a4\u30d6\u3000\u3084\u3081\u3088\u3063\u304b',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText puts an unreadable placement above bottom dialogue', () => {
|
||||
// Dialogue is the case that reliably declares a bottom alignment, so a cue whose
|
||||
// placement could not be read is more often a sign or song line. Keeping dialogue on
|
||||
// the bottom row means the line worth reading stays where the eye already is.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: '\u4e0b\u306e\u30bb\u30ea\u30d5\n\u4e0d\u660e\u306a\u884c',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{
|
||||
startTime: 1,
|
||||
endTime: 3,
|
||||
text: '\u4e0b\u306e\u30bb\u30ea\u30d5',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 0, verticalBand: 'bottom' },
|
||||
},
|
||||
{
|
||||
startTime: 1.5,
|
||||
endTime: 3,
|
||||
text: '\u4e0d\u660e\u306a\u884c',
|
||||
assLayout: { kind: 'source-order', sourceOrder: 1 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
'\u4e0d\u660e\u306a\u884c\n\n\u4e0b\u306e\u30bb\u30ea\u30d5',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolvePrimarySubtitleText keeps source order when no cue declares a placement', () => {
|
||||
// SRT and websocket cues carry no layout at all: every cue ties, so the stable sort
|
||||
// must leave them exactly as the cue list had them.
|
||||
assert.equal(
|
||||
resolvePrimarySubtitleText({
|
||||
liveText: 'First line\nSecond line',
|
||||
currentTimeSec: 2,
|
||||
cues: [
|
||||
{ startTime: 1, endTime: 3, text: 'First line' },
|
||||
{ startTime: 1.5, endTime: 3, text: 'Second line' },
|
||||
],
|
||||
}),
|
||||
'First line\n\nSecond line',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssVerticalBand, SubtitleCue } from '../../types';
|
||||
import type { SubtitleCue } from '../../types';
|
||||
import {
|
||||
removeAssControlDebrisLines,
|
||||
removeLiveGlyphFragmentLines,
|
||||
@@ -57,52 +57,21 @@ function compactWhitespace(text: string): string {
|
||||
return text.normalize('NFKC').replace(/\s+/gu, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct simultaneous cues are separated by a blank line so the display layer can tell
|
||||
* a wrap inside one utterance from the boundary between two of them. Consumers that read
|
||||
* the text rather than display it fold these back to single breaks.
|
||||
*/
|
||||
const CUE_BOUNDARY = '\n\n';
|
||||
|
||||
const VERTICAL_BAND_RANK: Record<AssVerticalBand, number> = { top: 0, middle: 1, bottom: 2 };
|
||||
|
||||
/**
|
||||
* Stack simultaneous cues the way they sit on screen: mpv keeps a top-anchored lyric or
|
||||
* sign above bottom dialogue for its whole run, while cue-list order follows start time
|
||||
* and would swap the pair whenever one side is replaced mid-overlap. The band is
|
||||
* constant per event, so a line never changes rows while it is displayed.
|
||||
*
|
||||
* A cue whose placement could not be read -- an unknown style, a script with no styles
|
||||
* section -- sorts to the top. Dialogue is the case that reliably declares a bottom
|
||||
* alignment, so what is left unresolved is more often a sign or a song line, and keeping
|
||||
* the dialogue on the bottom row means the line worth reading stays where the eye
|
||||
* already is. Sort is stable, so cues sharing a rank keep their existing order.
|
||||
*/
|
||||
function orderCuesForDisplay(cues: readonly SubtitleCue[]): SubtitleCue[] {
|
||||
const rank = (cue: SubtitleCue): number =>
|
||||
VERTICAL_BAND_RANK[cue.assLayout?.verticalBand ?? 'top'];
|
||||
return [...cues].sort((a, b) => rank(a) - rank(b));
|
||||
}
|
||||
|
||||
// ASS layers can encode the same visible spacing with ordinary, hard, or
|
||||
// ideographic spaces. Matching and emission must use the same identity or each
|
||||
// layer reappears as a copy.
|
||||
function uniqueCueTextGroups(cues: readonly SubtitleCue[]): string[] {
|
||||
const groups: string[] = [];
|
||||
function uniqueCueTexts(cues: readonly SubtitleCue[]): string[] {
|
||||
const texts: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const cue of cues) {
|
||||
const lines: string[] = [];
|
||||
for (const line of cue.text.split('\n')) {
|
||||
const compactText = compactWhitespace(line);
|
||||
if (!compactText || seen.has(compactText)) continue;
|
||||
seen.add(compactText);
|
||||
lines.push(line);
|
||||
}
|
||||
if (lines.length > 0) {
|
||||
groups.push(lines.join('\n'));
|
||||
texts.push(line);
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
return texts;
|
||||
}
|
||||
|
||||
function compactLineSegments(text: string): string[] {
|
||||
@@ -165,24 +134,23 @@ function resolveActiveParsedPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
// A cue selected only through the edge tolerance on its end has already finished by
|
||||
// its published timing: a lyric whose exit ghosts linger into the next line. It still
|
||||
// explains those live fragments above, but must not re-surface beside cues that are
|
||||
// still running. The start side keeps the tolerance: mpv publishes the combined
|
||||
// sub-text the moment a joining line's first frame renders, while the observed
|
||||
// time-pos still sits just before that line's start, and the selection above already
|
||||
// required the cue's text to be on screen (#220). With every selected cue finished,
|
||||
// the edge cues remain the display fallback for stale time-pos readings.
|
||||
const unfinished = selected.filter((cue) => cue.endTime > options.currentTimeSec);
|
||||
const displayCues = unfinished.length > 0 ? unfinished : selected;
|
||||
// A cue selected only through the edge tolerance has already ended (or not yet
|
||||
// started) by its published timing: a finished lyric whose exit ghosts linger into
|
||||
// the next line. It still explains those live fragments above, but while any cue is
|
||||
// strictly active, only the active cues supply the displayed text. With no strictly
|
||||
// active cue, the edge cues remain the display fallback for stale time-pos readings.
|
||||
const strictlyActive = selected.filter(
|
||||
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
|
||||
);
|
||||
const displayCues = strictlyActive.length > 0 ? strictlyActive : selected;
|
||||
|
||||
// Dense sign grids still explain their raw mpv fragments, but are visual
|
||||
// typesetting rather than a publishable subtitle line.
|
||||
const groups = uniqueCueTextGroups(
|
||||
orderCuesForDisplay(displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid')),
|
||||
const texts = uniqueCueTexts(
|
||||
displayCues.filter((cue) => cue.assLayout?.kind !== 'fragment-grid'),
|
||||
);
|
||||
return {
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
text: texts.join('\n'),
|
||||
startTime: Math.min(...displayCues.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...displayCues.map((cue) => cue.endTime)),
|
||||
cues: displayCues,
|
||||
@@ -249,9 +217,9 @@ export function resolveCanonicalPrimarySubtitle(options: {
|
||||
return null;
|
||||
}
|
||||
|
||||
const groups = uniqueCueTextGroups(orderCuesForDisplay(selected));
|
||||
const texts = uniqueCueTexts(selected);
|
||||
return {
|
||||
text: groups.join(CUE_BOUNDARY),
|
||||
text: texts.join('\n'),
|
||||
startTime: Math.min(...selected.map((cue) => cue.startTime)),
|
||||
endTime: Math.max(...selected.map((cue) => cue.endTime)),
|
||||
cues: selected,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
getFrequencyRankLabelForToken,
|
||||
getJlptLevelLabelForToken,
|
||||
normalizeSubtitle,
|
||||
normalizeSubtitleForDisplay,
|
||||
prepareSecondarySubtitleLines,
|
||||
sanitizeSubtitleHoverTokenColor,
|
||||
shouldRenderTokenizedSubtitle,
|
||||
@@ -1005,34 +1004,6 @@ test('normalizeSubtitle collapses explicit line breaks when collapseLineBreaks i
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSubtitleForDisplay always breaks between simultaneous cues', () => {
|
||||
// The blank line marks two distinct cues on screen at once. Flattening it would run a
|
||||
// sign or a second speaker into the line beside it as one sentence.
|
||||
const twoCues =
|
||||
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d\u3000\u9b3c\u5b50\u6bcd\u795e\u524d\n\n\u611b\u97f3\u3061\u3083\u3093\u3000\u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b';
|
||||
|
||||
assert.equal(
|
||||
normalizeSubtitleForDisplay(twoCues, false),
|
||||
'\u6b21\u306f\u9b3c\u5b50\u6bcd\u795e\u524d \u9b3c\u5b50\u6bcd\u795e\u524d\n\u611b\u97f3\u3061\u3083\u3093 \u3082\u3046\u5199\u771f\u4e0a\u3052\u3066\u308b',
|
||||
);
|
||||
assert.equal(normalizeSubtitleForDisplay(twoCues, true), twoCues.replace('\n\n', '\n'));
|
||||
});
|
||||
|
||||
test('normalizeSubtitleForDisplay preserves CRLF boundaries between simultaneous cues', () => {
|
||||
assert.equal(normalizeSubtitleForDisplay('a\r\n\r\nb', false), 'a\nb');
|
||||
});
|
||||
|
||||
test('normalizeSubtitleForDisplay still flattens a wrap inside one cue', () => {
|
||||
// A typesetter's \\N inside a single utterance is what preserveLineBreaks governs.
|
||||
assert.equal(
|
||||
normalizeSubtitleForDisplay(
|
||||
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070\\N\u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
|
||||
false,
|
||||
),
|
||||
'\u5e38\u4eba\u304c\u4f7f\u3048\u3070 \u305d\u306e\u5727\u5012\u7684\u306a\u529b\u306b',
|
||||
);
|
||||
});
|
||||
|
||||
test('normalizeSubtitle leaves already-decoded text alone', () => {
|
||||
// Primary subtitle text is decoded from ASS once, upstream: by mpv for live lines and
|
||||
// by the cue parser for prefetched ones. A brace that survives that is literal text.
|
||||
|
||||
@@ -50,21 +50,6 @@ export function normalizeSubtitle(text: string, trim = true, collapseLineBreaks
|
||||
return normalizePlainSubtitleText(text, { trim, collapseLineBreaks });
|
||||
}
|
||||
|
||||
/**
|
||||
* Display form of a resolved subtitle. `preserveLineBreaks` governs wrapping inside one
|
||||
* utterance, which is what a typesetter's `\N` means. The blank line the resolver puts
|
||||
* between two simultaneous cues is a different thing and always breaks, so a sign or a
|
||||
* second speaker never runs into the line beside it.
|
||||
*/
|
||||
export function normalizeSubtitleForDisplay(text: string, preserveLineBreaks: boolean): string {
|
||||
return text
|
||||
.replace(/\r\n/g, '\n')
|
||||
.split(/\n{2,}/)
|
||||
.map((cueText) => normalizeSubtitle(cueText, true, !preserveLineBreaks))
|
||||
.filter((cueText) => cueText.length > 0)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
const HEX_COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
||||
const SAFE_CSS_COLOR_PATTERN =
|
||||
/^(?:#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})|(?:rgba?|hsla?)\([^)]*\)|var\([^)]*\)|[a-zA-Z]+)$/;
|
||||
@@ -429,13 +414,16 @@ function renderWithTokens(
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
if (sourceText) {
|
||||
const normalizedSource = normalizeSubtitleForDisplay(sourceText, preserveLineBreaks);
|
||||
const normalizedSource = normalizeSubtitle(sourceText, true, !preserveLineBreaks);
|
||||
const segments = alignTokensToSourceText(tokens, normalizedSource);
|
||||
|
||||
for (const segment of segments) {
|
||||
if (segment.kind === 'text') {
|
||||
// Normalization already resolved which breaks survive; every one left is real.
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
if (preserveLineBreaks) {
|
||||
renderPlainTextPreserveLineBreaks(fragment, segment.text);
|
||||
} else {
|
||||
fragment.appendChild(document.createTextNode(segment.text));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -760,7 +748,7 @@ export function createSubtitleRenderer(ctx: RendererContext) {
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = normalizeSubtitleForDisplay(text, ctx.state.preserveSubtitleLineBreaks);
|
||||
const normalized = normalizeSubtitle(text, true, !ctx.state.preserveSubtitleLineBreaks);
|
||||
const hasRenderableTokens =
|
||||
shouldRenderTokenizedSubtitle(tokens?.length ?? 0) && Boolean(tokens);
|
||||
if (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AssVerticalBand, SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||
import type { SubtitleCue } from '../core/services/subtitle-cue-parser';
|
||||
|
||||
export enum PartOfSpeech {
|
||||
noun = 'noun',
|
||||
@@ -187,7 +187,7 @@ export interface ResolvedTokenPos2ExclusionConfig {
|
||||
|
||||
export type FrequencyDictionaryMode = 'single' | 'banded';
|
||||
|
||||
export type { AssVerticalBand, SubtitleCue };
|
||||
export type { SubtitleCue };
|
||||
|
||||
export type SubtitleSidebarLayout = 'overlay' | 'embedded';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user