feat(anki): add Senren scene-switching field grouping

- Support auto, manual, and disabled Senren duplicate-card merges
- Group sentence, furigana, audio, picture, and miscInfo fields
This commit is contained in:
2026-09-01 23:22:54 -07:00
68 changed files with 1532 additions and 845 deletions
+1
View File
@@ -86,6 +86,7 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'ankiConnect.fields.miscInfo',
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'ankiConnect.isSenren.fieldGrouping',
'ankiConnect.lapisKiku.wordCardKind',
] as const;
-6
View File
@@ -131,12 +131,6 @@ export {
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from './jellyfin';
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
export {
estimateSubtitleTimingOffset,
type SubtitleTimingOffsetOptions,
type SubtitleTimingOffsetResult,
} from './subtitle-timing-offset';
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
export {
broadcastRuntimeOptionsChangedRuntime,
@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
function statePath(name: string): string {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
}
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
const filePath = statePath('delays.json');
assert.equal(
saveJellyfinSubtitleDelay({
filePath,
itemId: 'episode-1',
streamIndex: 3,
delaySeconds: 1.25,
}),
true,
);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
});
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
const filePath = statePath('delays.json');
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
});
test('jellyfin subtitle delay store ignores invalid files and values', () => {
const filePath = statePath('delays.json');
fs.writeFileSync(filePath, '{');
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
assert.equal(
saveJellyfinSubtitleDelay({
filePath,
itemId: 'episode-1',
streamIndex: 3,
delaySeconds: Number.NaN,
}),
false,
);
});
@@ -1,66 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
type JellyfinSubtitleDelayStore = {
version?: unknown;
delays?: unknown;
};
type JellyfinSubtitleDelayParams = {
filePath: string;
itemId: string;
streamIndex: number;
};
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
delaySeconds: number;
};
function storeKey(itemId: string, streamIndex: number): string {
return JSON.stringify([itemId, streamIndex]);
}
function readDelayMap(filePath: string): Record<string, number> {
try {
if (!fs.existsSync(filePath)) return {};
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
if (
!parsed ||
typeof parsed !== 'object' ||
!parsed.delays ||
typeof parsed.delays !== 'object'
) {
return {};
}
const delays: Record<string, number> = {};
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
if (typeof value === 'number' && Number.isFinite(value)) {
delays[key] = value;
}
}
return delays;
} catch {
return {};
}
}
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
}
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
if (!Number.isFinite(params.delaySeconds)) return false;
try {
const delays = readDelayMap(params.filePath);
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
const dir = path.dirname(params.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
return true;
} catch {
return false;
}
}
+45
View File
@@ -244,6 +244,35 @@ test('handleMultiCopyDigit copies available history and reports truncation', ()
assert.equal(osd.at(-1), 'Only 2 lines available, copied 2');
});
test('handleMultiCopyDigit copies backward from the current subtitle after a backward seek', () => {
const copied: string[] = [];
const tracker = new SubtitleTimingTracker();
try {
tracker.recordSubtitle('A', 1, 2);
tracker.recordSubtitle('B', 3, 4);
tracker.recordSubtitle('C', 5, 6);
tracker.recordSubtitle('B', 3, 4);
const deps = {
subtitleTimingTracker: tracker,
writeClipboardText: (text: string) => copied.push(text),
showMpvOsd: () => {},
};
handleMultiCopyDigit(1, deps);
handleMultiCopyDigit(2, deps);
assert.deepEqual(copied, ['B', 'A\n\nB']);
assert.deepEqual(tracker.getRecentEntries(2), [
{ displayText: 'A', startTime: 1, endTime: 2, secondaryText: undefined },
{ displayText: 'B', startTime: 3, endTime: 4, secondaryText: undefined },
]);
} finally {
tracker.destroy();
}
});
test('handleMineSentenceDigit reports async create failures', async () => {
const osd: string[] = [];
const logs: Array<{ message: string; err: unknown }> = [];
@@ -344,6 +373,22 @@ test('handleMineSentenceDigit keeps per-entry timings when subtitle text repeats
}
});
test('subtitle timing history preserves adjacent repeated text with distinct timings', () => {
const tracker = new SubtitleTimingTracker();
try {
tracker.recordSubtitle('same', 1, 2);
tracker.recordSubtitle('same', 3, 4);
assert.deepEqual(tracker.getRecentEntries(2), [
{ displayText: 'same', startTime: 1, endTime: 2, secondaryText: undefined },
{ displayText: 'same', startTime: 3, endTime: 4, secondaryText: undefined },
]);
} finally {
tracker.destroy();
}
});
test('handleMineSentenceDigit joins per-entry secondary subtitles when available', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
const tracker = new SubtitleTimingTracker();
+34
View File
@@ -83,6 +83,7 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
state.secondarySubText = text;
},
resolvePendingRequest: () => false,
shouldEnforceSecondarySubVisibilityHidden: () => true,
setSecondarySubVisibility: () => {},
syncCurrentAudioStreamIndex: () => {},
setCurrentAudioTrackId: () => {},
@@ -198,6 +199,21 @@ test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () =
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
});
test('dispatchMpvProtocolMessage hides native secondary subtitles after a track change', async () => {
const visibilityChanges: boolean[] = [];
const { deps, state } = createDeps({
setSecondarySubVisibility: (visible) => visibilityChanges.push(visible),
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4' },
deps,
);
assert.deepEqual(visibilityChanges, [false]);
assert.deepEqual(state.events, [{ sid: 4 }]);
});
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
const { deps, state } = createDeps({
isVisibleOverlayVisible: () => true,
@@ -239,6 +255,24 @@ test('dispatchMpvProtocolMessage skips sub-visibility suppression when overlay i
assert.equal(state.commands.length, 0);
});
test('dispatchMpvProtocolMessage corrects native secondary subtitle visibility', async () => {
const visibilityChanges: boolean[] = [];
const { deps } = createDeps({
setSecondarySubVisibility: (visible) => visibilityChanges.push(visible),
});
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'yes' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sub-visibility', data: 'no' },
deps,
);
assert.deepEqual(visibilityChanges, [false]);
});
test('dispatchMpvProtocolMessage sets secondary subtitle track based on track list response', async () => {
const { deps, state } = createDeps();
+9
View File
@@ -72,6 +72,7 @@ export interface MpvProtocolHandleMessageDeps {
emitSubtitleMetricsChange: (payload: Partial<MpvSubtitleRenderMetrics>) => void;
setCurrentSecondarySubText: (text: string) => void;
resolvePendingRequest: (requestId: number, message: MpvMessage) => boolean;
shouldEnforceSecondarySubVisibilityHidden: () => boolean;
setSecondarySubVisibility: (visible: boolean) => void;
syncCurrentAudioStreamIndex: () => void;
setCurrentAudioTrackId: (value: number | null) => void;
@@ -285,6 +286,9 @@ export async function dispatchMpvProtocolMessage(
: null;
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
} else if (msg.name === 'secondary-sid') {
if (deps.shouldEnforceSecondarySubVisibilityHidden()) {
deps.setSecondarySubVisibility(false);
}
const sid =
typeof msg.data === 'number'
? msg.data
@@ -375,6 +379,11 @@ export async function dispatchMpvProtocolMessage(
if (deps.isVisibleOverlayVisible() && asBoolean(msg.data, false)) {
deps.sendCommand({ command: ['set_property', 'sub-visibility', false] });
}
} else if (msg.name === 'secondary-sub-visibility') {
const visible = parseVisibilityProperty(msg.data);
if (deps.shouldEnforceSecondarySubVisibilityHidden() && visible === true) {
deps.setSecondarySubVisibility(false);
}
} else if (msg.name === 'sub-use-margins') {
deps.emitSubtitleMetricsChange({
subUseMargins: asBoolean(msg.data, deps.getSubtitleMetrics().subUseMargins),
+52 -1
View File
@@ -652,7 +652,7 @@ test('MpvIpcClient captures and disables secondary subtitle visibility on reques
]);
});
test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tracked value', async () => {
test('MpvIpcClient restores secondary subtitle visibility and relinquishes suppression', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
const previous: boolean[] = [];
@@ -671,6 +671,12 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
});
client.restorePreviousSecondarySubVisibility();
await invokeHandleMessage(client, {
event: 'property-change',
name: 'secondary-sub-visibility',
data: 'yes',
});
assert.equal(previous[0], true);
assert.equal(previous.length, 1);
assert.deepEqual(commands, [
@@ -682,8 +688,53 @@ test('MpvIpcClient restorePreviousSecondarySubVisibility restores and clears tra
},
]);
await invokeHandleMessage(client, {
event: 'property-change',
name: 'secondary-sub-visibility',
data: 'yes',
});
assert.equal(commands.length, 2);
client.restorePreviousSecondarySubVisibility();
assert.equal(commands.length, 2);
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
commands.length = 0;
await invokeHandleMessage(client, {
event: 'property-change',
name: 'secondary-sub-visibility',
data: 'yes',
});
assert.deepEqual(commands, [{ command: ['set_property', 'secondary-sub-visibility', 'no'] }]);
});
test('MpvIpcClient keeps secondary subtitle suppression when restoration send fails', async () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = (payload: unknown) => {
commands.push(payload);
return false;
};
await invokeHandleMessage(client, {
request_id: MPV_REQUEST_ID_SECONDARY_SUB_VISIBILITY,
data: 'yes',
});
client.restorePreviousSecondarySubVisibility();
await invokeHandleMessage(client, {
event: 'property-change',
name: 'secondary-sid',
data: 4,
});
assert.deepEqual(commands, [
{ command: ['set_property', 'secondary-sub-visibility', 'no'] },
{ command: ['set_property', 'secondary-sub-visibility', 'yes'] },
{ command: ['set_property', 'secondary-sub-visibility', 'no'] },
]);
});
test('MpvIpcClient updates current audio stream index from track list', async () => {
+6 -1
View File
@@ -184,6 +184,7 @@ export class MpvIpcClient implements MpvClient {
osdDimensions: null,
};
private previousSecondarySubVisibility: boolean | null = null;
private enforceSecondarySubVisibilityHidden = true;
private playbackPaused: boolean | null = null;
private pauseAtTime: number | null = null;
private pendingPauseAtSubEnd = false;
@@ -199,6 +200,7 @@ export class MpvIpcClient implements MpvClient {
socketFactory: deps.socketFactory,
connectTimeoutMs: deps.connectTimeoutMs,
onConnect: () => {
this.enforceSecondarySubVisibilityHidden = true;
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
@@ -476,6 +478,7 @@ export class MpvIpcClient implements MpvClient {
},
resolvePendingRequest: (requestId: number, message: MpvMessage) =>
this.tryResolvePendingRequest(requestId, message),
shouldEnforceSecondarySubVisibilityHidden: () => this.enforceSecondarySubVisibilityHidden,
setSecondarySubVisibility: (visible: boolean) => this.setSecondarySubVisibility(visible),
syncCurrentAudioStreamIndex: () => {
this.syncCurrentAudioStreamIndex();
@@ -647,9 +650,11 @@ export class MpvIpcClient implements MpvClient {
restorePreviousSecondarySubVisibility(): void {
const previous = this.previousSecondarySubVisibility;
if (previous === null) return;
this.send({
const restored = this.send({
command: ['set_property', 'secondary-sub-visibility', previous ? 'yes' : 'no'],
});
if (!restored) return;
this.enforceSecondarySubVisibilityHidden = false;
this.previousSecondarySubVisibility = null;
}
+179 -7
View File
@@ -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(332,443)\\fscx50\\fscy50}ともり',
'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');
const cues = parseSubtitleCues(content, 'test.ass');
// The bare speaker label row joins the dialogue row beneath it as one cue.
assert.deepEqual(
cues.map((cue) => cue.text),
['(立希)', 'お前… 燈をバンドに誘ったの?'],
['(立希)\nお前… 燈をバンドに誘ったの?', '(燈)うん。'],
);
assert.deepEqual(cues[0]?.assFurigana, ['たき']);
assert.deepEqual(cues[1]?.assFurigana, ['ともり']);
assert.deepEqual(cues[0]?.assFurigana, ['たき', 'ともり']);
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[3]?.assFurigana, ['かく', 'ちょぞう']);
assert.deepEqual(cues[4]?.assFurigana, ['ぜったい ちが']);
assert.deepEqual(cues[2]?.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', () => {
+183 -1
View File
@@ -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 {
const cues: AnnotatedSubtitleCue[] = [];
const comments: AnnotatedSubtitleCue[] = [];
@@ -2476,7 +2655,10 @@ function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
removeAssFontTextureEvents(parseAnnotatedAssEvents(content, placement)),
placement.playResY,
);
return recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events));
return mergeAssCaptionRows(
recoverFragmentOnlyAssLines(recoverCanonicalAssEvents(events)),
placement.playResY,
);
}
export function parseAssCues(content: string): SubtitleCue[] {
@@ -1,73 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
function cue(startTime: number) {
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
}
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
const primary = [
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
87.988, 90.991, 94.094, 97.097,
].map(cue);
const reference = [
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.ok(result);
assert.ok(result.offsetSeconds > -32);
assert.ok(result.offsetSeconds < -31);
assert.ok(result.matchCount >= 8);
assert.ok(result.meanErrorSeconds <= 0.75);
});
test('estimate subtitle timing offset favors the early episode timeline', () => {
const primary = [
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
435.602, 440.44, 442.942, 445.445, 449.783,
].map(cue);
const reference = [
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.ok(result);
assert.ok(result.offsetSeconds > -32);
assert.ok(result.offsetSeconds < -31);
});
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
const result = estimateSubtitleTimingOffset(
starts.map(cue),
starts.map((start) => cue(start + 0.04)),
);
assert.equal(result, null);
});
test('estimate subtitle timing offset rejects weak timeline matches', () => {
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.equal(result, null);
});
-153
View File
@@ -1,153 +0,0 @@
import type { SubtitleCue } from './subtitle-cue-parser';
export type SubtitleTimingOffsetResult = {
offsetSeconds: number;
matchCount: number;
meanErrorSeconds: number;
maxErrorSeconds: number;
};
export type SubtitleTimingOffsetOptions = {
maxCueCount?: number;
maxOffsetSeconds?: number;
matchThresholdSeconds?: number;
maxMeanErrorSeconds?: number;
minMatchCount?: number;
minMatchRatio?: number;
minUsefulOffsetSeconds?: number;
};
type OffsetScore = SubtitleTimingOffsetResult;
const DEFAULT_MAX_CUE_COUNT = 60;
const DEFAULT_MAX_OFFSET_SECONDS = 180;
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
const DEFAULT_MIN_MATCH_COUNT = 8;
const DEFAULT_MIN_MATCH_RATIO = 0.25;
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
const starts = cues
.map((cue) => cue.startTime)
.filter((start) => Number.isFinite(start) && start >= 0)
.sort((a, b) => a - b);
const deduped: number[] = [];
for (const start of starts) {
const previous = deduped[deduped.length - 1];
if (previous === undefined || Math.abs(start - previous) > 0.05) {
deduped.push(start);
}
if (deduped.length >= maxCueCount) {
break;
}
}
return deduped;
}
function roundToMillis(value: number): number {
return Math.round(value * 1000) / 1000;
}
function scoreOffset(
primaryStarts: number[],
referenceStarts: number[],
offsetSeconds: number,
matchThresholdSeconds: number,
): OffsetScore {
let primaryIndex = 0;
let referenceIndex = 0;
let matchCount = 0;
let totalErrorSeconds = 0;
let maxErrorSeconds = 0;
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
const reference = referenceStarts[referenceIndex]!;
const errorSeconds = Math.abs(shiftedPrimary - reference);
if (errorSeconds <= matchThresholdSeconds) {
matchCount += 1;
totalErrorSeconds += errorSeconds;
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
primaryIndex += 1;
referenceIndex += 1;
continue;
}
if (shiftedPrimary < reference) {
primaryIndex += 1;
} else {
referenceIndex += 1;
}
}
return {
offsetSeconds,
matchCount,
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
maxErrorSeconds,
};
}
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
if (current === null) return true;
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
return next.meanErrorSeconds < current.meanErrorSeconds;
}
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
}
export function estimateSubtitleTimingOffset(
primaryCues: SubtitleCue[],
referenceCues: SubtitleCue[],
options: SubtitleTimingOffsetOptions = {},
): SubtitleTimingOffsetResult | null {
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
const minUsefulOffsetSeconds =
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
if (comparableCueCount < minMatchCount) {
return null;
}
const candidates = new Set<number>();
for (const primaryStart of primaryStarts) {
for (const referenceStart of referenceStarts) {
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
candidates.add(offsetSeconds);
}
}
}
let best: OffsetScore | null = null;
for (const offsetSeconds of candidates) {
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
continue;
}
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
if (score.matchCount < minMatchCount) {
continue;
}
if (score.matchCount / comparableCueCount < minMatchRatio) {
continue;
}
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
continue;
}
if (isBetterScore(score, best)) {
best = score;
}
}
return best;
}