Compare commits

..
Author SHA1 Message Date
sudacode 8ebf6a45fe test(mpv): cover decimal subtitle track IDs
- Verify decimal numeric IDs are rejected for primary and secondary subtitles
2026-08-18 00:16:20 -07:00
sudacode fc7fde30d5 fix(overlay): prevent secondary subtitle duplication
- Reject decimal subtitle track IDs
- Preserve repeated short dialogue lines
2026-08-17 23:49:08 -07:00
sudacode f335b26fe3 fix(overlay): deduplicate secondary subtitle rendering
- Parse selected secondary tracks through the subtitle deduplication pipeline
- Fall back to live mpv text when source resolution fails
2026-08-17 23:37:09 -07:00
39 changed files with 845 additions and 1885 deletions
-5
View File
@@ -1,5 +0,0 @@
type: fixed
area: subtitles
- Typeset ASS karaoke and animated signs no longer flood the primary overlay, subtitle sidebar, immersion history, or sentence mining with repeated glyph fragments or full-line color phases. Matching timed comments and full-line boundary events recover the complete authored line without merging ordinary repeated dialogue or separately positioned signs, and dialogue spoken while a song's animation is on screen is kept intact instead of being replaced by the lyric. Entrance and exit frames that run past the authored line timing still resolve to the clean line during lyric transitions, and dialogue spoken while a song's animation is on screen enters immersion and subtitle history without the fragment lines beside it.
- The secondary subtitle overlay drops layered duplicate lines from animated tracks, so a short stack of repeated words collapses to its distinct lines even when the full karaoke heuristic does not apply.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Secondary subtitles now parse the selected ASS/SRT/VTT source with the primary subtitle deduplication pipeline, preventing layered animation text from appearing several times in the overlay, mined cards, and statistics. Live mpv text remains the fallback for unreadable tracks.
-2
View File
@@ -138,8 +138,6 @@ Karaoke openings and animated signs are authored as one subtitle event per anima
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
- When a typeset ASS file stores a clean lyric or sign in a timed authoring comment, or in full-line events surrounding generated fragments, the matching complete line is recorded once. The repeated glyph or clip-animation frames are not recorded. Dialogue spoken while such an animation is on screen records as itself, without the fragment lines beside it.
- When karaoke styling redraws the same complete lyric across consecutive color or highlight phases, those phases are combined into one line with their full timing. Repeated ordinary dialogue remains separate.
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record.
-2
View File
@@ -12,8 +12,6 @@ When SubMiner parses the active subtitle source into a cue list, the sidebar bec
- Clicking any cue seeks mpv to that timestamp.
- The sidebar stays synchronized with the overlay - media transitions and subtitle source changes update both simultaneously.
For typeset ASS karaoke and animated signs, SubMiner collapses generated animation frames and repeated full-line color phases before they reach the sidebar. It recovers a clean complete line from a matching timed authoring comment or from full-line events surrounding generated fragments. Ordinary ASS comments, editor notes, alternate lines, repeated dialogue, and separately positioned signs remain distinct.
The sidebar only appears when a parsed cue list is available. External subtitle sources that SubMiner cannot parse (for example, embedded ASS tracks rendered directly by mpv) will not populate the sidebar.
## Layout Modes
@@ -70,25 +70,18 @@ interface SubtitleCue {
startTime: number; // seconds
endTime: number; // seconds
text: string; // plain text, decoded from the source format
source?: 'canonical-ass'; // recovered authored text for generated ASS animation
animationStartTime?: number; // full generated-frame envelope; entrance/exit frames
animationEndTime?: number; // run past the authored timing, live matching uses this
}
```
**Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse the `[Events]` section, read the field order from the `Format:` row, and extract timed `Dialogue:` lines. Timed `Comment:` lines are normally ignored, but can supply canonical authored text when they match a nearby generated animation from the same style and actor. Text can itself contain commas.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
ASS scripts can also redraw one complete lyric for two or more long color/highlight phases. Those flush-timed phases collapse separately from short animation frames when they share text, style, actor, and layer and carry direct animation evidence, such as temporal tags or changing non-spatial overrides. Spatial command changes do not prove a phase, so separately positioned signs remain distinct.
**Canonical animation recovery.** Some ASS producers keep the readable lyric or sign as a timed `Comment:` and generate hundreds of `Dialogue:` frames containing repeated glyphs or changing clip regions. Others retain the complete line as brief `Dialogue:` events around the generated fragments. A complete event is promoted only when nearby dialogue from the same style and actor forms a proven animation cluster and reconstructs its entire text in source order. The generated frames are then replaced by one cue marked `source: 'canonical-ass'`. This source marker lets the live primary-subtitle path prefer the clean authored text and timing for display, sidebar history, immersion recording, and mining, while unmatched editor notes and alternative translations remain ignored.
#### Prefetch Service Lifecycle
1. **Activation trigger:** When a subtitle track is activated (or changes), check if it's external via MPV's `track-list` property. If `external === true`, read the file via `external-filename` using the existing `loadSubtitleSourceText` infrastructure.
+22 -3
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-08-04
Last verified: 2026-08-17
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -77,6 +77,25 @@ coming and prefetching would otherwise idle for the rest of the cue.
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
or character images, but cue visibility does not wait for that work.
## Secondary Subtitle Flow
- `secondary-sub-text` remains the immediate fallback, so unreadable and remote subtitle sources
still appear without waiting for file resolution.
- `secondary-subtitle-track.ts` resolves `secondary-sid` against mpv's track list. External tracks
are read directly; supported embedded text tracks are extracted through the same ffmpeg-backed
source resolver used by primary subtitle prefetching.
- The selected source is parsed with `parseSubtitleCues()`, including metadata-aware ASS duplicate
and animation collapse. Playback `time-pos` selects the active parsed cue after applying
`secondary-sub-delay`.
- The resolved text is stored in `mpvClient.currentSecondarySubText` before it is broadcast. The
overlay, mining, timing tracker, and immersion statistics therefore consume the same secondary
text when a readable source is available.
- Media and `secondary-sid` changes clear the previous parsed state before refreshing the source;
track-list changes refresh without discarding an unchanged source. Observed
`secondary-sub-delay` changes retime the active parsed cue without rereading the file. If loading,
extraction, or parsing fails, the controller returns to live mpv text and the renderer's
conservative short stack heuristic remains the final display fallback.
## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
@@ -84,8 +103,8 @@ coming and prefetching would otherwise idle for the rest of the cue.
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
when text and cue timing match.
- Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- Secondary priming reads mpv `secondary-sub-text` and routes it through the secondary track
controller. A parsed active cue replaces the live text when the selected source is readable.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
written.
@@ -1,4 +1,7 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { Database } from '../sqlite.js';
import type { DatabaseSync } from '../sqlite.js';
@@ -18,6 +21,17 @@ interface SeedLine {
createdMs?: number;
}
function makeDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-duplicate-line-test-'));
return path.join(dir, 'immersion.sqlite');
}
function cleanupDbPath(dbPath: string): void {
const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) return;
fs.rmSync(dir, { recursive: true, force: true });
}
/** One episode, two sessions of it, and one word occurrence per seeded line. */
function seed(db: DatabaseSync, lines: SeedLine[]): void {
db.exec(`
@@ -68,16 +82,12 @@ function seed(db: DatabaseSync, lines: SeedLine[]): void {
`);
}
/**
* These tests exercise the cleanup SQL, not durability. A fresh on-disk database per
* test pays a schema-creation fsync that is cheap on a local NVMe but slow enough on CI
* runners to blow the 5s per-test timeout, so the database stays in memory.
*/
function createDb(lines: SeedLine[]): { db: DatabaseSync } {
const db = new Database(':memory:');
function createDb(lines: SeedLine[]): { db: DatabaseSync; dbPath: string } {
const dbPath = makeDbPath();
const db = new Database(dbPath);
ensureSchema(db);
seed(db, lines);
return { db };
return { db, dbPath };
}
/** A typeset line mpv reported once per animation frame. */
@@ -109,7 +119,7 @@ function wordFrequency(db: DatabaseSync): number {
}
test('a karaoke burst collapses to one line and gives back its word counts', () => {
const { db } = createDb([
const { db, dbPath } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 40, 40),
{ session: 1, text: 'おはよう', startMs: 20_000, endMs: 22_000 },
]);
@@ -138,6 +148,7 @@ test('a karaoke burst collapses to one line and gives back its word counts', ()
assert.equal(summary.samples[0]!.videoTitle, 'Ep 1');
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -149,7 +160,7 @@ test('ordinary repeated dialogue survives', () => {
startMs: 5_000 + index * 800,
endMs: 5_000 + (index + 1) * 800,
}));
const { db } = createDb(lines);
const { db, dbPath } = createDb(lines);
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -160,13 +171,14 @@ test('ordinary repeated dialogue survives', () => {
assert.equal(wordFrequency(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a long run of quarter-second frames is still a burst', () => {
// Between the timing-only bound (0.1s) and the animation-frame bound (0.3s): heavier
// typesetting lands here, and the run length is what makes it conclusive.
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -177,11 +189,12 @@ test('a long run of quarter-second frames is still a burst', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a qualifying short-frame burst may end with one long hold frame', () => {
const { db } = createDb([
const { db, dbPath } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 8, 40),
{ session: 1, text: '飛び上がる', startMs: 10_320, endMs: 12_320 },
]);
@@ -195,11 +208,12 @@ test('a qualifying short-frame burst may end with one long hold frame', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a long event before the final frame prevents burst cleanup', () => {
const { db } = createDb([
const { db, dbPath } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 5, 40),
{ session: 1, text: '飛び上がる', startMs: 10_200, endMs: 12_200 },
{ session: 1, text: '飛び上がる', startMs: 12_200, endMs: 12_240 },
@@ -212,11 +226,12 @@ test('a long event before the final frame prevents burst cleanup', () => {
assert.equal(countLines(db), 7);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a run of frames longer than the animation bound survives', () => {
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 400));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -225,6 +240,7 @@ test('a run of frames longer than the animation bound survives', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -232,7 +248,7 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
// The streaming gate records the first four frames of a burst before the run is long
// enough to recognise. Four contiguous identical events under the strict timing-only
// bound are that residue, and no real dialogue.
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -243,13 +259,14 @@ test('the four-frame residue the live gate stores is cleaned up', () => {
assert.equal(wordFrequency(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a four-frame run above the strict frame bound survives', () => {
// Long enough per event to be plausible dialogue; only a five-event run may use the
// looser animation-frame bound.
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 4, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -258,13 +275,14 @@ test('a four-frame run above the strict frame bound survives', () => {
assert.equal(countLines(db), 4);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('an explicit minRunLength raises the bar', () => {
// Five quarter-second frames qualify under the defaults; a cautious run asking for six
// leaves them alone. Above the strict bound, so the residue rule stays out of it.
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 5, 250));
try {
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
@@ -275,11 +293,12 @@ test('an explicit minRunLength raises the bar', () => {
assert.equal(countLines(db), 5);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('an explicit maxFrameSeconds tightens the frame bound', () => {
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 250));
try {
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: 0.2 });
@@ -288,12 +307,13 @@ test('an explicit maxFrameSeconds tightens the frame bound', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a non-finite maxFrameSeconds falls back to the default bound', () => {
// Six normal-beat lines: Infinity must not turn every event into a "short frame".
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 6, 800));
try {
const summary = cleanupDuplicateSubtitleLines(db, { maxFrameSeconds: Infinity });
@@ -302,11 +322,12 @@ test('a non-finite maxFrameSeconds falls back to the default bound', () => {
assert.equal(countLines(db), 6);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('sampleLimit zero removes bursts but reports no samples', () => {
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db, { sampleLimit: 0 });
@@ -316,11 +337,12 @@ test('sampleLimit zero removes bursts but reports no samples', () => {
assert.equal(countLines(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a short run below every threshold survives', () => {
const { db } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 1_000, 3, 40));
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -329,6 +351,7 @@ test('a short run below every threshold survives', () => {
assert.equal(countLines(db), 3);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
@@ -338,7 +361,7 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
const kanji = karaokeFrames(1, '飛び上がる', 10_000, 20, 60);
const romaji = karaokeFrames(1, 'tobiagaru', 10_001, 20, 60);
const interleaved = [...kanji, ...romaji].sort((a, b) => a.startMs - b.startMs);
const { db } = createDb(interleaved);
const { db, dbPath } = createDb(interleaved);
try {
const summary = cleanupDuplicateSubtitleLines(db);
@@ -349,11 +372,12 @@ test('interleaved dual-line karaoke collapses each line to one row', () => {
assert.equal(wordFrequency(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('the same line in a rewatch session is never merged into the first watch', () => {
const { db } = createDb([
const { db, dbPath } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
...karaokeFrames(2, '飛び上がる', 10_000, 6, 40),
]);
@@ -368,11 +392,12 @@ test('the same line in a rewatch session is never merged into the first watch',
assert.equal(wordFrequency(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a gap between runs splits them', () => {
const { db } = createDb([
const { db, dbPath } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40),
...karaokeFrames(1, '飛び上がる', 60_000, 6, 40),
]);
@@ -384,11 +409,12 @@ test('a gap between runs splits them', () => {
assert.equal(countLines(db), 2);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('a dry run reports what an apply would do and writes nothing', () => {
const { db } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
const { db, dbPath } = createDb(karaokeFrames(1, '飛び上がる', 10_000, 40, 40));
try {
const preview = cleanupDuplicateSubtitleLines(db, { dryRun: true });
@@ -404,13 +430,14 @@ test('a dry run reports what an apply would do and writes nothing', () => {
assert.equal(countLines(db), 1);
} finally {
db.close();
cleanupDbPath(dbPath);
}
});
test('the lookback window leaves older bursts alone', () => {
const recentMs = BASE_MS;
const oldMs = BASE_MS - 40 * DAY_MS;
const { db } = createDb([
const { db, dbPath } = createDb([
...karaokeFrames(1, '飛び上がる', 10_000, 6, 40).map((line) => ({
...line,
createdMs: oldMs,
@@ -435,5 +462,6 @@ test('the lookback window leaves older bursts alone', () => {
} finally {
globalThis.__subminerTestNowMs = undefined;
db.close();
cleanupDbPath(dbPath);
}
});
+16 -65
View File
@@ -125,52 +125,40 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
]);
});
test('mineSentenceCard prefers a canonical primary subtitle snapshot', async () => {
const created: Array<{
sentence: string;
startTime: number;
endTime: number;
secondarySub?: string;
}> = [];
test('mineSentenceCard uses normalized secondary subtitle state instead of raw mpv text', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
let requestedRawSecondaryText = false;
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, startTime, endTime, secondarySub) => {
created.push({ sentence, startTime, endTime, secondarySub });
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '今今今手手手',
currentSubStart: 11.4,
currentSubEnd: 11.8,
currentSecondarySubText: 'English subtitle',
},
primarySubtitle: {
text: '今 手にある物差しでは',
startTime: 11.13,
endTime: 13.83,
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'Your\nmosaic',
requestProperty: async () => {
requestedRawSecondaryText = true;
return 'Your\nYour\nYour\nYour\nmosaic';
},
},
showMpvOsd: () => {},
});
assert.deepEqual(created, [
{
sentence: '今 手にある物差しでは',
startTime: 11.13,
endTime: 13.83,
secondarySub: 'English subtitle',
},
]);
assert.equal(requestedRawSecondaryText, false);
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'Your\nmosaic' }]);
});
test('mineSentenceCard refreshes secondary subtitle text before creating card', async () => {
test('mineSentenceCard omits normalized secondary text that matches the primary subtitle', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
const requestedProperties: string[] = [];
await mineSentenceCard({
ankiIntegration: {
@@ -188,43 +176,6 @@ test('mineSentenceCard refreshes secondary subtitle text before creating card',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: '日本語字幕',
requestProperty: async (name: string) => {
requestedProperties.push(name);
return name === 'secondary-sub-text' ? 'English subtitle' : null;
},
},
showMpvOsd: () => {},
});
assert.deepEqual(requestedProperties, ['secondary-sub-text']);
assert.deepEqual(created, [{ sentence: '日本語字幕', secondarySub: 'English subtitle' }]);
});
test('mineSentenceCard does not fall back to stale cached secondary subtitle after successful refresh', async () => {
const created: Array<{ sentence: string; secondarySub?: string }> = [];
await mineSentenceCard({
ankiIntegration: {
updateLastAddedFromClipboard: async () => {},
triggerFieldGroupingForLastAddedCard: async () => {},
markLastCardAsAudioCard: async () => {},
createSentenceCard: async (sentence, _startTime, _endTime, secondarySub) => {
created.push({ sentence, secondarySub });
return true;
},
},
mpvClient: {
connected: true,
currentSubText: '日本語字幕',
currentSubStart: 10,
currentSubEnd: 12,
currentSecondarySubText: 'stale cached subtitle',
requestProperty: async (name: string) => {
if (name === 'secondary-sub-text') {
return '';
}
return null;
},
},
showMpvOsd: () => {},
});
+7 -20
View File
@@ -129,19 +129,8 @@ function normalizeSecondarySubText(text: unknown, primaryText: string): string |
return trimmed;
}
async function getCurrentSecondarySubTextForSentenceCard(
mpvClient: MpvClientLike,
primaryText: string,
): Promise<string | undefined> {
if (mpvClient.requestProperty) {
try {
const latestSecondaryText = await mpvClient.requestProperty('secondary-sub-text');
return normalizeSecondarySubText(latestSecondaryText, primaryText);
} catch {
// Fall back to the cached secondary subtitle below.
}
}
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, primaryText);
function getCurrentSecondarySubTextForSentenceCard(mpvClient: MpvClientLike): string | undefined {
return normalizeSecondarySubText(mpvClient.currentSecondarySubText, mpvClient.currentSubText);
}
export async function updateLastCardFromClipboard(deps: {
@@ -175,7 +164,6 @@ export async function markLastCardAsAudioCard(deps: {
export async function mineSentenceCard(deps: {
ankiIntegration: AnkiIntegrationLike | null;
mpvClient: MpvClientLike | null;
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
showMpvOsd: (text: string) => void;
}): Promise<boolean> {
const anki = requireAnkiIntegration(deps.ankiIntegration, deps.showMpvOsd);
@@ -186,17 +174,16 @@ export async function mineSentenceCard(deps: {
deps.showMpvOsd('MPV not connected');
return false;
}
const primaryText = deps.primarySubtitle?.text ?? mpvClient.currentSubText;
if (!primaryText) {
if (!mpvClient.currentSubText) {
deps.showMpvOsd('No current subtitle');
return false;
}
const secondarySubText = await getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient);
return await anki.createSentenceCard(
primaryText,
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
mpvClient.currentSubText,
mpvClient.currentSubStart,
mpvClient.currentSubEnd,
secondarySubText,
);
}
+2
View File
@@ -65,6 +65,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
'secondary-sub-visibility',
'sub-visibility',
'sid',
'secondary-sid',
'secondary-sub-delay',
'track-list',
];
+33 -1
View File
@@ -63,6 +63,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
emitSubtitleTiming: (payload) => state.events.push(payload),
emitSecondarySubtitleChange: (payload) => state.events.push(payload),
emitSubtitleTrackChange: (payload) => state.events.push(payload),
emitSecondarySubtitleTrackChange: (payload) => state.events.push(payload),
emitSecondarySubtitleDelayChange: (payload) => state.events.push(payload),
emitSubtitleTrackListChange: (payload) => state.events.push(payload),
getCurrentSubText: () => state.subText,
setCurrentSubText: (text) => {
@@ -158,12 +160,42 @@ test('dispatchMpvProtocolMessage emits subtitle track changes', async () => {
});
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '3' }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sub-delay', data: '0.5' },
deps,
);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'track-list', data: [{ type: 'sub', id: 3 }] },
deps,
);
assert.deepEqual(state.events, [{ sid: 3 }, { trackList: [{ type: 'sub', id: 3 }] }]);
assert.deepEqual(state.events, [
{ sid: 3 },
{ sid: 4 },
{ delay: 0.5 },
{ trackList: [{ type: 'sub', id: 3 }] },
]);
});
test('dispatchMpvProtocolMessage rejects decimal subtitle track IDs', async () => {
const { deps, state } = createDeps();
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: '4.5' }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: '4.5' },
deps,
);
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sid', data: 4.5 }, deps);
await dispatchMpvProtocolMessage(
{ event: 'property-change', name: 'secondary-sid', data: 4.5 },
deps,
);
assert.deepEqual(state.events, [{ sid: null }, { sid: null }, { sid: null }, { sid: null }]);
});
test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => {
+21 -1
View File
@@ -54,6 +54,8 @@ export interface MpvProtocolHandleMessageDeps {
emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
emitSecondarySubtitleChange: (payload: { text: string }) => void;
emitSubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
emitSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
getCurrentSubText: () => string;
setCurrentSubText: (text: string) => void;
@@ -281,7 +283,25 @@ export async function dispatchMpvProtocolMessage(
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null });
deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isInteger(sid) ? sid : null });
} else if (msg.name === 'secondary-sid') {
const sid =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: null;
deps.emitSecondarySubtitleTrackChange({
sid: sid !== null && Number.isInteger(sid) ? sid : null,
});
} else if (msg.name === 'secondary-sub-delay') {
const delay =
typeof msg.data === 'number'
? msg.data
: typeof msg.data === 'string'
? Number(msg.data)
: 0;
deps.emitSecondarySubtitleDelayChange({ delay: Number.isFinite(delay) ? delay : 0 });
} else if (msg.name === 'track-list') {
deps.emitSubtitleTrackListChange({
trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null,
+8
View File
@@ -131,6 +131,8 @@ export interface MpvIpcClientEventMap {
'fullscreen-change': { fullscreen: boolean };
'secondary-subtitle-change': { text: string };
'subtitle-track-change': { sid: number | null };
'secondary-subtitle-track-change': { sid: number | null };
'secondary-subtitle-delay-change': { delay: number };
'subtitle-track-list-change': { trackList: unknown[] | null };
'media-path-change': { path: string };
'media-title-change': { title: string | null };
@@ -438,6 +440,12 @@ export class MpvIpcClient implements MpvClient {
emitSubtitleTrackChange: (payload) => {
this.emit('subtitle-track-change', payload);
},
emitSecondarySubtitleTrackChange: (payload) => {
this.emit('secondary-subtitle-track-change', payload);
},
emitSecondarySubtitleDelayChange: (payload) => {
this.emit('secondary-subtitle-delay-change', payload);
},
emitSubtitleTrackListChange: (payload) => {
this.emit('subtitle-track-list-change', payload);
},
+7 -181
View File
@@ -27,188 +27,17 @@ function cueKey(cue: SubtitleCue): string {
/**
* Identical text over an identical span is redundant however it was authored -- most
* often a layered ASS event stacking a shadow copy under the visible one. When one of
* the duplicates is a recovered canonical cue, that copy survives: dropping it would
* strip the `source` marker and animation envelope the live overlay substitutes on.
* often a layered ASS event stacking a shadow copy under the visible one.
*/
function collapseExactDuplicates(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const survivorByKey = new Map<string, AnnotatedSubtitleCue>();
const keysInOrder: string[] = [];
for (const cue of cues) {
const seen = new Set<string>();
return cues.filter((cue) => {
const key = cueKey(cue);
const existing = survivorByKey.get(key);
if (!existing) {
survivorByKey.set(key, cue);
keysInOrder.push(key);
} else if (!existing.source && cue.source) {
survivorByKey.set(key, cue);
if (seen.has(key)) {
return false;
}
}
return keysInOrder.map((key) => survivorByKey.get(key)!);
}
const SPATIAL_ASS_OVERRIDE_COMMANDS = new Set([
'a',
'an',
'clip',
'iclip',
'move',
'org',
'pbo',
'pos',
'q',
]);
interface RepeatedPhaseRun {
cues: AnnotatedSubtitleCue[];
indices: number[];
}
// A changing override signature alone is weak: two ordinary repeats restyled with
// different colors look identical to a phase pair. Real phase redraws carry a styling
// stack over a full lyric line, and they exist to move a color/highlight boundary
// *within* the line -- so every event also has an override block after visible text
// began. An ordinary restyled repeat carries only a leading block and stays separate.
const MIN_PHASE_EVIDENCE_OVERRIDES = 2;
const MIN_PHASE_TEXT_LENGTH = 4;
function hasMidLineOverrideBlock(rawText: string): boolean {
let sawVisibleText = false;
for (let i = 0; i < rawText.length; i += 1) {
if (rawText[i] === '{') {
const close = rawText.indexOf('}', i);
if (close === -1) {
// Unclosed brace renders as literal text; nothing after it is markup.
return false;
}
if (sawVisibleText) {
return true;
}
i = close;
} else if (!/\s/.test(rawText[i]!)) {
sawVisibleText = true;
}
}
return false;
}
function assStyleKey(cue: AnnotatedSubtitleCue): string {
return `${cue.style}\0${cue.name}\0${cue.layer}`;
}
function spatialOverrideSignature(cue: AnnotatedSubtitleCue): string {
return cue.overrides
.filter((command) => SPATIAL_ASS_OVERRIDE_COMMANDS.has(command.name.toLowerCase()))
.map((command) => `${command.name.toLowerCase()}(${command.args})`)
.join('|');
}
function hasStableSpatialOverrides(run: readonly AnnotatedSubtitleCue[]): boolean {
const firstSignature = spatialOverrideSignature(run[0]!);
return run.every((cue) => spatialOverrideSignature(cue) === firstSignature);
}
function hasDirectPhaseEvidence(run: readonly AnnotatedSubtitleCue[]): boolean {
// Phases redraw one authored line in place. Whatever the animation evidence, a run
// whose spatial placement changes is separate authored occurrences -- two flush
// same-text `\move` signs at different coordinates must never merge.
if (!hasStableSpatialOverrides(run)) {
return false;
}
if (run.every((cue) => hasAssTemporalOverride(cue.overrides))) {
seen.add(key);
return true;
}
if (run.every((cue) => isAnimatedAssEffectKind(cue.effectKind))) {
return true;
}
const [first] = run;
return (
first!.text.replace(/\s+/gu, '').length >= MIN_PHASE_TEXT_LENGTH &&
run.every(
(cue) =>
cue.overrides.length >= MIN_PHASE_EVIDENCE_OVERRIDES &&
hasMidLineOverrideBlock(cue.rawText),
) &&
run.some((cue) => cue.overrideSignature !== first!.overrideSignature)
);
}
function collectRepeatedPhaseRuns(cues: AnnotatedSubtitleCue[]): RepeatedPhaseRun[] {
const runs: RepeatedPhaseRun[] = [];
let start = 0;
while (start < cues.length) {
const first = cues[start]!;
const styleKey = assStyleKey(first);
let end = start;
while (end + 1 < cues.length) {
const current = cues[end]!;
const next = cues[end + 1]!;
const isFlush =
Math.abs(next.startTime - current.endTime) <= DUPLICATE_CUE_GAP_TOLERANCE_SECONDS;
if (
first.source === 'canonical-ass' ||
next.source === 'canonical-ass' ||
next.text !== first.text ||
assStyleKey(next) !== styleKey ||
!isFlush
) {
break;
}
end += 1;
}
if (end > start) {
const indices = Array.from({ length: end - start + 1 }, (_, offset) => start + offset);
runs.push({
cues: indices.map((index) => cues[index]!),
indices,
});
}
start = end + 1;
}
return runs;
}
/**
* Some karaoke scripts redraw one complete lyric for each color/highlight phase. These
* events last far longer than animation frames, but are still one sidebar/history line.
* The events must prove themselves through direct animation metadata or changing
* non-spatial overrides. Plain repeated dialogue and separately positioned signs stay
* intact.
*/
function collapseAnimatedStylePhases(cues: AnnotatedSubtitleCue[]): AnnotatedSubtitleCue[] {
const runs = collectRepeatedPhaseRuns(cues);
if (runs.length === 0) {
return cues;
}
const dropped = new Set<number>();
const extendedEnd = new Map<number, number>();
for (const run of runs) {
if (!hasDirectPhaseEvidence(run.cues)) {
continue;
}
const [firstIndex, ...remainingIndices] = run.indices;
for (const index of remainingIndices) {
dropped.add(index);
}
extendedEnd.set(firstIndex!, Math.max(...run.cues.map((cue) => cue.endTime)));
}
if (dropped.size === 0) {
return cues;
}
return cues.flatMap((cue, index) => {
if (dropped.has(index)) {
return [];
}
const endTime = extendedEnd.get(index);
return endTime !== undefined ? [{ ...cue, endTime }] : [cue];
});
}
@@ -347,8 +176,5 @@ export function mergeDuplicateCues(
cues: AnnotatedSubtitleCue[],
format: SubtitleSourceFormat,
): AnnotatedSubtitleCue[] {
const exactDeduplicated = collapseExactDuplicates(cues);
const phaseDeduplicated =
format === 'ass' ? collapseAnimatedStylePhases(exactDeduplicated) : exactDeduplicated;
return collapseAnimationBursts(phaseDeduplicated, format);
return collapseAnimationBursts(collapseExactDuplicates(cues), format);
}
@@ -327,122 +327,6 @@ test('parseSubtitleCues collapses per-frame karaoke duplicates into one cue', ()
assert.equal(cues[0]!.text, '過ぎ去ってしまう瞬間を');
});
test('parseSubtitleCues collapses long full-line color phases', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:03:49.75,0:03:51.21,OPJP,,0,0,0,,{\\blur0.6\\c&H312D38&\\4c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
'Dialogue: 1,0:03:51.21,0:03:52.25,OPJP,,0,0,0,,{\\blur0.6\\4c&H312D38&\\c&HFFFFFF&}ちゃんと目を{\\4c&HD590FF&}合わせてよ',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 229.75,
endTime: 232.25,
text: 'ちゃんと目を合わせてよ',
},
]);
});
test('parseSubtitleCues keeps ordinary repeated dialogue separate', () => {
// A single restyle tag on a repeated line is how ordinary dialogue gets decorated;
// it is not phase evidence, whatever the line length.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:02.00,OPJP,,0,0,0,,{\\c&H111111&}歌詞',
'Dialogue: 1,0:00:02.00,0:00:03.00,OPJP,,0,0,0,,{\\c&H222222&}歌詞',
'Dialogue: 1,0:00:04.00,0:00:05.00,OPJP,,0,0,0,,{\\c&H333333&}別の歌詞',
'Dialogue: 1,0:00:05.00,0:00:06.00,OPJP,,0,0,0,,{\\c&H444444&}別の歌詞',
'Dialogue: 8,0:00:07.00,0:00:08.00,Text - JP,,0,0,0,,えっ?',
'Dialogue: 8,0:00:08.00,0:00:09.00,Text - JP,,0,0,0,,えっ?',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: '歌詞' },
{ startTime: 2, endTime: 3, text: '歌詞' },
{ startTime: 4, endTime: 5, text: '別の歌詞' },
{ startTime: 5, endTime: 6, text: '別の歌詞' },
{ startTime: 7, endTime: 8, text: 'えっ?' },
{ startTime: 8, endTime: 9, text: 'えっ?' },
]);
});
test('parseSubtitleCues keeps separately positioned temporal signs separate', () => {
// Two flush signs with the same text but different \move paths are separate authored
// occurrences, not phases of one redraw: temporal evidence alone must not merge them.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Sign,,0,0,0,,{\\move(100,100,200,100)}立入禁止',
'Dialogue: 0,0:00:02.00,0:00:03.00,Sign,,0,0,0,,{\\move(500,400,600,400)}立入禁止',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: '立入禁止' },
{ startTime: 2, endTime: 3, text: '立入禁止' },
]);
});
test('parseSubtitleCues keeps richly styled ordinary repeats separate', () => {
// Blur plus a changing color is still an ordinary restyle. Phase redraws are
// recognized by the color/highlight boundary moving *within* the line, which these
// leading-block-only events do not have.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}待ってよ',
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}待ってよ',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: '待ってよ' },
{ startTime: 2, endTime: 3, text: '待ってよ' },
]);
});
test('parseSubtitleCues keeps canonical metadata when an identical plain cue exists', () => {
// A plain dialogue line can share exact timing and text with a recovered canonical
// cue from another style. The canonical copy must win the exact-duplicate collapse,
// or the live overlay loses the marker it substitutes on.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:05.00,0:00:08.00,Plain,,0,0,0,,ライン',
'Comment: 0,0:00:05.00,0:00:08.00,OP,,0,0,0,,ライン',
'Dialogue: 0,0:00:05.00,0:00:05.04,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}ライン',
'Dialogue: 0,0:00:05.04,0:00:05.08,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}ライン',
'Dialogue: 0,0:00:05.08,0:00:08.00,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}ライン',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 5,
endTime: 8,
text: 'ライン',
source: 'canonical-ass',
animationStartTime: 5,
animationEndTime: 8,
},
]);
});
test('parseSubtitleCues keeps short styled repeats separate even with richer styling', () => {
// Two ordinary えっ lines restyled with different colors are two utterances, not two
// phases of one lyric: short text never satisfies the changing-override evidence path.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:01.00,0:00:02.00,Dial,,0,0,0,,{\\blur0.4\\c&H111111&}えっ',
'Dialogue: 0,0:00:02.00,0:00:03.00,Dial,,0,0,0,,{\\blur0.4\\c&H222222&}えっ',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{ startTime: 1, endTime: 2, text: 'えっ' },
{ startTime: 2, endTime: 3, text: 'えっ' },
]);
});
test('parseSubtitleCues keeps back-to-back plain dialogue repeats separate', () => {
// Several characters greeting in turn: distinct utterances that happen to abut.
const content = [
@@ -473,194 +357,6 @@ test('parseSubtitleCues collapses exact duplicate cues even without effect tags'
assert.equal(cues.length, 1);
});
test('parseSubtitleCues replaces generated glyph animation with its timed canonical comment', () => {
// Aegisub automation commonly keeps the authored lyric as a Comment and emits
// multiple moving Dialogue layers for every glyph. This mirrors the MyGO ED script:
// three entrance copies followed by three exit copies for each character.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.20,0:00:03.80,ED_JP,,0,0,0,,{\\fad(480,480)}今 手にある',
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(10,20,100,200)\\t(0,600,\\fscx100)}今',
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(30,40,100,200)\\t(0,600,\\fscx100)}今',
'Dialogue: 0,0:00:00.80,0:00:01.50,ED_JP,,0,0,0,,{\\move(50,60,100,200)\\t(0,600,\\fscx100)}今',
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,20,30)\\t(2000,2600,\\blur20)}今',
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,40,50)\\t(2000,2600,\\blur20)}今',
'Dialogue: 1,0:00:01.40,0:00:04.20,ED_JP,,0,0,0,,{\\move(100,200,60,70)\\t(2000,2600,\\blur20)}今',
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(10,20,140,200)\\t(0,600,\\fscx100)}手',
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(30,40,140,200)\\t(0,600,\\fscx100)}手',
'Dialogue: 0,0:00:00.86,0:00:01.56,ED_JP,,0,0,0,,{\\move(50,60,140,200)\\t(0,600,\\fscx100)}手',
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,20,30)\\t(2000,2600,\\blur20)}手',
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,40,50)\\t(2000,2600,\\blur20)}手',
'Dialogue: 1,0:00:01.46,0:00:04.26,ED_JP,,0,0,0,,{\\move(140,200,60,70)\\t(2000,2600,\\blur20)}手',
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(10,20,180,200)\\t(0,600,\\fscx100)}にある',
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(30,40,180,200)\\t(0,600,\\fscx100)}にある',
'Dialogue: 0,0:00:00.92,0:00:01.62,ED_JP,,0,0,0,,{\\move(50,60,180,200)\\t(0,600,\\fscx100)}にある',
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,20,30)\\t(2000,2600,\\blur20)}にある',
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,40,50)\\t(2000,2600,\\blur20)}にある',
'Dialogue: 1,0:00:01.52,0:00:04.32,ED_JP,,0,0,0,,{\\move(180,200,60,70)\\t(2000,2600,\\blur20)}にある',
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial_JP,,0,0,0,,普通の会話',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(cues, [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
// Entrance frames start before and exit frames end after the authored timing.
animationStartTime: 0.8,
animationEndTime: 4.32,
},
{ startTime: 6, endTime: 8, text: '普通の会話' },
]);
});
test('parseSubtitleCues recovers a full Dialogue line surrounding generated fragments', () => {
// Some scripts do not retain the authored line as a Comment. Instead, brief entrance
// and exit events contain the complete line around a long run of generated syllables.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:01.15,ED Romaji,,0,0,0,fx,{\\move(100,40,60,40)}toki yo ugokidase',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(0,300,\\c&HFFFFFF&)}to',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(300,500,\\c&HFFFFFF&)}ki',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(500,700,\\c&HFFFFFF&)}yo',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(700,900,\\c&HFFFFFF&)}u',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(900,1100,\\c&HFFFFFF&)}go',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1100,1300,\\c&HFFFFFF&)}ki',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1300,1500,\\c&HFFFFFF&)}da',
'Dialogue: 1,0:00:01.15,0:00:03.00,ED Romaji,,0,0,0,fx,{\\t(1500,1800,\\c&HFFFFFF&)}se',
'Dialogue: 1,0:00:03.00,0:00:03.15,ED Romaji,,0,0,0,fx,{\\move(60,40,20,40)}toki yo ugokidase',
'Dialogue: 0,0:00:06.00,0:00:08.00,Default,,0,0,0,,Ordinary dialogue',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 1,
endTime: 3.15,
text: 'toki yo ugokidase',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 3.15,
},
{ startTime: 6, endTime: 8, text: 'Ordinary dialogue' },
]);
});
test('parseSubtitleCues does not promote a short animated fragment as a complete line', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}my',
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(100,100)\\t(0,100,\\fscx120)}m',
'Dialogue: 1,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
'Dialogue: 2,0:00:01.00,0:00:03.00,OP English,,0,0,0,,{\\pos(120,100)\\t(20,120,\\fscx120)}y',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.equal(
cues.some((cue) => cue.source === 'canonical-ass'),
false,
);
});
test('parseSubtitleCues ignores timed comments without a matching animated dialogue cluster', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,編集メモ',
'Comment: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
'Dialogue: 0,0:00:01.00,0:00:03.00,Dial_JP,,0,0,0,,通常の字幕',
'Dialogue: 0,0:00:04.00,0:00:06.00,Dial_JP,,0,0,0,,別案の字幕',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(cues, [
{ startTime: 1, endTime: 3, text: '通常の字幕' },
{ startTime: 4, endTime: 6, text: '別案の字幕' },
]);
});
test('parseAssCues returns recovered canonical cues in chronological order', () => {
// Recovery appends recovered cues after surviving dialogue; the bare parseAssCues
// export must still come back time-ordered.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 0,0:00:06.00,0:00:08.00,Dial,,0,0,0,,あとのセリフ',
'Comment: 0,0:00:01.20,0:00:03.80,OP,,0,0,0,,雨が上がっても',
'Dialogue: 0,0:00:01.20,0:00:01.24,OP,,0,0,0,,{\\pos(1,1)\\clip(m 1 1)}雨が上がっても',
'Dialogue: 0,0:00:01.24,0:00:01.28,OP,,0,0,0,,{\\pos(1,1)\\clip(m 2 2)}雨が上がっても',
'Dialogue: 0,0:00:01.28,0:00:03.80,OP,,0,0,0,,{\\pos(1,1)\\clip(m 3 3)}雨が上がっても',
].join('\n');
assert.deepEqual(
parseAssCues(content).map((cue) => cue.startTime),
[1.2, 6],
);
});
test('parseSubtitleCues withdraws a recovery whose owner is claimed by a later candidate', () => {
// The exit boundary event appears first in the file and recovers a canonical cue from
// its own small cluster. The entrance candidate then proves that exit event was a
// generated frame of the full animation; the earlier recovery is a duplicate of the
// same authored line and must not survive alongside it.
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Dialogue: 1,0:00:14.00,0:00:14.20,ED,,0,0,0,,{\\move(100,200,20,30)}ABCDEFGH',
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(0,300,\\c&HFFFFFF&)}ABC',
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(300,600,\\c&HFFFFFF&)}DEF',
'Dialogue: 1,0:00:13.50,0:00:14.50,ED,,0,0,0,,{\\t(600,900,\\c&HFFFFFF&)}GH',
'Dialogue: 0,0:00:10.00,0:00:10.20,ED,,0,0,0,,{\\move(10,20,100,200)}ABCDEFGH',
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(0,300,\\fscx100)}ABC',
'Dialogue: 0,0:00:10.00,0:00:12.00,ED,,0,0,0,,{\\t(300,600,\\fscx100)}DEF',
'Dialogue: 0,0:00:10.00,0:00:13.40,ED,,0,0,0,,{\\t(600,900,\\fscx100)}GH',
].join('\n');
assert.deepEqual(parseSubtitleCues(content, 'test.ass'), [
{
startTime: 10,
endTime: 14.2,
text: 'ABCDEFGH',
source: 'canonical-ass',
animationStartTime: 10,
animationEndTime: 14.2,
},
]);
});
test('parseSubtitleCues recovers canonical comments from generated clip frames', () => {
const content = [
'[Events]',
'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text',
'Comment: 0,0:00:01.00,0:00:03.00,OP_JP,,0,0,0,,雨が上がっても',
'Dialogue: 0,0:00:01.00,0:00:01.04,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 1 1)}雨が上がっても',
'Dialogue: 0,0:00:01.04,0:00:01.08,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 2 2)}雨が上がっても',
'Dialogue: 0,0:00:01.08,0:00:03.00,OP_JP,,0,0,0,,{\\pos(960,1068)\\clip(m 3 3)}雨が上がっても',
].join('\n');
const cues = parseSubtitleCues(content, 'test.ass');
assert.deepEqual(cues, [
{
startTime: 1,
endTime: 3,
text: '雨が上がっても',
source: 'canonical-ass',
animationStartTime: 1,
animationEndTime: 3,
},
]);
});
test('parseSubtitleCues collapses tag-less animation frames in converted SRT', () => {
// ASS -> SRT conversion drops override tags, so only the ~0.04s frame timing remains.
const lines = ['1', '00:00:07,870 --> 00:00:07,910', 'Kaguya Wants to be Confessed to', ''];
+10 -368
View File
@@ -6,21 +6,12 @@ import {
type AssEffectKind,
type AssOverrideCommand,
} from './ass-text';
import { hasAssAnimationEvidence, mergeDuplicateCues } from './subtitle-cue-dedup';
import { mergeDuplicateCues } from './subtitle-cue-dedup';
export interface SubtitleCue {
startTime: number;
endTime: number;
text: string;
/** A complete authored line recovered from matching generated ASS animation events. */
source?: 'canonical-ass';
/**
* Full span of the generated animation events a canonical cue replaced. Entrance and
* exit frames routinely run past the authored `startTime`/`endTime`, so live-text
* matching must use this envelope while display and history keep the authored timing.
*/
animationStartTime?: number;
animationEndTime?: number;
}
/**
@@ -28,8 +19,7 @@ export interface SubtitleCue {
* Deduplication needs the authoring context -- which style the line belongs to, which
* override commands it carries, whether the `Effect` column was set -- to tell a karaoke
* burst apart from two characters saying the same word in turn. None of it is meaningful
* outside the parser, so the public API exposes only timing, text, and the optional
* canonical-source marker used by live subtitle consumers.
* outside the parser, so the public API stays `{startTime, endTime, text}`.
*/
export interface AnnotatedSubtitleCue extends SubtitleCue {
/** Text exactly as authored, override blocks and all. */
@@ -80,11 +70,7 @@ function sanitizeSubtitleCueText(text: string): string {
}
function toPublicCues(cues: AnnotatedSubtitleCue[]): SubtitleCue[] {
return cues.map(({ startTime, endTime, text, source, animationStartTime, animationEndTime }) =>
source
? { startTime, endTime, text, source, animationStartTime, animationEndTime }
: { startTime, endTime, text },
);
return cues.map(({ startTime, endTime, text }) => ({ startTime, endTime, text }));
}
function parseAnnotatedSrtCues(content: string): AnnotatedSubtitleCue[] {
@@ -152,13 +138,7 @@ export function parseSrtCues(content: string): SubtitleCue[] {
const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/;
const ASS_FORMAT_PREFIX = 'Format:';
const ASS_DIALOGUE_PREFIX = 'Dialogue:';
const ASS_COMMENT_PREFIX = 'Comment:';
const ASS_NAME_FIELD_ALIASES = ['name', 'actor'];
const CANONICAL_MATCH_MARGIN_SECONDS = 1;
const MIN_CANONICAL_ANIMATION_EVENTS = 3;
// A tiny animated fragment can itself be composed from still smaller glyph events. It is
// not enough evidence that the fragment represents an authored line boundary.
const MIN_CANONICAL_DIALOGUE_TEXT_LENGTH = 4;
function parseAssTimestamp(raw: string): number | null {
const match = ASS_TIMING_PATTERN.exec(raw.trim());
@@ -186,333 +166,10 @@ function findFieldIndex(formatFields: string[], aliases: string[]): number {
return -1;
}
interface ParsedAssEvents {
dialogue: AnnotatedSubtitleCue[];
comments: AnnotatedSubtitleCue[];
}
// Every candidate line re-reads the compacted text of each event in its window, so on
// fragment-heavy scripts the same event compacts thousands of times without this cache.
const compactMatchTextCache = new WeakMap<AnnotatedSubtitleCue, string>();
function compactAssMatchText(text: string): string {
return text.replace(/\s+/gu, '');
}
function compactCueMatchText(cue: AnnotatedSubtitleCue): string {
let compact = compactMatchTextCache.get(cue);
if (compact === undefined) {
compact = compactAssMatchText(cue.text);
compactMatchTextCache.set(cue, compact);
}
return compact;
}
function assEventGroupKey(cue: AnnotatedSubtitleCue): string {
return `${cue.style}\0${cue.name}`;
}
/**
* Windowed lookup over one style/name group. Every candidate line queries its time
* neighborhood, and fragment-heavy scripts put thousands of candidates in one group, so
* a linear rescan per candidate is quadratic in practice. Events are sorted by start
* once; `prefixMaxEnd` lets the backward walk stop as soon as no earlier event can still
* reach the window.
*/
interface AssEventGroupIndex {
byStart: AnnotatedSubtitleCue[];
prefixMaxEnd: number[];
}
function buildAssEventGroupIndex(events: readonly AnnotatedSubtitleCue[]): AssEventGroupIndex {
const byStart = [...events].sort((a, b) => a.startTime - b.startTime || a.order - b.order);
const prefixMaxEnd: number[] = [];
let maxEnd = -Infinity;
for (const event of byStart) {
maxEnd = Math.max(maxEnd, event.endTime);
prefixMaxEnd.push(maxEnd);
}
return { byStart, prefixMaxEnd };
}
/** Group events overlapping `[startTime, endTime]`, returned in source order. */
function eventsOverlappingWindow(
index: AssEventGroupIndex,
startTime: number,
endTime: number,
): AnnotatedSubtitleCue[] {
const { byStart, prefixMaxEnd } = index;
let low = 0;
let high = byStart.length;
while (low < high) {
const mid = (low + high) >>> 1;
if (byStart[mid]!.startTime <= endTime) {
low = mid + 1;
} else {
high = mid;
}
}
const matches: AnnotatedSubtitleCue[] = [];
for (let i = low - 1; i >= 0 && prefixMaxEnd[i]! >= startTime; i -= 1) {
if (byStart[i]!.endTime >= startTime) {
matches.push(byStart[i]!);
}
}
return matches.sort((a, b) => a.order - b.order);
}
interface FragmentGroup {
text: string;
events: AnnotatedSubtitleCue[];
}
function fragmentPlacementAnchors(event: AnnotatedSubtitleCue): Set<string> {
const anchors = new Set<string>();
for (const command of event.overrides) {
const name = command.name.toLowerCase();
const args = command.args.split(',').map((value) => value.trim());
if (name === 'pos' && args.length >= 2) {
anchors.add(`pos:${args[0]},${args[1]}`);
} else if (name === 'move' && args.length >= 4) {
anchors.add(`move:${args[0]},${args[1]}`);
anchors.add(`move:${args[2]},${args[3]}`);
}
}
return anchors;
}
function isRepeatedFragmentCopy(
previous: AnnotatedSubtitleCue,
current: AnnotatedSubtitleCue,
): boolean {
const previousAnchors = fragmentPlacementAnchors(previous);
if ([...fragmentPlacementAnchors(current)].some((anchor) => previousAnchors.has(anchor))) {
return true;
}
return (
previous.startTime === current.startTime &&
previous.endTime === current.endTime &&
previous.overrideSignature === current.overrideSignature
);
}
function groupConsecutiveAssFragments(events: readonly AnnotatedSubtitleCue[]): FragmentGroup[] {
const groups: FragmentGroup[] = [];
for (const event of events) {
const text = compactCueMatchText(event);
if (!text) {
continue;
}
const previous = groups.at(-1);
if (
previous?.text === text &&
previous.events.some((previousEvent) => isRepeatedFragmentCopy(previousEvent, event))
) {
previous.events.push(event);
} else {
groups.push({ text, events: [event] });
}
}
return groups;
}
function findCanonicalFragmentEvents(
events: readonly AnnotatedSubtitleCue[],
canonicalText: string,
): AnnotatedSubtitleCue[] {
const groups = groupConsecutiveAssFragments(events);
const matches = new Set<AnnotatedSubtitleCue>();
for (let start = 0; start < groups.length; start += 1) {
let combined = '';
for (let end = start; end < groups.length; end += 1) {
const group = groups[end]!;
// A complete rendered copy cannot prove that the neighboring events are its
// fragments. Exact full-line animation is handled separately for comments.
if (group.text.length >= canonicalText.length) {
break;
}
const next = combined + group.text;
if (!canonicalText.startsWith(next)) {
break;
}
combined = next;
if (combined !== canonicalText) {
continue;
}
for (let index = start; index <= end; index += 1) {
for (const event of groups[index]!.events) {
matches.add(event);
}
}
start = end;
break;
}
}
return [...matches];
}
function matchingAssAnimationEvents(options: {
candidate: AnnotatedSubtitleCue;
group: AssEventGroupIndex;
allowFullLineFrames: boolean;
}): AnnotatedSubtitleCue[] {
const canonicalText = compactCueMatchText(options.candidate);
// The group index already restricts to the candidate's style and name.
const nearby = eventsOverlappingWindow(
options.group,
options.candidate.startTime - CANONICAL_MATCH_MARGIN_SECONDS,
options.candidate.endTime + CANONICAL_MATCH_MARGIN_SECONDS,
);
const fragments = findCanonicalFragmentEvents(nearby, canonicalText);
if (fragments.length >= MIN_CANONICAL_ANIMATION_EVENTS && hasAssAnimationEvidence(fragments)) {
return fragments;
}
if (!options.allowFullLineFrames) {
return [];
}
const fullLineFrames = nearby.filter((cue) => compactCueMatchText(cue) === canonicalText);
return fullLineFrames.length >= MIN_CANONICAL_ANIMATION_EVENTS &&
hasAssAnimationEvidence(fullLineFrames)
? fullLineFrames
: [];
}
// Reductions rather than `Math.min(...events)`: one generated line can carry an
// unbounded number of events, and spreading them all as arguments risks the engine's
// argument-count limit.
function earliestStartTime(events: readonly AnnotatedSubtitleCue[], seed = Infinity): number {
return events.reduce((earliest, event) => Math.min(earliest, event.startTime), seed);
}
function latestEndTime(events: readonly AnnotatedSubtitleCue[], seed = -Infinity): number {
return events.reduce((latest, event) => Math.max(latest, event.endTime), seed);
}
function includeCanonicalBoundaryEvents(options: {
candidate: AnnotatedSubtitleCue;
group: AssEventGroupIndex;
animationEvents: readonly AnnotatedSubtitleCue[];
}): AnnotatedSubtitleCue[] {
const canonicalText = compactCueMatchText(options.candidate);
const startTime = earliestStartTime(options.animationEvents);
const endTime = latestEndTime(options.animationEvents);
return eventsOverlappingWindow(
options.group,
startTime - CANONICAL_MATCH_MARGIN_SECONDS,
endTime + CANONICAL_MATCH_MARGIN_SECONDS,
).filter((cue) => compactCueMatchText(cue) === canonicalText);
}
function recoverCanonicalAssEvents({
dialogue,
comments,
}: ParsedAssEvents): AnnotatedSubtitleCue[] {
const recovered: AnnotatedSubtitleCue[] = [];
const suppressed = new Set<AnnotatedSubtitleCue>();
// A recovery is only as good as its owning event. When a later candidate proves that
// an earlier candidate was itself a generated frame of its animation, the earlier
// recovery is a duplicate of the same authored line and must be withdrawn.
const recoveredByOwner = new Map<AnnotatedSubtitleCue, AnnotatedSubtitleCue>();
const withdrawn = new Set<AnnotatedSubtitleCue>();
const eventsByGroup = new Map<string, AnnotatedSubtitleCue[]>();
for (const cue of dialogue) {
const key = assEventGroupKey(cue);
const group = eventsByGroup.get(key);
if (group) {
group.push(cue);
} else {
eventsByGroup.set(key, [cue]);
}
}
const indexByGroup = new Map<string, AssEventGroupIndex>();
for (const [key, events] of eventsByGroup) {
indexByGroup.set(key, buildAssEventGroupIndex(events));
}
const emptyGroupIndex: AssEventGroupIndex = { byStart: [], prefixMaxEnd: [] };
const candidates = [
...comments.map((cue) => ({ cue, kind: 'comment' as const })),
...dialogue
.filter(
(cue) =>
compactCueMatchText(cue).length >= MIN_CANONICAL_DIALOGUE_TEXT_LENGTH &&
hasAssAnimationEvidence([cue]),
)
.sort((left, right) => right.text.length - left.text.length || left.order - right.order)
.map((cue) => ({ cue, kind: 'dialogue' as const })),
];
for (const { cue: candidate, kind } of candidates) {
if (candidate.endTime <= candidate.startTime || suppressed.has(candidate)) {
continue;
}
const canonicalText = compactCueMatchText(candidate);
if (!canonicalText) {
continue;
}
const group = indexByGroup.get(assEventGroupKey(candidate)) ?? emptyGroupIndex;
const animationEvents = matchingAssAnimationEvents({
candidate,
group,
allowFullLineFrames: kind === 'comment',
});
if (animationEvents.length === 0) {
continue;
}
const boundaryEvents = includeCanonicalBoundaryEvents({
candidate,
group,
animationEvents,
});
const generatedEvents = [...new Set([...animationEvents, ...boundaryEvents])];
const animationStartTime = earliestStartTime(generatedEvents, candidate.startTime);
const animationEndTime = latestEndTime(generatedEvents, candidate.endTime);
const startTime = kind === 'comment' ? candidate.startTime : animationStartTime;
const endTime = kind === 'comment' ? candidate.endTime : animationEndTime;
const recoveredCue: AnnotatedSubtitleCue = {
...candidate,
startTime,
endTime,
animationStartTime,
animationEndTime,
source: 'canonical-ass',
};
recovered.push(recoveredCue);
recoveredByOwner.set(candidate, recoveredCue);
for (const event of generatedEvents) {
suppressed.add(event);
if (event === candidate) {
continue;
}
const priorRecovery = recoveredByOwner.get(event);
if (priorRecovery) {
// No text is lost by withdrawing: a fragment claim means the withdrawn line is
// a contiguous piece of this candidate's text, and a boundary claim means the
// texts are equal, so the surviving canonical cue always contains it.
withdrawn.add(priorRecovery);
}
}
}
const survivingRecovered = recovered.filter((cue) => !withdrawn.has(cue));
if (survivingRecovered.length === 0) {
return dialogue;
}
return [...dialogue.filter((cue) => !suppressed.has(cue)), ...survivingRecovered].sort(
(a, b) => a.startTime - b.startTime || a.endTime - b.endTime || a.order - b.order,
);
}
function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
const cues: AnnotatedSubtitleCue[] = [];
const comments: AnnotatedSubtitleCue[] = [];
const lines = content.split(/\r?\n/);
let inEventsSection = false;
let eventOrder = 0;
const fieldIndex = {
start: -1,
end: -1,
@@ -565,12 +222,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
continue;
}
const eventPrefix = trimmed.startsWith(ASS_DIALOGUE_PREFIX)
? ASS_DIALOGUE_PREFIX
: trimmed.startsWith(ASS_COMMENT_PREFIX)
? ASS_COMMENT_PREFIX
: null;
if (!eventPrefix) {
if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) {
continue;
}
@@ -578,7 +230,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
continue;
}
const fields = trimmed.slice(eventPrefix.length).split(',');
const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(',');
if (
fieldIndex.start >= fields.length ||
fieldIndex.end >= fields.length ||
@@ -602,7 +254,7 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
const effect = readField(fields, fieldIndex.effect);
const layer = Number(readField(fields, fieldIndex.layer));
const overrides = collectAssOverrideCommands(rawText);
const cue: AnnotatedSubtitleCue = {
cues.push({
startTime,
endTime,
text,
@@ -614,21 +266,11 @@ function parseAnnotatedAssEvents(content: string): ParsedAssEvents {
effectKind: parseAssEffectField(effect),
overrides,
overrideSignature: assOverrideSignature(overrides),
order: eventOrder,
};
eventOrder += 1;
if (eventPrefix === ASS_COMMENT_PREFIX) {
comments.push(cue);
} else {
cues.push(cue);
}
order: cues.length,
});
}
return { dialogue: cues, comments };
}
function parseAnnotatedAssCues(content: string): AnnotatedSubtitleCue[] {
return recoverCanonicalAssEvents(parseAnnotatedAssEvents(content));
return cues;
}
export function parseAssCues(content: string): SubtitleCue[] {
+40 -38
View File
@@ -235,7 +235,6 @@ import {
createCycleSecondarySubModeRuntimeHandler,
} from './main/runtime/domains/mpv';
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
import { resolveCanonicalPrimarySubtitle } from './main/runtime/primary-subtitle-text';
import {
createBuildCopyCurrentSubtitleMainDepsHandler,
createBuildHandleMineSentenceDigitMainDepsHandler,
@@ -528,6 +527,7 @@ import {
createRefreshSubtitlePrefetchFromActiveTrackHandler,
createResolveActiveSubtitleSidebarSourceHandler,
} from './main/runtime/subtitle-prefetch-runtime';
import { createSecondarySubtitleTrackController } from './main/runtime/secondary-subtitle-track';
import {
createCreateAnilistSetupWindowHandler,
createCreateConfigSettingsWindowHandler,
@@ -1807,42 +1807,10 @@ async function openYoutubeTrackPickerFromPlayback(): Promise<void> {
let appTray: Tray | null = null;
let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null = null;
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: payload.text,
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues,
});
return {
...payload,
startTime: canonical?.startTime ?? appState.mpvClient?.currentSubStart ?? null,
endTime: canonical?.endTime ?? appState.mpvClient?.currentSubEnd ?? null,
};
}
function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | null {
const canonical = resolveCanonicalPrimarySubtitle({
liveText: appState.mpvClient?.currentSubText ?? '',
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
cues: appState.activeParsedSubtitleCues,
});
// Same validity bar as the live capture path: an unusable canonical span must fall
// back rather than hand mining an empty line or an inverted range.
const canonicalText = canonical?.text.trim();
if (
!canonical ||
!canonicalText ||
!Number.isFinite(canonical.startTime) ||
!Number.isFinite(canonical.endTime) ||
canonical.endTime <= canonical.startTime
) {
return captureLiveSubtitleMiningContext(appState.mpvClient);
}
return {
source: 'overlay',
text: canonicalText,
startTime: canonical.startTime,
endTime: canonical.endTime,
capturedAtMs: Date.now(),
startTime: appState.mpvClient?.currentSubStart ?? null,
endTime: appState.mpvClient?.currentSubEnd ?? null,
};
}
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
@@ -1976,7 +1944,7 @@ const autoplaySubtitlePrimingRuntime = createAutoplaySubtitlePrimingRuntime({
getLastObservedTimePos: () => lastObservedTimePos,
getVisibleOverlayVisible: () => overlayManager.getVisibleOverlayVisible(),
emitSecondarySubtitle: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
secondarySubtitleTrackController.handleLiveText(text);
},
initSubtitlePrefetch: (sourcePath, currentTimePos, sourceKey) =>
subtitlePrefetchInitController.initSubtitlePrefetch(sourcePath, currentTimePos, sourceKey),
@@ -2034,6 +2002,24 @@ const resolveActiveSubtitleSidebarSourceHandler = createResolveActiveSubtitleSid
logDebug: (message) => logger.debug(message),
});
const secondarySubtitleTrackController = createSecondarySubtitleTrackController({
getMpvClient: () => appState.mpvClient,
getCurrentTimePos: () => appState.mpvClient?.currentTimePos ?? lastObservedTimePos,
resolveSubtitleSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
loadSubtitleSourceText,
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
setCurrentSecondaryText: (text) => {
if (appState.mpvClient) {
appState.mpvClient.currentSecondarySubText = text;
}
},
broadcastSecondaryText: (text) => {
overlayManager.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
logDebug: (message) => logger.debug(message),
logWarn: (message, error) => logger.warn(message, error),
});
const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
@@ -4416,6 +4402,7 @@ const {
onMpvConnected: () => {
maybeStartOverlayLoadingOsd();
flushQueuedMpvOsdNotifications();
secondarySubtitleTrackController.scheduleRefresh(0);
if (appState.sessionBindingsInitialized) {
sendMpvCommandRuntime(appState.mpvClient, [
'script-message',
@@ -4434,6 +4421,9 @@ const {
broadcastToOverlayWindows: (channel, payload) => {
overlayManager.broadcastToOverlayWindows(channel, payload);
},
onSecondarySubtitleChange: (text) => {
secondarySubtitleTrackController.handleLiveText(text);
},
getImmediateSubtitlePayload: (text) => subtitleProcessingController.consumeCachedSubtitle(text),
emitImmediateSubtitle: (payload) => {
emitSubtitlePayload(payload);
@@ -4467,6 +4457,7 @@ const {
appState.activeParsedSubtitleMediaPath,
);
if ((normalizedPath || null) !== previousPath) {
secondarySubtitleTrackController.reset();
const resetSubtitlePayload = { text: '', tokens: null };
const frequencyDictionary = configService.getConfig().subtitleStyle.frequencyDictionary;
const frequencyOptions = {
@@ -4501,6 +4492,7 @@ const {
void youtubeMediaCachePlaybackRuntime.handleMediaPathChange(path);
if (path) {
ensureImmersionTrackerStarted();
secondarySubtitleTrackController.scheduleRefresh();
void subtitlePrefetchRuntime.refreshSubtitlePrefetchFromActiveTrack();
// Retry after a short delay because MPV can populate track-list after path.
subtitlePrefetchRuntime.scheduleSubtitlePrefetchRefresh(500);
@@ -4555,6 +4547,7 @@ const {
subtitlePrefetchService.onSeek(time);
}
lastObservedTimePos = time;
secondarySubtitleTrackController.handleTimePos(time);
},
onFullscreenChange: (fullscreen) => {
cancelLinuxMpvFullscreenOverlayRefreshBurst = updateLinuxMpvFullscreenOverlayRefreshBurst(
@@ -4582,6 +4575,13 @@ const {
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
},
onSecondarySubtitleTrackChange: () => {
secondarySubtitleTrackController.handleTrackChange();
secondarySubtitleTrackController.scheduleRefresh(0);
},
onSecondarySubtitleDelayChange: (delay) => {
secondarySubtitleTrackController.handleDelayChange(delay);
},
onSubtitleTrackListChange: (trackList) => {
const diagnostics = buildSubtitleTrackDiagnostics(
lastObservedPrimarySubtitleTrackId,
@@ -4595,6 +4595,7 @@ const {
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
}
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
secondarySubtitleTrackController.scheduleRefresh(0);
autoplaySubtitlePrimingRuntime.scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
},
@@ -5264,7 +5265,6 @@ const markLastCardAsAudioCardHandler = createMarkLastCardAsAudioCardHandler(
const buildMineSentenceCardMainDepsHandler = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => appState.ankiIntegration,
getMpvClient: () => appState.mpvClient,
getPrimarySubtitle: () => captureCurrentPrimarySubtitleMiningContext(),
showMpvOsd: (text) => overlayNotificationsRuntime.showConfiguredStatusNotification(text),
mineSentenceCardCore,
recordCardsMined: (count, noteIds) => {
@@ -5578,7 +5578,9 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
// live mpv sub timings at lookup time so media generation clips the mined line even
// when extraction finishes long after playback has moved on.
recordSubtitleMiningContext: (context) =>
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
recordSubtitleMiningContext(
context ?? captureLiveSubtitleMiningContext(appState.mpvClient),
),
quitApp: () => requestAppQuit(),
toggleVisibleOverlay: () => toggleVisibleOverlay(),
tokenizeCurrentSubtitle: async () => {
@@ -64,17 +64,11 @@ test('anki action main deps builders map callbacks', async () => {
const mine = createBuildMineSentenceCardMainDepsHandler({
getAnkiIntegration: () => ({ enabled: true }),
getMpvClient: () => ({ connected: true }),
getPrimarySubtitle: () => ({ text: '正式な字幕', startTime: 1, endTime: 3 }),
showMpvOsd: (text) => calls.push(`mine:${text}`),
mineSentenceCardCore: async () => true,
recordCardsMined: (count) => calls.push(`cards:${count}`),
})();
assert.deepEqual(mine.getMpvClient(), { connected: true });
assert.deepEqual(mine.getPrimarySubtitle?.(), {
text: '正式な字幕',
startTime: 1,
endTime: 3,
});
mine.showMpvOsd('m');
await mine.mineSentenceCardCore({
ankiIntegration: { enabled: true },
+1 -7
View File
@@ -1,4 +1,4 @@
import type { createRefreshKnownWordCacheHandler, PrimarySubtitle } from './anki-actions';
import type { createRefreshKnownWordCacheHandler } from './anki-actions';
type RefreshKnownWordCacheMainDeps = Parameters<typeof createRefreshKnownWordCacheHandler>[0];
@@ -72,12 +72,10 @@ export function createBuildMarkLastCardAsAudioCardMainDepsHandler<TAnki>(deps: {
export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
getPrimarySubtitle?: () => PrimarySubtitle | null;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
@@ -85,14 +83,10 @@ export function createBuildMineSentenceCardMainDepsHandler<TAnki, TMpv>(deps: {
return () => ({
getAnkiIntegration: () => deps.getAnkiIntegration(),
getMpvClient: () => deps.getMpvClient(),
...(deps.getPrimarySubtitle
? { getPrimarySubtitle: () => deps.getPrimarySubtitle?.() ?? null }
: {}),
showMpvOsd: (text: string) => deps.showMpvOsd(text),
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => deps.mineSentenceCardCore(options),
recordCardsMined: (count: number, noteIds?: number[]) => deps.recordCardsMined(count, noteIds),
-17
View File
@@ -87,20 +87,3 @@ test('mine sentence handler records mined cards only when core returns true', as
await mineSentenceCard();
assert.deepEqual(calls, ['osd:mine', 'osd:mine', 'cards:1']);
});
test('mine sentence handler forwards the canonical primary subtitle snapshot', async () => {
const primarySubtitle = { text: '正式な字幕', startTime: 1, endTime: 3 };
const mineSentenceCard = createMineSentenceCardHandler({
getAnkiIntegration: () => ({}),
getMpvClient: () => ({}),
getPrimarySubtitle: () => primarySubtitle,
showMpvOsd: () => {},
mineSentenceCardCore: async (options) => {
assert.equal(options.primarySubtitle, primarySubtitle);
return true;
},
recordCardsMined: () => {},
});
await mineSentenceCard();
});
-10
View File
@@ -2,12 +2,6 @@ type AnkiIntegrationLike = {
refreshKnownWordCache: () => Promise<void>;
};
export type PrimarySubtitle = {
text: string;
startTime: number;
endTime: number;
};
export function createUpdateLastCardFromClipboardHandler<TAnki>(deps: {
getAnkiIntegration: () => TAnki;
readClipboardText: () => string;
@@ -75,22 +69,18 @@ export function createMarkLastCardAsAudioCardHandler<TAnki>(deps: {
export function createMineSentenceCardHandler<TAnki, TMpv>(deps: {
getAnkiIntegration: () => TAnki;
getMpvClient: () => TMpv;
getPrimarySubtitle?: () => PrimarySubtitle | null;
showMpvOsd: (text: string) => void;
mineSentenceCardCore: (options: {
ankiIntegration: TAnki;
mpvClient: TMpv;
primarySubtitle?: PrimarySubtitle;
showMpvOsd: (text: string) => void;
}) => Promise<boolean>;
recordCardsMined: (count: number, noteIds?: number[]) => void;
}) {
return async (): Promise<void> => {
const primarySubtitle = deps.getPrimarySubtitle?.();
const created = await deps.mineSentenceCardCore({
ankiIntegration: deps.getAnkiIntegration(),
mpvClient: deps.getMpvClient(),
...(primarySubtitle ? { primarySubtitle } : {}),
showMpvOsd: deps.showMpvOsd,
});
if (created) {
@@ -1,7 +1,6 @@
import type { SubtitleCue, SubtitleData } from '../../types';
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
import { resolvePrimarySubtitleText } from './primary-subtitle-text';
import { resolveSubtitleSourcePath } from './subtitle-prefetch-source';
const AUTOPLAY_SUBTITLE_PRIME_LOOKAHEAD_SECONDS = 2;
@@ -142,16 +141,6 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
return true;
}
function resolveLivePrimarySubtitleText(text: string): string {
const client = deps.getMpvClient();
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
return resolvePrimarySubtitleText({
liveText: text,
currentTimeSec,
cues: deps.getActiveParsedSubtitleCues(),
});
}
async function primeCurrentSubtitleForAutoplay(mediaPath: string): Promise<void> {
const client = deps.getMpvClient();
if (!client?.connected || !isCurrentAutoplayMediaPath(mediaPath)) {
@@ -166,8 +155,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
);
return null;
});
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
const text = resolveLivePrimarySubtitleText(liveText);
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
if (emitAutoplayPrimedSubtitle(mediaPath, text)) {
return;
}
@@ -187,7 +175,6 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
async function primeCurrentSubtitleForVisibleOverlay(): Promise<void> {
await primeVisibleOverlaySubtitleFromMpv({
getMpvClient: () => deps.getMpvClient(),
resolvePrimarySubtitleText: (text) => resolveLivePrimarySubtitleText(text),
setCurrentSubText: (text) => {
deps.setCurrentSubText(text);
},
@@ -46,7 +46,6 @@ export async function resolveCurrentSubtitleForRenderer(deps: {
export async function primeVisibleOverlaySubtitleFromMpv(deps: {
getMpvClient: () => CurrentSubtitleMpvClient | null;
setCurrentSubText: (text: string) => void;
resolvePrimarySubtitleText?: (text: string) => string;
getCurrentSubtitleData: () => SubtitleData | null;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
onSubtitleChange: (text: string) => void;
@@ -74,8 +73,7 @@ export async function primeVisibleOverlaySubtitleFromMpv(deps: {
return;
}
const liveText = typeof subTextRaw === 'string' ? subTextRaw : '';
const text = deps.resolvePrimarySubtitleText?.(liveText) ?? liveText;
const text = typeof subTextRaw === 'string' ? subTextRaw : '';
deps.setCurrentSubText(text);
const primeSecondarySubtitle = async (): Promise<void> => {
@@ -191,6 +191,8 @@ test('mpv event bindings register all expected events', () => {
onSubtitleAssChange: () => {},
onSecondarySubtitleChange: () => {},
onSubtitleTrackChange: () => {},
onSecondarySubtitleTrackChange: () => {},
onSecondarySubtitleDelayChange: () => {},
onSubtitleTrackListChange: () => {},
onSubtitleTiming: () => {},
onMediaPathChange: () => {},
@@ -215,6 +217,8 @@ test('mpv event bindings register all expected events', () => {
'subtitle-ass-change',
'secondary-subtitle-change',
'subtitle-track-change',
'secondary-subtitle-track-change',
'secondary-subtitle-delay-change',
'subtitle-track-list-change',
'subtitle-timing',
'media-path-change',
@@ -4,6 +4,8 @@ type MpvBindingEventName =
| 'subtitle-ass-change'
| 'secondary-subtitle-change'
| 'subtitle-track-change'
| 'secondary-subtitle-track-change'
| 'secondary-subtitle-delay-change'
| 'subtitle-track-list-change'
| 'subtitle-timing'
| 'media-path-change'
@@ -90,6 +92,8 @@ export function createBindMpvClientEventHandlers(deps: {
onSubtitleAssChange: (payload: { text: string }) => void;
onSecondarySubtitleChange: (payload: { text: string }) => void;
onSubtitleTrackChange: (payload: { sid: number | null }) => void;
onSecondarySubtitleTrackChange: (payload: { sid: number | null }) => void;
onSecondarySubtitleDelayChange: (payload: { delay: number }) => void;
onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void;
onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void;
onMediaPathChange: (payload: { path: string | null }) => void;
@@ -107,6 +111,8 @@ export function createBindMpvClientEventHandlers(deps: {
mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange);
mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange);
mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange);
mpvClient.on('secondary-subtitle-track-change', deps.onSecondarySubtitleTrackChange);
mpvClient.on('secondary-subtitle-delay-change', deps.onSecondarySubtitleDelayChange);
mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange);
mpvClient.on('subtitle-timing', deps.onSubtitleTiming);
mpvClient.on('media-path-change', deps.onMediaPathChange);
@@ -26,29 +26,6 @@ test('subtitle change handler updates state and forwards uncached text without r
assert.deepEqual(calls, ['set:line', 'process:line', 'presence']);
});
test('subtitle change handler consistently forwards resolved canonical text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
resolveSubtitleText: () => '今 手にある物差しでは',
setCurrentSubText: (text) => calls.push(`set:${text}`),
getImmediateSubtitlePayload: (text) => {
calls.push(`lookup:${text}`);
return null;
},
broadcastSubtitle: () => {},
onSubtitleChange: (text) => calls.push(`process:${text}`),
refreshDiscordPresence: () => {},
});
handler({ text: '今今今手手手ににに' });
assert.deepEqual(calls, [
'set:今 手にある物差しでは',
'lookup:今 手にある物差しでは',
'process:今 手にある物差しでは',
]);
});
test('subtitle change handler clears immediately for empty subtitle text', () => {
const calls: string[] = [];
const handler = createHandleMpvSubtitleChangeHandler({
+2 -5
View File
@@ -4,8 +4,7 @@ type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
};
/** Jump size that marks a time-pos change as a seek rather than normal playback. */
export const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
const SEEK_LIKE_TIME_DELTA_SECONDS = 2.5;
function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): boolean {
if (previousTime === null || !Number.isFinite(previousTime) || !Number.isFinite(nextTime)) {
@@ -15,7 +14,6 @@ function isSeekLikeTimeChange(previousTime: number | null, nextTime: number): bo
}
export function createHandleMpvSubtitleChangeHandler(deps: {
resolveSubtitleText?: (text: string) => string;
setCurrentSubText: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
@@ -24,8 +22,7 @@ export function createHandleMpvSubtitleChangeHandler(deps: {
refreshDiscordPresence: () => void;
logDebug?: (message: string) => void;
}) {
return ({ text: liveText }: { text: string }): void => {
const text = deps.resolveSubtitleText?.(liveText) ?? liveText;
return ({ text }: { text: string }): void => {
deps.setCurrentSubText(text);
const immediatePayload = deps.getImmediateSubtitlePayload?.(text) ?? null;
if (immediatePayload) {
@@ -37,6 +37,9 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`),
broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`),
onSubtitleTrackChange: () => calls.push('subtitle-track-change'),
onSecondarySubtitleTrackChange: () => calls.push('secondary-subtitle-track-change'),
onSecondarySubtitleDelayChange: (delay) =>
calls.push(`secondary-subtitle-delay-change:${delay}`),
onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'),
updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`),
@@ -73,6 +76,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
handlers.get('connection-change')?.({ connected: true });
handlers.get('subtitle-change')?.({ text: 'line' });
handlers.get('subtitle-track-change')?.({ sid: 3 });
handlers.get('secondary-subtitle-track-change')?.({ sid: 4 });
handlers.get('secondary-subtitle-delay-change')?.({ delay: 0.5 });
handlers.get('subtitle-track-list-change')?.({ trackList: [] });
handlers.get('media-path-change')?.({ path: '/tmp/video.mkv' });
handlers.get('media-path-change')?.({ path: '' });
@@ -86,6 +91,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => {
assert.equal(calls.includes('broadcast-sub:line'), true);
assert.ok(calls.includes('subtitle-change:line'));
assert.ok(calls.includes('subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-track-change'));
assert.ok(calls.includes('secondary-subtitle-delay-change:0.5'));
assert.ok(calls.includes('subtitle-track-list-change'));
assert.ok(calls.includes('media-title:Episode 1'));
assert.ok(calls.includes('media-path:/tmp/video.mkv'));
+4 -2
View File
@@ -43,7 +43,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logSubtitleTimingError: (message: string, error: unknown) => void;
setCurrentSubText: (text: string) => void;
resolveSubtitleText?: (text: string) => string;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
broadcastSubtitle: (payload: SubtitleData) => void;
@@ -55,6 +54,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
broadcastSubtitleAss: (text: string) => void;
broadcastSecondarySubtitle: (text: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleDelayChange?: (delay: number) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
@@ -118,7 +119,6 @@ export function createBindMpvMainEventHandlersHandler(deps: {
logError: (message, error) => deps.logSubtitleTimingError(message, error),
});
const handleMpvSubtitleChange = createHandleMpvSubtitleChangeHandler({
resolveSubtitleText: deps.resolveSubtitleText,
setCurrentSubText: (text) => deps.setCurrentSubText(text),
getImmediateSubtitlePayload: (text) => deps.getImmediateSubtitlePayload?.(text) ?? null,
emitImmediateSubtitle: deps.emitImmediateSubtitle
@@ -191,6 +191,8 @@ export function createBindMpvMainEventHandlersHandler(deps: {
onSubtitleAssChange: handleMpvSubtitleAssChange,
onSecondarySubtitleChange: handleMpvSecondarySubtitleChange,
onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid),
onSecondarySubtitleTrackChange: ({ sid }) => deps.onSecondarySubtitleTrackChange?.(sid),
onSecondarySubtitleDelayChange: ({ delay }) => deps.onSecondarySubtitleDelayChange?.(delay),
onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList),
onSubtitleTiming: handleMpvSubtitleTiming,
onMediaPathChange: handleMpvMediaPathChange,
@@ -47,6 +47,9 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
logSubtitleTimingError: (message) => calls.push(`subtitle-error:${message}`),
broadcastToOverlayWindows: (channel, payload) =>
calls.push(`broadcast:${channel}:${String(payload)}`),
onSecondarySubtitleChange: (text) => calls.push(`secondary:${text}`),
onSecondarySubtitleTrackChange: (sid) => calls.push(`secondary-track:${String(sid)}`),
onSecondarySubtitleDelayChange: (delay) => calls.push(`secondary-delay:${delay}`),
onSubtitleChange: (text) => calls.push(`subtitle-change:${text}`),
ensureImmersionTrackerInitialized: () => calls.push('ensure-immersion'),
updateCurrentMediaPath: (path) => calls.push(`path:${path}`),
@@ -86,6 +89,8 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
deps.setCurrentSubAssText('ass');
deps.broadcastSubtitleAss('ass');
deps.broadcastSecondarySubtitle('sec');
deps.onSecondarySubtitleTrackChange?.(4);
deps.onSecondarySubtitleDelayChange?.(0.5);
deps.updateCurrentMediaPath('/tmp/video');
deps.restoreMpvSubVisibility();
deps.resetSubtitleSidebarEmbeddedLayout();
@@ -116,6 +121,10 @@ test('mpv main event main deps map app state updates and delegate callbacks', as
assert.ok(calls.includes('sync-overlay-mpv-sub'));
assert.ok(calls.includes('anilist-post-watch'));
assert.ok(calls.includes('timing:y:secondary'));
assert.ok(calls.includes('secondary:sec'));
assert.ok(calls.includes('secondary-track:4'));
assert.ok(calls.includes('secondary-delay:0.5'));
assert.ok(!calls.includes('broadcast:secondary-subtitle:set:sec'));
assert.ok(calls.includes('ensure-immersion'));
assert.ok(calls.includes('sync-immersion'));
assert.ok(calls.includes('autoplay:/tmp/video'));
@@ -387,170 +396,3 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
handlers.recordImmersionSubtitleLine('飛び上がる', 20.04, 20.08);
assert.deepEqual(recordedStarts.slice(-1), [20]);
});
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
const immersion: Array<{ text: string; start: number; end: number }> = [];
const timing: Array<{ text: string; start: number; end: number }> = [];
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState: {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: {
recordSubtitleLine: (text: string, start: number, end: number) =>
immersion.push({ text, start, end }),
},
subtitleTimingTracker: {
recordSubtitle: (text: string, start: number, end: number) =>
timing.push({ text, start, end }),
},
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass',
},
{
startTime: 3,
endTime: 6,
text: '飛び越えてみたくて',
source: 'canonical-ass',
},
],
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
},
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(handlers.resolveSubtitleText?.('今\n今\n今\n手\n手\n手'), '今 手にある物差しでは');
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('手', 0.86, 1.56);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(immersion, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.deepEqual(timing, [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
// Concurrent dialogue during the song is not part of the animation: it must be
// recorded as itself -- without the fragment lines beside it -- and must not cause
// the song line to be recorded again when the animation frames resume.
assert.equal(handlers.resolveSubtitleText?.('普通のセリフ\n今\n手'), '普通のセリフ\n今\n手');
handlers.recordImmersionSubtitleLine('普通のセリフ\n今\n手', 1.9, 3.2);
handlers.recordImmersionSubtitleLine('にある', 2.1, 2.9);
handlers.recordSubtitleTiming('次のセリフ', 3.9, 5.0);
assert.deepEqual(immersion.slice(1), [{ text: '普通のセリフ', start: 1.9, end: 3.2 }]);
assert.deepEqual(timing.slice(1), [{ text: '次のセリフ', start: 3.9, end: 5 }]);
// Overlapping canonical lines resolve as shifting subsets (A, then A+B, then A).
// Every recorded cue is remembered, so each authored line still records exactly once.
handlers.recordImmersionSubtitleLine('飛び越えて', 3.2, 3.4);
handlers.recordImmersionSubtitleLine('手にある', 3.5, 3.7);
handlers.recordSubtitleTiming('飛び越えて', 3.2, 3.4);
handlers.recordSubtitleTiming('手にある', 3.5, 3.7);
assert.deepEqual(immersion.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
assert.deepEqual(timing.slice(2), [{ text: '飛び越えてみたくて', start: 3, end: 6 }]);
// A backward seek means the user is rewatching: the timing history (a viewing log)
// records the revisited line again, while immersion stays once-per-media.
handlers.onTimePosUpdate?.(30);
handlers.onTimePosUpdate?.(2);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
handlers.recordImmersionSubtitleLine('今', 0.8, 1.5);
assert.deepEqual(timing.slice(3), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
assert.equal(immersion.length, 3);
// A jump of exactly the seek threshold counts as a seek, matching the time-pos
// handler's own `>=` boundary.
handlers.onTimePosUpdate?.(4.5);
handlers.onTimePosUpdate?.(2);
handlers.recordSubtitleTiming('今', 0.8, 1.5);
assert.deepEqual(timing.slice(4), [{ text: '今 手にある物差しでは', start: 1.2, end: 3.8 }]);
});
test('subtitle-track changes stop stale canonical cues from substituting immediately', () => {
const appState = {
initialArgs: null,
overlayRuntimeInitialized: true,
mpvClient: { currentTimePos: 2 },
immersionTracker: { recordSubtitleLine: () => {} },
subtitleTimingTracker: { recordSubtitle: () => {} },
activeParsedSubtitleCues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある物差しでは',
source: 'canonical-ass' as const,
},
] as Array<{ startTime: number; endTime: number; text: string; source?: 'canonical-ass' }>,
activeParsedSubtitleSource: 'track-a.ass' as string | null,
currentMediaPath: '/video.mkv',
currentSubText: '',
currentSubAssText: '',
playbackPaused: null,
previousSecondarySubVisibility: false,
};
const handlers = createBuildBindMpvMainEventHandlersMainDepsHandler({
appState,
getQuitOnDisconnectArmed: () => false,
scheduleQuitCheck: () => {},
quitApp: () => {},
reportJellyfinRemoteStopped: () => {},
syncOverlayMpvSubtitleSuppression: () => {},
maybeRunAnilistPostWatchUpdate: async () => {},
logSubtitleTimingError: () => {},
broadcastToOverlayWindows: () => {},
onSubtitleChange: () => {},
ensureImmersionTrackerInitialized: () => {},
updateCurrentMediaPath: () => {},
restoreMpvSubVisibility: () => {},
getCurrentAnilistMediaKey: () => null,
resetAnilistMediaTracking: () => {},
maybeProbeAnilistDuration: () => {},
ensureAnilistMediaGuess: () => {},
syncImmersionMediaState: () => {},
updateCurrentMediaTitle: () => {},
resetAnilistMediaGuessState: () => {},
reportJellyfinRemoteProgress: () => {},
updateSubtitleRenderMetrics: () => {},
refreshDiscordPresence: () => {},
})();
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今 手にある物差しでは');
// The new track's cues arrive only after an async re-parse; until then, the old
// track's canonical lyric must not replace the new track's live text.
handlers.onSubtitleTrackChange?.(2);
assert.deepEqual(appState.activeParsedSubtitleCues, []);
assert.equal(appState.activeParsedSubtitleSource, null);
assert.equal(handlers.resolveSubtitleText?.('今\n手にある'), '今\n手にある');
});
+47 -147
View File
@@ -1,11 +1,5 @@
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
import { SEEK_LIKE_TIME_DELTA_SECONDS } from './mpv-main-event-actions';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
type AnilistPostWatchRunOptions = {
watchedSeconds?: number;
@@ -42,8 +36,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordSubtitle?: (text: string, start: number, end: number, secondaryText?: string) => void;
} | null;
activeParsedSubtitleCues?: SubtitleCue[] | null;
/** Cache key of the source the cues were parsed from; cleared with the cues. */
activeParsedSubtitleSource?: string | null;
currentMediaPath?: string | null;
currentSubText: string;
currentSubAssText: string;
@@ -61,11 +53,14 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
recordAnilistMediaDuration?: (durationSec: number) => void;
logSubtitleTimingError: (message: string, error: unknown) => void;
broadcastToOverlayWindows: (channel: string, payload: unknown) => void;
onSecondarySubtitleChange?: (text: string) => void;
getImmediateSubtitlePayload?: (text: string) => SubtitleData | null;
emitImmediateSubtitle?: (payload: SubtitleData) => void;
onSubtitleChange: (text: string) => void;
logSubtitleProcessingDebug?: (message: string) => void;
onSubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleTrackChange?: (sid: number | null) => void;
onSecondarySubtitleDelayChange?: (delay: number) => void;
onSubtitleTrackListChange?: (trackList: unknown[] | null) => void;
updateCurrentMediaPath: (path: string) => void;
restoreMpvSubVisibility: () => void;
@@ -101,38 +96,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
const immersionLineDedupGate = createSubtitleLineDedupGate({
getParsedCues: () => deps.appState.activeParsedSubtitleCues,
});
// One seen-set per consumer: canonical cues overlap, so live samples resolve to
// shifting subsets (A, then A+B, then B). Remembering every recorded cue -- not just
// the previous sample -- keeps each authored line recorded exactly once per source.
const recordedImmersionCanonicalKeys = new Set<string>();
const recordedTimingCanonicalKeys = new Set<string>();
// Bumped on track/media changes so an immersion record whose tokenization resolves
// after the change is dropped instead of landing in the next session.
let subtitleSessionEpoch = 0;
let lastTimePosForTimingReset: number | null = null;
const canonicalCueKey = (cue: SubtitleCue): string =>
`${cue.startTime}|${cue.endTime}|${cue.text}`;
const resetSubtitleDeduplication = (): void => {
immersionLineDedupGate.reset();
recordedImmersionCanonicalKeys.clear();
recordedTimingCanonicalKeys.clear();
subtitleSessionEpoch += 1;
lastTimePosForTimingReset = null;
};
const resolveCanonicalSample = (liveText: string, startSec: number) =>
resolveCanonicalPrimarySubtitle({
liveText,
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
});
// When substitution declined because dialogue shares the screen with a song, record
// the dialogue alone rather than the combined dialogue-plus-fragments stack.
const stripFragmentsForRecording = (liveText: string, startSec: number) =>
stripCanonicalFragmentLines({
liveText,
currentTimeSec: startSec,
cues: deps.appState.activeParsedSubtitleCues,
});
const hasInitialPlaybackQuitOnDisconnectArg = (): boolean =>
Boolean(
deps.appState.initialArgs?.managedPlayback ||
@@ -151,99 +114,45 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
scheduleQuitCheck: (callback: () => void) => deps.scheduleQuitCheck(callback),
isMpvConnected: () => Boolean(deps.appState.mpvClient?.connected),
quitApp: () => deps.quitApp(),
resolveSubtitleText: (liveText: string) =>
resolvePrimarySubtitleText({
liveText,
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
cues: deps.appState.activeParsedSubtitleCues,
}),
recordImmersionSubtitleLine: (text: string, start: number, end: number) => {
deps.ensureImmersionTrackerInitialized();
const tracker = deps.appState.immersionTracker;
if (!tracker?.recordSubtitleLine) {
return;
}
const recordLine = (lineText: string, startSec: number, endSec: number): void => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens =
deps.appState.currentSubtitleData?.text === lineText
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine?.(lineText, startSec, endSec, cachedTokens, secondaryText);
return;
}
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
return;
}
const epochAtRecord = subtitleSessionEpoch;
void deps
.tokenizeSubtitleForImmersion(lineText)
.then((payload) => {
if (subtitleSessionEpoch !== epochAtRecord) {
return;
}
tracker.recordSubtitleLine?.(
lineText,
startSec,
endSec,
payload?.tokens ?? null,
secondaryText,
);
})
.catch(() => {
if (subtitleSessionEpoch !== epochAtRecord) {
return;
}
tracker.recordSubtitleLine?.(lineText, startSec, endSec, null, secondaryText);
});
};
const canonical = resolveCanonicalSample(text, start);
if (canonical) {
for (const cue of canonical.cues) {
const key = canonicalCueKey(cue);
if (recordedImmersionCanonicalKeys.has(key)) {
continue;
}
recordedImmersionCanonicalKeys.add(key);
recordLine(cue.text, cue.startTime, cue.endTime);
}
return;
}
text = stripFragmentsForRecording(text, start);
if (!immersionLineDedupGate.shouldRecord({ text, startSec: start, endSec: end })) {
return;
}
recordLine(text, start, end);
},
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) => {
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
const canonical = resolveCanonicalSample(text, start);
if (!canonical) {
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
stripFragmentsForRecording(text, start),
start,
end,
secondaryText,
);
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || null;
const cachedTokens =
deps.appState.currentSubtitleData?.text === text
? deps.appState.currentSubtitleData.tokens
: null;
if (cachedTokens) {
tracker.recordSubtitleLine(text, start, end, cachedTokens, secondaryText);
return;
}
for (const cue of canonical.cues) {
const key = canonicalCueKey(cue);
if (recordedTimingCanonicalKeys.has(key)) {
continue;
}
recordedTimingCanonicalKeys.add(key);
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
cue.text,
cue.startTime,
cue.endTime,
secondaryText,
);
if (!deps.tokenizeSubtitleForImmersion) {
tracker.recordSubtitleLine(text, start, end, null, secondaryText);
return;
}
void deps
.tokenizeSubtitleForImmersion(text)
.then((payload) => {
tracker.recordSubtitleLine?.(text, start, end, payload?.tokens ?? null, secondaryText);
})
.catch(() => {
tracker.recordSubtitleLine?.(text, start, end, null, secondaryText);
});
},
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
recordSubtitleTiming: (text: string, start: number, end: number) =>
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
text,
start,
end,
deps.appState.mpvClient?.currentSecondarySubText || undefined,
),
maybeRunAnilistPostWatchUpdate: (options?: AnilistPostWatchRunOptions) =>
deps.maybeRunAnilistPostWatchUpdate(options),
logSubtitleTimingError: (message: string, error: unknown) =>
@@ -264,16 +173,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
? (message: string) => deps.logSubtitleProcessingDebug!(message)
: undefined,
onSubtitleTrackChange: (sid: number | null) => {
resetSubtitleDeduplication();
// The replacement track's cues arrive only after an async re-read and re-parse.
// Clearing synchronously keeps the previous track's canonical cues from
// substituting into, or recording against, the new track's live text. The source
// key is cleared with the cues so cue-list consumers (the sidebar snapshot)
// re-parse on demand instead of trusting the stale pairing.
deps.appState.activeParsedSubtitleCues = [];
deps.appState.activeParsedSubtitleSource = null;
immersionLineDedupGate.reset();
deps.onSubtitleTrackChange?.(sid);
},
onSecondarySubtitleTrackChange: deps.onSecondarySubtitleTrackChange
? (sid: number | null) => deps.onSecondarySubtitleTrackChange!(sid)
: undefined,
onSecondarySubtitleDelayChange: deps.onSecondarySubtitleDelayChange
? (delay: number) => deps.onSecondarySubtitleDelayChange!(delay)
: undefined,
onSubtitleTrackListChange: deps.onSubtitleTrackListChange
? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList)
: undefined,
@@ -283,10 +191,15 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
broadcastSubtitleAss: (text: string) =>
deps.broadcastToOverlayWindows('subtitle-ass:set', text),
broadcastSecondarySubtitle: (text: string) =>
deps.broadcastToOverlayWindows('secondary-subtitle:set', text),
broadcastSecondarySubtitle: (text: string) => {
if (deps.onSecondarySubtitleChange) {
deps.onSecondarySubtitleChange(text);
return;
}
deps.broadcastToOverlayWindows('secondary-subtitle:set', text);
},
updateCurrentMediaPath: (path: string) => {
resetSubtitleDeduplication();
immersionLineDedupGate.reset();
deps.updateCurrentMediaPath(path);
},
restoreMpvSubVisibility: () => deps.restoreMpvSubVisibility(),
@@ -318,22 +231,9 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
},
reportJellyfinRemoteProgress: (forceImmediate: boolean) =>
deps.reportJellyfinRemoteProgress(forceImmediate),
onTimePosUpdate: (time: number) => {
// Timing history is a viewing log: after a real backward seek, a rewatched
// canonical line should enter it again. Immersion stats keep their
// once-per-media deduplication and are not reset here.
if (
Number.isFinite(time) &&
lastTimePosForTimingReset !== null &&
time <= lastTimePosForTimingReset - SEEK_LIKE_TIME_DELTA_SECONDS
) {
recordedTimingCanonicalKeys.clear();
}
if (Number.isFinite(time)) {
lastTimePosForTimingReset = time;
}
deps.onTimePosUpdate?.(time);
},
onTimePosUpdate: deps.onTimePosUpdate
? (time: number) => deps.onTimePosUpdate!(time)
: undefined,
onFullscreenChange: deps.onFullscreenChange
? (fullscreen: boolean) => deps.onFullscreenChange!(fullscreen)
: undefined,
@@ -1,277 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
resolveCanonicalPrimarySubtitle,
resolvePrimarySubtitleText,
stripCanonicalFragmentLines,
} from './primary-subtitle-text';
test('resolvePrimarySubtitleText prefers an active canonical cue over flattened mpv glyphs', () => {
// mpv renders each simultaneously active ASS event on its own sub-text line.
const text = resolvePrimarySubtitleText({
liveText: '今\n今\n今\n手\n手\n手\nにある\nにある\nにある',
currentTimeSec: 2,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '今 手にある');
});
test('resolvePrimarySubtitleText preserves live text outside canonical cue timing', () => {
const text = resolvePrimarySubtitleText({
liveText: '通常の会話',
currentTimeSec: 8,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '通常の会話');
});
test('resolvePrimarySubtitleText keeps concurrent dialogue that is not part of the animation', () => {
// An insert song's canonical window can overlap real dialogue on the same track.
const text = resolvePrimarySubtitleText({
liveText: '普通のセリフ\n今\n手にある',
currentTimeSec: 2,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '普通のセリフ\n今\n手にある');
});
test('resolvePrimarySubtitleText keeps a fresh line starting just after the animation ended', () => {
const text = resolvePrimarySubtitleText({
liveText: '次のセリフ',
currentTimeSec: 4.1,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(text, '次のセリフ');
});
test('resolvePrimarySubtitleText survives overlapping frames of consecutive karaoke lines', () => {
// Near a line boundary the previous line's exit frames and the next line's entrance
// frames render together; neither line alone explains every live segment.
const cues = [
{ startTime: 1.2, endTime: 3.8, text: '今 手にある', source: 'canonical-ass' as const },
{ startTime: 3.8, endTime: 6.4, text: '物差しでは', source: 'canonical-ass' as const },
];
assert.equal(
resolvePrimarySubtitleText({
liveText: '手にある\n物差し\nでは',
currentTimeSec: 3.6,
cues,
}),
'今 手にある',
);
assert.equal(
resolvePrimarySubtitleText({
liveText: '手にある\n物差し\nでは',
currentTimeSec: 3.9,
cues,
}),
'物差しでは',
);
});
test('resolvePrimarySubtitleText combines simultaneous canonical cues in source order', () => {
const text = resolvePrimarySubtitleText({
liveText: 'fir\nst\nsecond',
currentTimeSec: 2,
cues: [
{ startTime: 1, endTime: 3, text: 'first', source: 'canonical-ass' },
{ startTime: 1.5, endTime: 2.5, text: 'second', source: 'canonical-ass' },
],
});
assert.equal(text, 'first\nsecond');
});
test('resolveCanonicalPrimarySubtitle covers a nearby generated animation edge', () => {
const cue = {
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
};
const resolved = resolveCanonicalPrimarySubtitle({
liveText: '今\n手にある',
currentTimeSec: 0.8,
cues: [cue],
});
assert.deepEqual(resolved, {
text: '今 手にある',
startTime: 1.2,
endTime: 3.8,
cues: [cue],
});
});
test('resolveCanonicalPrimarySubtitle covers exit frames that outlive the authored timing', () => {
// Real generated animations keep exit fragments on screen well past the authored
// comment window; the recorded animation envelope is what makes them resolvable.
const cue = {
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
animationStartTime: 0.8,
animationEndTime: 5.6,
};
const resolved = resolveCanonicalPrimarySubtitle({
liveText: '今\n手にある',
currentTimeSec: 5.4,
cues: [cue],
});
assert.deepEqual(resolved, {
text: '今 手にある',
startTime: 1.2,
endTime: 3.8,
cues: [cue],
});
});
test('resolvePrimarySubtitleText handles late exit frames overlapping the next active line', () => {
// The previous line's exit fragments can persist more than a second into the next
// authored line. The next line supplies the text; the previous line's envelope
// explains its lingering fragments.
const cues = [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
animationStartTime: 0.8,
animationEndTime: 5.6,
},
{
startTime: 3.8,
endTime: 6.4,
text: '物差しでは',
source: 'canonical-ass' as const,
animationStartTime: 3.4,
animationEndTime: 7.0,
},
];
assert.equal(
resolvePrimarySubtitleText({
liveText: '手にある\n手にある\n物差し\nでは',
currentTimeSec: 5.2,
cues,
}),
'物差しでは',
);
});
test('resolveCanonicalPrimarySubtitle rejects unrelated live text at the animation edge', () => {
const resolved = resolveCanonicalPrimarySubtitle({
liveText: '次のセリフ',
currentTimeSec: 4.1,
cues: [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass',
},
],
});
assert.equal(resolved, null);
});
test('stripCanonicalFragmentLines drops fragment lines but keeps concurrent dialogue', () => {
const cues = [
{
startTime: 1.2,
endTime: 3.8,
text: '今 手にある',
source: 'canonical-ass' as const,
},
];
assert.equal(
stripCanonicalFragmentLines({
liveText: '普通のセリフ\n今\n手にある',
currentTimeSec: 2,
cues,
}),
'普通のセリフ',
);
// No canonical cue nearby: nothing to strip.
assert.equal(
stripCanonicalFragmentLines({ liveText: '普通のセリフ\n今', currentTimeSec: 30, cues }),
'普通のセリフ\n今',
);
// Everything matched (defensive): return the input rather than empty text.
assert.equal(
stripCanonicalFragmentLines({ liveText: '今\n手にある', currentTimeSec: 2, cues }),
'今\n手にある',
);
});
test('resolveCanonicalPrimarySubtitle picks the cue its fragments spell, not the nearest', () => {
// In the gap between two authored spans, the next line sits closer in time while only
// the previous line's exit fragments are on screen: the fragments decide.
const cues = [
{
startTime: 1,
endTime: 3,
text: '今 手にある',
source: 'canonical-ass' as const,
animationStartTime: 0.6,
animationEndTime: 3.9,
},
{
startTime: 4,
endTime: 6,
text: '物差しでは',
source: 'canonical-ass' as const,
animationStartTime: 3.5,
animationEndTime: 6.4,
},
];
assert.equal(
resolveCanonicalPrimarySubtitle({ liveText: '手にある', currentTimeSec: 3.8, cues })?.text,
'今 手にある',
);
// Fragments of both lines in the gap: both envelopes cover the moment (distance 0),
// and the earlier line wins the tie while it is still animating out.
assert.equal(
resolveCanonicalPrimarySubtitle({ liveText: '手にある\n物差し', currentTimeSec: 3.8, cues })
?.text,
'今 手にある',
);
});
-160
View File
@@ -1,160 +0,0 @@
import type { SubtitleCue } from '../../types';
// Slack on top of each cue's recorded animation envelope, for time-pos observation
// staleness and small user sub-delay offsets. The envelope itself covers how far
// entrance/exit frames actually run past the authored timing.
const CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS = 1;
export interface ResolvedPrimarySubtitle {
text: string;
startTime: number;
endTime: number;
/** The canonical cues behind `text`, for consumers that record lines individually. */
cues: SubtitleCue[];
}
function animationSpan(cue: SubtitleCue): { start: number; end: number } {
return {
start: cue.animationStartTime ?? cue.startTime,
end: cue.animationEndTime ?? cue.endTime,
};
}
function nearbyCanonicalCues(
cues: readonly SubtitleCue[] | null | undefined,
currentTimeSec: number,
): SubtitleCue[] {
return (cues ?? []).filter((cue) => {
if (cue.source !== 'canonical-ass') {
return false;
}
const span = animationSpan(cue);
return (
span.end >= currentTimeSec - CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS &&
span.start <= currentTimeSec + CANONICAL_ANIMATION_EDGE_TOLERANCE_SECONDS
);
});
}
function compactWhitespace(text: string): string {
return text.replace(/\s+/gu, '');
}
/**
* mpv's `sub-text` renders each simultaneously active ASS event on its own line, so
* while a generated animation plays every live line is a contiguous piece of the
* authored text. A line that is not -- concurrent dialogue during an insert song, or a
* fresh line starting just after the animation ended -- proves the live text is not this
* animation, and substituting the canonical line would swallow real dialogue.
*/
function liveTextIsFromCues(liveText: string, cues: readonly SubtitleCue[]): boolean {
const compactCues = cues.map((cue) => compactWhitespace(cue.text));
const segments = liveText.split('\n').map(compactWhitespace).filter(Boolean);
return (
segments.length > 0 &&
segments.every((segment) => compactCues.some((cueText) => cueText.includes(segment)))
);
}
export function resolveCanonicalPrimarySubtitle(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): ResolvedPrimarySubtitle | null {
if (!Number.isFinite(options.currentTimeSec)) {
return null;
}
// Consecutive karaoke lines overlap: one line's exit frames are still on screen while
// the next line's entrance frames appear. The fragment check therefore runs against
// every canonical cue whose animation envelope reaches the current time, while only
// the active (or single nearest) cue supplies the displayed text.
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
const active = nearby.filter(
(cue) => cue.startTime <= options.currentTimeSec && cue.endTime > options.currentTimeSec,
);
const liveSegments = options.liveText.split('\n').map(compactWhitespace).filter(Boolean);
const selected =
active.length > 0
? active
: nearby
// Between authored spans, proximity alone can pick the wrong neighbor: the
// next line can sit closer while only the previous line's exit fragments are
// on screen. Only cues that explain at least one live line may be selected.
.filter((cue) => {
const cueText = compactWhitespace(cue.text);
return liveSegments.some((segment) => cueText.includes(segment));
})
.map((cue) => {
const span = animationSpan(cue);
const distance =
options.currentTimeSec < span.start
? span.start - options.currentTimeSec
: Math.max(0, options.currentTimeSec - span.end);
return { cue, distance };
})
.sort((a, b) => a.distance - b.distance || a.cue.startTime - b.cue.startTime)
.slice(0, 1)
.map(({ cue }) => cue);
if (selected.length === 0 || !liveTextIsFromCues(options.liveText, nearby)) {
return null;
}
const texts: string[] = [];
const seen = new Set<string>();
for (const cue of selected) {
if (!seen.has(cue.text)) {
seen.add(cue.text);
texts.push(cue.text);
}
}
return {
text: texts.join('\n'),
startTime: Math.min(...selected.map((cue) => cue.startTime)),
endTime: Math.max(...selected.map((cue) => cue.endTime)),
cues: selected,
};
}
/**
* Live text with generated-animation fragment lines removed. Recording paths use this
* when full canonical substitution declined -- concurrent dialogue during an insert
* song: the dialogue is worth recording, the glyph fragments beside it are not. Returns
* the input unchanged when no canonical cue is near or nothing non-fragment remains.
*/
export function stripCanonicalFragmentLines(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!Number.isFinite(options.currentTimeSec)) {
return options.liveText;
}
const nearby = nearbyCanonicalCues(options.cues, options.currentTimeSec);
if (nearby.length === 0) {
return options.liveText;
}
const compactCues = nearby.map((cue) => compactWhitespace(cue.text));
const kept = options.liveText.split('\n').filter((line) => {
const compact = compactWhitespace(line);
return compact && !compactCues.some((cueText) => cueText.includes(compact));
});
return kept.length > 0 ? kept.join('\n') : options.liveText;
}
export function resolvePrimarySubtitleText(options: {
liveText: string;
currentTimeSec: number;
cues: readonly SubtitleCue[] | null | undefined;
}): string {
if (!options.liveText.trim()) {
return options.liveText;
}
return (
resolveCanonicalPrimarySubtitle({
liveText: options.liveText,
currentTimeSec: options.currentTimeSec,
cues: options.cues,
})?.text ?? options.liveText
);
}
@@ -0,0 +1,250 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import {
createSecondarySubtitleTrackController,
findActiveSubtitleText,
} from './secondary-subtitle-track';
test('findActiveSubtitleText combines unique simultaneous parsed cues', () => {
assert.equal(
findActiveSubtitleText(
[
{ startTime: 1, endTime: 3, text: 'Your' },
{ startTime: 1, endTime: 3, text: 'Your' },
{ startTime: 1, endTime: 3, text: 'mosaic' },
],
2,
),
'Your\nmosaic',
);
});
test('secondary track controller parses the selected ASS file before publishing', async () => {
const broadcasts: string[] = [];
let currentText = '';
const resolverInputs: Array<{ allowSelectedFallback?: boolean }> = [];
const ass = `[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 1,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 2,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 3,0:00:01.00,0:00:03.00,Sign,,0,0,0,,Your
Dialogue: 4,0:00:01.00,0:00:03.00,Sign,,0,0,0,,mosaic`;
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async (input) => {
resolverInputs.push(input);
return { path: '/subs/english.ass', sourceKey: '/subs/english.ass' };
},
loadSubtitleSourceText: async () => ass,
parseSubtitleCues,
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
controller.handleLiveText('Your\nYour\nYour\nYour\nmosaic');
assert.equal(resolverInputs[0]?.allowSelectedFallback, false);
assert.equal(currentText, 'Your\nmosaic');
assert.deepEqual(broadcasts, ['Your\nmosaic']);
});
test('secondary track controller follows parsed cue timing and subtitle delay', async () => {
const broadcasts: string[] = [];
let time = 2.25;
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2 }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0.5;
return null;
},
}),
getCurrentTimePos: () => time,
resolveSubtitleSource: async () => ({ path: '/subs/english.srt', sourceKey: 'english' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [
{ startTime: 1, endTime: 2, text: 'first' },
{ startTime: 2, endTime: 3, text: 'second' },
],
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
controller.handleDelayChange(0);
time = 3.25;
controller.handleTimePos(time);
assert.deepEqual(broadcasts, ['first', 'second', '']);
});
test('secondary track controller clears old parsed text immediately on a track change', async () => {
const broadcasts: string[] = [];
let currentText = '';
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => ({ path: '/subs/old.ass', sourceKey: 'old' }),
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [{ startTime: 1, endTime: 3, text: 'old parsed text' }],
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
await controller.refresh();
controller.handleTrackChange();
controller.handleLiveText('new live text');
assert.equal(currentText, 'new live text');
assert.deepEqual(broadcasts, ['old parsed text', '', 'new live text']);
});
test('secondary track controller falls back to live mpv text without a readable source', async () => {
const broadcasts: string[] = [];
let currentText = '';
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 'no';
if (name === 'path') return '/media/video.mkv';
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => null,
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => [],
setCurrentSecondaryText: (text) => {
currentText = text;
},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
controller.handleLiveText('live fallback');
await controller.refresh();
assert.equal(currentText, 'live fallback');
assert.deepEqual(broadcasts, ['live fallback']);
});
test('secondary track controller reuses parsed cues for an unchanged embedded track', async () => {
let resolveCalls = 0;
let parseCalls = 0;
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
return [{ type: 'sub', id: 2, external: false, 'ff-index': 3 }];
}
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => {
resolveCalls += 1;
return { path: `/tmp/extracted-${resolveCalls}.ass`, sourceKey: 'embedded-track-2' };
},
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => {
parseCalls += 1;
return [{ startTime: 1, endTime: 3, text: 'parsed' }];
},
setCurrentSecondaryText: () => {},
broadcastSecondaryText: () => {},
});
await controller.refresh();
await controller.refresh();
assert.equal(resolveCalls, 1);
assert.equal(parseCalls, 1);
});
test('secondary track controller ignores and cleans up a refresh invalidated by reset', async () => {
const broadcasts: string[] = [];
let notifyResolveStarted: (() => void) | undefined;
let releaseResolve: (() => void) | undefined;
let cleanupCalls = 0;
let parseCalls = 0;
const resolveStarted = new Promise<void>((resolve) => {
notifyResolveStarted = resolve;
});
const resolveGate = new Promise<void>((resolve) => {
releaseResolve = resolve;
});
const controller = createSecondarySubtitleTrackController({
getMpvClient: () => ({
connected: true,
requestProperty: async (name) => {
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return [{ type: 'sub', id: 2, external: true }];
if (name === 'path') return '/media/video.mkv';
if (name === 'secondary-sub-delay') return 0;
return null;
},
}),
getCurrentTimePos: () => 2,
resolveSubtitleSource: async () => {
notifyResolveStarted?.();
await resolveGate;
return {
path: '/subs/secondary.ass',
sourceKey: 'secondary',
cleanup: async () => {
cleanupCalls += 1;
},
};
},
loadSubtitleSourceText: async () => '',
parseSubtitleCues: () => {
parseCalls += 1;
return [{ startTime: 1, endTime: 3, text: 'stale' }];
},
setCurrentSecondaryText: () => {},
broadcastSecondaryText: (text) => broadcasts.push(text),
});
const refresh = controller.refresh();
await resolveStarted;
controller.reset();
releaseResolve?.();
await refresh;
assert.deepEqual(broadcasts, ['']);
assert.equal(parseCalls, 0);
assert.equal(cleanupCalls, 1);
});
@@ -0,0 +1,233 @@
import type { SubtitleCue } from '../../types/subtitle';
type SecondarySubtitleMpvClient = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
type ResolvedSubtitleSource = {
path: string;
sourceKey: string;
cleanup?: () => Promise<void>;
};
type SecondarySubtitleSourceInput = {
currentExternalFilenameRaw: unknown;
currentTrackRaw: unknown;
trackListRaw: unknown;
sidRaw: unknown;
videoPath: string;
allowSelectedFallback?: boolean;
};
const DEFAULT_REFRESH_DELAY_MS = 500;
function finiteNumber(value: unknown, fallback = 0): number {
const number = typeof value === 'number' ? value : Number(value);
return Number.isFinite(number) ? number : fallback;
}
function trackId(value: unknown): number | null {
if (typeof value !== 'number' && typeof value !== 'string') return null;
const number = typeof value === 'number' ? value : Number(value.trim());
return Number.isInteger(number) ? number : null;
}
function buildSelectedTrackIdentity(
trackListRaw: unknown,
sidRaw: unknown,
videoPath: string,
): string | null {
if (!Array.isArray(trackListRaw)) return null;
const sid = trackId(sidRaw);
if (sid === null) return null;
const selectedTrack = trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') return false;
const track = entry as Record<string, unknown>;
return track.type === 'sub' && trackId(track.id) === sid;
}) as Record<string, unknown> | undefined;
if (!selectedTrack) return null;
return JSON.stringify([
videoPath,
sid,
selectedTrack.external === true,
selectedTrack['external-filename'] ?? null,
trackId(selectedTrack['ff-index']),
]);
}
export function findActiveSubtitleText(cues: readonly SubtitleCue[], timeSeconds: number): string {
if (!Number.isFinite(timeSeconds)) return '';
const seen = new Set<string>();
const activeText: string[] = [];
for (const cue of cues) {
if (cue.startTime > timeSeconds || cue.endTime <= timeSeconds) continue;
const text = cue.text.trim();
if (!text || seen.has(text)) continue;
seen.add(text);
activeText.push(text);
}
return activeText.join('\n');
}
export function createSecondarySubtitleTrackController(deps: {
getMpvClient: () => SecondarySubtitleMpvClient | null;
getCurrentTimePos: () => number;
resolveSubtitleSource: (
input: SecondarySubtitleSourceInput,
) => Promise<ResolvedSubtitleSource | null>;
loadSubtitleSourceText: (source: string) => Promise<string>;
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
setCurrentSecondaryText: (text: string) => void;
broadcastSecondaryText: (text: string) => void;
logDebug?: (message: string) => void;
logWarn?: (message: string, error: unknown) => void;
}) {
let parsedCues: SubtitleCue[] | null = null;
let parsedSourceKey: string | null = null;
let parsedTrackIdentity: string | null = null;
let secondaryDelaySeconds = 0;
let lastLiveText = '';
let lastBroadcastText: string | null = null;
let refreshGeneration = 0;
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
const publish = (text: string): void => {
deps.setCurrentSecondaryText(text);
if (text === lastBroadcastText) return;
lastBroadcastText = text;
deps.broadcastSecondaryText(text);
};
const resolveAtTime = (timeSeconds: number): string => {
if (!parsedCues) return lastLiveText;
return findActiveSubtitleText(parsedCues, timeSeconds - secondaryDelaySeconds);
};
const useLiveFallback = (): void => {
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
publish(lastLiveText);
};
const refresh = async (): Promise<void> => {
const generation = ++refreshGeneration;
const client = deps.getMpvClient();
if (!client?.connected) {
useLiveFallback();
return;
}
let resolvedSource: ResolvedSubtitleSource | null = null;
try {
const [secondarySid, trackList, videoPathRaw, secondaryDelayRaw] = await Promise.all([
client.requestProperty('secondary-sid').catch(() => null),
client.requestProperty('track-list').catch(() => null),
client.requestProperty('path').catch(() => null),
client.requestProperty('secondary-sub-delay').catch(() => 0),
]);
if (generation !== refreshGeneration) return;
const videoPath = typeof videoPathRaw === 'string' ? videoPathRaw.trim() : '';
if (!videoPath || secondarySid === null || secondarySid === 'no') {
useLiveFallback();
return;
}
secondaryDelaySeconds = finiteNumber(secondaryDelayRaw);
const selectedTrackIdentity = buildSelectedTrackIdentity(trackList, secondarySid, videoPath);
if (selectedTrackIdentity && selectedTrackIdentity === parsedTrackIdentity && parsedCues) {
publish(resolveAtTime(deps.getCurrentTimePos()));
return;
}
resolvedSource = await deps.resolveSubtitleSource({
currentExternalFilenameRaw: null,
currentTrackRaw: null,
trackListRaw: trackList,
sidRaw: secondarySid,
videoPath,
allowSelectedFallback: false,
});
if (generation !== refreshGeneration) return;
if (!resolvedSource) {
deps.logDebug?.('[secondary-subtitle-track] selected source is not readable');
useLiveFallback();
return;
}
if (resolvedSource.sourceKey === parsedSourceKey && parsedCues) {
parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos()));
return;
}
const content = await deps.loadSubtitleSourceText(resolvedSource.path);
const cues = deps.parseSubtitleCues(content, resolvedSource.path);
if (generation !== refreshGeneration) return;
if (cues.length === 0) {
deps.logDebug?.('[secondary-subtitle-track] selected source contained no parsed cues');
useLiveFallback();
return;
}
parsedCues = cues;
parsedSourceKey = resolvedSource.sourceKey;
parsedTrackIdentity = selectedTrackIdentity;
publish(resolveAtTime(deps.getCurrentTimePos()));
} catch (error) {
if (generation !== refreshGeneration) return;
deps.logWarn?.('[secondary-subtitle-track] failed to parse selected source', error);
useLiveFallback();
} finally {
await resolvedSource?.cleanup?.().catch(() => undefined);
}
};
const scheduleRefresh = (delayMs = DEFAULT_REFRESH_DELAY_MS): void => {
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = setTimeout(() => {
refreshTimer = null;
void refresh();
}, delayMs);
};
const clearSelectedTrack = (): void => {
refreshGeneration += 1;
if (refreshTimer) clearTimeout(refreshTimer);
refreshTimer = null;
parsedCues = null;
parsedSourceKey = null;
parsedTrackIdentity = null;
secondaryDelaySeconds = 0;
lastLiveText = '';
publish('');
};
return {
refresh,
scheduleRefresh,
handleLiveText(text: string): void {
lastLiveText = text;
publish(resolveAtTime(deps.getCurrentTimePos()));
},
handleTimePos(timeSeconds: number): void {
if (!parsedCues) return;
publish(resolveAtTime(timeSeconds));
},
handleTrackChange(): void {
clearSelectedTrack();
},
handleDelayChange(delaySeconds: number): void {
secondaryDelaySeconds = finiteNumber(delaySeconds);
if (parsedCues) {
publish(resolveAtTime(deps.getCurrentTimePos()));
}
},
reset: clearSelectedTrack,
};
}
@@ -248,3 +248,31 @@ test('subtitle source resolver logs debug when no active subtitle track is selec
assert.equal(debugs.length, 1);
assert.match(debugs[0]!, /\[subtitle-prefetch\].*no active subtitle track/);
});
test('subtitle source resolver does not fall back to the primary selected track for secondary', async () => {
const resolveSource = createResolveActiveSubtitleSidebarSourceHandler({
getFfmpegPath: () => 'ffmpeg',
extractInternalSubtitleTrack: async () => {
throw new Error('should not extract the primary track');
},
});
const resolved = await resolveSource({
currentExternalFilenameRaw: null,
currentTrackRaw: null,
trackListRaw: [
{
type: 'sub',
id: 1,
selected: true,
external: true,
'external-filename': '/subs/primary.ass',
},
],
sidRaw: null,
videoPath: '/media/video.mkv',
allowSelectedFallback: false,
});
assert.equal(resolved, null);
});
+12 -1
View File
@@ -41,6 +41,7 @@ function getActiveSubtitleTrack(
currentTrackRaw: unknown,
trackListRaw: unknown,
sidRaw: unknown,
allowSelectedFallback: boolean,
): MpvSubtitleTrackLike | null {
if (currentTrackRaw && typeof currentTrackRaw === 'object') {
const track = currentTrackRaw as MpvSubtitleTrackLike;
@@ -68,6 +69,10 @@ function getActiveSubtitleTrack(
return bySid;
}
if (!allowSelectedFallback) {
return null;
}
return (
(trackListRaw.find((entry: unknown) => {
if (!entry || typeof entry !== 'object') {
@@ -94,6 +99,7 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
trackListRaw: unknown;
sidRaw: unknown;
videoPath: string;
allowSelectedFallback?: boolean;
}): Promise<ActiveSubtitleSidebarSource | null> => {
const currentExternalFilename =
typeof input.currentExternalFilenameRaw === 'string'
@@ -103,7 +109,12 @@ export function createResolveActiveSubtitleSidebarSourceHandler(deps: {
return { path: currentExternalFilename, sourceKey: currentExternalFilename };
}
const track = getActiveSubtitleTrack(input.currentTrackRaw, input.trackListRaw, input.sidRaw);
const track = getActiveSubtitleTrack(
input.currentTrackRaw,
input.trackListRaw,
input.sidRaw,
input.allowSelectedFallback !== false,
);
if (!track) {
deps.logDebug?.('[subtitle-prefetch] no active subtitle track selected yet');
return null;
+12 -13
View File
@@ -1424,19 +1424,6 @@ test('subtitle annotation CSS underlines JLPT tokens without changing token colo
);
});
test('prepareSecondarySubtitleLines drops layered duplicate lines in short stacks', () => {
// A word-level animation stacks one event per layer copy; the stack is too short for
// the karaoke heuristic but the duplicates are still never distinct content.
assert.deepEqual(prepareSecondarySubtitleLines('Your\\NYour\\NYour\\NYour\\Nmosaic'), [
'Your',
'mosaic',
]);
assert.deepEqual(prepareSecondarySubtitleLines('One line\\NAnother line'), [
'One line',
'Another line',
]);
});
test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one deduped line', () => {
// Karaoke-typeset OP/ED: one ASS event per syllable, duplicated across layers,
// joined with \N by mpv's secondary-sub-text.
@@ -1447,6 +1434,18 @@ test('prepareSecondarySubtitleLines collapses karaoke syllable spam into one ded
assert.deepEqual(prepareSecondarySubtitleLines(karaoke), ['ya This no ma ups']);
});
test('prepareSecondarySubtitleLines preserves repeated short dialogue without layer metadata', () => {
const dialogue = ['Wait', 'Wait', 'Wait'];
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
});
test('prepareSecondarySubtitleLines preserves short simultaneous dialogue without repeats', () => {
const dialogue = ['Wait', 'Go!', 'No!', 'Run!'];
assert.deepEqual(prepareSecondarySubtitleLines(dialogue.join('\\N')), dialogue);
});
test('prepareSecondarySubtitleLines keeps normal dialogue lines intact', () => {
const dialogue = ' I never expected this. \\N\\N But here we are. ';
+3 -9
View File
@@ -677,12 +677,10 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
if (!isKaraokeLikeLineSet(lines)) {
return lines;
}
// Identical lines in one render are layered copies of the same event (animation
// scripts stack several per glyph), never distinct content -- always drop them, so a
// short stack like "Your ×4 / mosaic" collapses without needing the karaoke
// heuristic. Karaoke-likeness is still judged on the raw stack, where the layered
// repetition is the signal.
const seen = new Set<string>();
const unique: string[] = [];
for (const line of lines) {
@@ -690,10 +688,6 @@ export function prepareSecondarySubtitleLines(text: string): string[] {
seen.add(line);
unique.push(line);
}
if (!isKaraokeLikeLineSet(lines)) {
return unique;
}
return [unique.join(' ')];
}