From 650e95cdc31b77842fcf8eddce4781d91308db97 Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 15 Mar 2026 17:21:57 -0700 Subject: [PATCH] Feature/renderer performance (#24) --- .../2026-03-15-renderer-performance-design.md | 283 +++ .../plans/2026-03-15-renderer-performance.md | 1588 +++++++++++++++++ scripts/get_frequency.ts | 2 + src/core/services/mpv-properties.ts | 2 + src/core/services/mpv-protocol.test.ts | 20 + src/core/services/mpv-protocol.ts | 14 + src/core/services/mpv.ts | 8 + src/core/services/subtitle-cue-parser.test.ts | 274 +++ src/core/services/subtitle-cue-parser.ts | 180 ++ src/core/services/subtitle-prefetch.test.ts | 234 +++ src/core/services/subtitle-prefetch.ts | 153 ++ .../subtitle-processing-controller.test.ts | 42 + .../subtitle-processing-controller.ts | 8 + src/core/services/tokenizer.test.ts | 57 + .../services/tokenizer/annotation-stage.ts | 202 ++- src/main.ts | 128 +- .../runtime/mpv-client-event-bindings.test.ts | 4 + src/main/runtime/mpv-client-event-bindings.ts | 6 + src/main/runtime/mpv-main-event-actions.ts | 2 + .../runtime/mpv-main-event-bindings.test.ts | 6 + src/main/runtime/mpv-main-event-bindings.ts | 6 + src/main/runtime/mpv-main-event-main-deps.ts | 10 + .../runtime/subtitle-prefetch-init.test.ts | 114 ++ src/main/runtime/subtitle-prefetch-init.ts | 83 + .../runtime/subtitle-prefetch-source.test.ts | 47 + src/main/runtime/subtitle-prefetch-source.ts | 42 + src/renderer/subtitle-render.test.ts | 9 + src/renderer/subtitle-render.ts | 18 +- 28 files changed, 3435 insertions(+), 107 deletions(-) create mode 100644 docs/architecture/2026-03-15-renderer-performance-design.md create mode 100644 docs/superpowers/plans/2026-03-15-renderer-performance.md create mode 100644 src/core/services/subtitle-cue-parser.test.ts create mode 100644 src/core/services/subtitle-cue-parser.ts create mode 100644 src/core/services/subtitle-prefetch.test.ts create mode 100644 src/core/services/subtitle-prefetch.ts create mode 100644 src/main/runtime/subtitle-prefetch-init.test.ts create mode 100644 src/main/runtime/subtitle-prefetch-init.ts create mode 100644 src/main/runtime/subtitle-prefetch-source.test.ts create mode 100644 src/main/runtime/subtitle-prefetch-source.ts diff --git a/docs/architecture/2026-03-15-renderer-performance-design.md b/docs/architecture/2026-03-15-renderer-performance-design.md new file mode 100644 index 0000000..42dfa07 --- /dev/null +++ b/docs/architecture/2026-03-15-renderer-performance-design.md @@ -0,0 +1,283 @@ +# Renderer Performance Optimizations + +**Date:** 2026-03-15 +**Status:** Draft + +## Goal + +Minimize the time between a subtitle line appearing and annotations being displayed. Three optimizations target different pipeline stages to achieve this. + +## Current Pipeline (Warm State) + +```text +MPV subtitle change (0ms) + -> IPC to main (5ms) + -> Cache check (2ms) + -> [CACHE MISS] Yomitan parser (35-180ms) + -> Parallel: MeCab enrichment (20-80ms) + Frequency lookup (15-50ms) + -> Annotation stage: 4 sequential passes (25-70ms) + -> IPC to renderer (10ms) + -> DOM render: createElement per token (15-50ms) + ───────────────────────────────── + Total: ~200-320ms (cache miss) + Total: ~72ms (cache hit) +``` + +## Target Pipeline + +```text +MPV subtitle change (0ms) + -> IPC to main (5ms) + -> Cache check (2ms) + -> [CACHE HIT via prefetch] (0ms) + -> IPC to renderer (10ms) + -> DOM render: cloneNode from template (10-30ms) + ───────────────────────────────── + Total: ~30-50ms (prefetch-warmed, normal playback) + + [CACHE MISS, e.g. immediate seek] + -> Yomitan parser (35-180ms) + -> Parallel: MeCab enrichment + Frequency lookup + -> Annotation stage: 1 batched pass (10-25ms) + -> IPC to renderer (10ms) + -> DOM render: cloneNode from template (10-30ms) + ───────────────────────────────── + Total: ~150-260ms (cache miss, still improved) +``` + +--- + +## Optimization 1: Subtitle Prefetching + +### Summary + +A new `SubtitlePrefetchService` parses external subtitle files and tokenizes upcoming lines in the background before they appear on screen. This converts most cache misses into cache hits during normal playback. + +### Scope + +External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out of scope since Japanese subtitles are virtually always external files. + +### Architecture + +#### Subtitle File Parsing + +A new cue parser that extracts both timing and text content from subtitle files. The existing `parseSrtOrVttStartTimes` in `subtitle-delay-shift.ts` only extracts timing; this needs a companion that also extracts the dialogue text. + +**Parsed cue structure:** +```typescript +interface SubtitleCue { + startTime: number; // seconds + endTime: number; // seconds + text: string; // raw subtitle text +} +``` + +**Supported formats:** +- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks. +- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing. + ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text. + +#### 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. +2. **Parse phase:** Parse all cues from the file content. Sort by start time. Store as an ordered array. +3. **Priority window:** Determine the current playback position. Identify the next 10 cues as the priority window. +4. **Priority tokenization:** Tokenize the priority window cues sequentially, storing results into the `SubtitleProcessingController`'s tokenization cache. +5. **Background tokenization:** After the priority window is done, tokenize remaining cues working forward from the current position, then wrapping around to cover earlier cues. The prefetcher stops once it has tokenized all cues or the cache is full (whichever comes first) to avoid wasteful eviction churn. For files with more cues than the cache limit, background tokenization focuses on cues ahead of the current position. +6. **Seek handling:** On seek, re-compute the priority window from the new position. A seek is detected by observing MPV's `time-pos` property and checking if the delta from the last observed position exceeds a threshold (e.g., > 3 seconds forward or any backward jump). The current in-flight tokenization finishes naturally, then the new priority window takes over. +7. **Teardown:** When the subtitle track changes or playback ends, stop all prefetch work and discard state. + +#### Live Priority + +The prefetcher and live subtitle handler share the Yomitan parser (single-threaded IPC). Live subtitle requests must always take priority. The prefetcher: + +- Checks a `paused` flag before each cue tokenization. The live handler sets `paused = true` on subtitle change and clears it after emission. +- Yields between each background cue tokenization (via `setTimeout(0)` or equivalent) so the live handler can set the pause flag between cues. +- When paused, the prefetcher waits (polling the flag on a short interval or awaiting a resume signal) before continuing with the next cue. + +#### Cache Integration + +The prefetcher calls the same `tokenizeSubtitle` function used by live processing to produce `SubtitleData` results, then stores them into the existing `SubtitleProcessingController` tokenization cache via a new method: + +```typescript +// New methods on SubtitleProcessingController +preCacheTokenization: (text: string, data: SubtitleData) => void; +isCacheFull: () => boolean; +``` + +`preCacheTokenization` uses the same `setCachedTokenization` logic internally (LRU eviction, Map-based storage). `isCacheFull` returns `true` when the cache has reached its limit, allowing the prefetcher to stop background tokenization and avoid wasteful eviction churn. + +#### Cache Invalidation + +When the user marks a word as known (or any event triggers `invalidateTokenizationCache()`), all cached results are cleared -- including prefetched ones, since they share the same cache. After invalidation, the prefetcher re-computes the priority window from the current playback position and re-tokenizes those cues to restore warm cache state. + +#### Error Handling + +If the subtitle file is malformed or partially parseable, the cue parser uses what it can extract. A file that yields zero cues disables prefetching silently (falls back to live-only processing). Encoding errors from `loadSubtitleSourceText` are caught and logged; prefetching is skipped for that track. + +#### Integration Points + +- **MPV property subscriptions:** Needs `track-list` (to detect external subtitle file path) and `time-pos` (to track playback position for window calculation and seek detection). +- **File loading:** Uses existing `loadSubtitleSourceText` dependency. +- **Tokenization:** Calls the same `tokenizeSubtitle` function used by live processing. +- **Cache:** Writes into `SubtitleProcessingController`'s cache. +- **Cache invalidation:** Listens for cache invalidation events to re-prefetch the priority window. + +### Files Affected + +- **New:** `src/core/services/subtitle-prefetch.ts` -- the prefetch service +- **New:** `src/core/services/subtitle-cue-parser.ts` -- SRT/VTT/ASS cue parser (text + timing) +- **Modified:** `src/core/services/subtitle-processing-controller.ts` -- expose `preCacheTokenization` method +- **Modified:** `src/main.ts` -- wire up the prefetch service, listen to track changes + +--- + +## Optimization 2: Batched Annotation Pass + +### Summary + +Collapse the 4 sequential annotation passes (`applyKnownWordMarking` -> `applyFrequencyMarking` -> `applyJlptMarking` -> `markNPlusOneTargets`) into a single iteration over the token array, followed by N+1 marking. + +**Important context:** Frequency rank _values_ (`token.frequencyRank`) are already assigned at the parser level by `applyFrequencyRanks()` in `tokenizer.ts`, before the annotation stage is called. The annotation stage's `applyFrequencyMarking` only performs POS-based _filtering_ -- clearing `frequencyRank` to `undefined` for tokens that should be excluded (particles, noise tokens, etc.) and normalizing valid ranks. This optimization does not change the parser-level frequency rank assignment; it only batches the annotation-level filtering. + +### Current Flow (4 passes, 4 array copies) + +```text +tokens (already have frequencyRank values from parser-level applyFrequencyRanks) + -> applyKnownWordMarking() // .map() -> new array + -> applyFrequencyMarking() // .map() -> new array (POS-based filtering only) + -> applyJlptMarking() // .map() -> new array + -> markNPlusOneTargets() // .map() -> new array +``` + +### Dependency Analysis + +All annotations either depend on MeCab POS data or benefit from running after it: +- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately. +- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data. +- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data. +- **N+1 marking:** Uses POS exclusion sets to filter candidates. Depends on known word status + MeCab POS. + +Since frequency filtering and JLPT marking both depend on POS data from MeCab enrichment, and MeCab enrichment already happens before the annotation stage, all four can run in a single pass after MeCab completes. + +### New Flow (1 pass + N+1) + +```typescript +function annotateTokens(tokens, deps, options): MergedToken[] { + const pos1Exclusions = resolvePos1Exclusions(options); + const pos2Exclusions = resolvePos2Exclusions(options); + + // Single pass: known word + frequency filtering + JLPT computed together + const annotated = tokens.map((token) => { + const isKnown = nPlusOneEnabled + ? token.isKnown || computeIsKnown(token, deps) + : false; + + // Filter frequency rank using POS exclusions (rank values already set at parser level) + const frequencyRank = frequencyEnabled + ? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions) + : undefined; + + const jlptLevel = jlptEnabled + ? computeJlptLevel(token, deps.getJlptLevel) + : undefined; + + return { ...token, isKnown, frequencyRank, jlptLevel }; + }); + + // N+1 must run after known word status is set for all tokens + if (nPlusOneEnabled) { + return markNPlusOneTargets(annotated, minSentenceWords, pos1Exclusions, pos2Exclusions); + } + + return annotated; +} +``` + +### What Changes + +- The individual `applyKnownWordMarking`, `applyFrequencyMarking`, `applyJlptMarking` functions are refactored into per-token computation helpers (pure functions that compute a single field). The frequency helper is named `filterFrequencyRank` to clarify it performs POS-based exclusion, not rank computation. +- The `annotateTokens` orchestrator runs one `.map()` call that invokes all three helpers per token. +- `markNPlusOneTargets` remains a separate pass because it needs the full array with `isKnown` set (it examines sentence-level context). +- The parser-level `applyFrequencyRanks()` call in `tokenizer.ts` is unchanged -- it remains a separate step outside the annotation stage. +- Net: 4 array copies + 4 iterations become 1 array copy + 1 iteration + N+1 pass. + +### Expected Savings + +~15-45ms saved (3 fewer array allocations + 3 fewer full iterations). Annotation drops from ~25-70ms to ~10-25ms. + +### Files Affected + +- **Modified:** `src/core/services/tokenizer/annotation-stage.ts` -- refactor into batched single-pass + +--- + +## Optimization 3: DOM Template Pooling + +### Summary + +Replace `document.createElement('span')` calls in the renderer with `templateSpan.cloneNode(false)` from a pre-created template element. + +### Current Behavior + +In `renderWithTokens` (`subtitle-render.ts`), each render cycle: +1. Clears DOM with `innerHTML = ''` +2. Creates a `DocumentFragment` +3. Calls `document.createElement('span')` for each token (~10-15 per subtitle) +4. Sets `className`, `textContent`, `dataset.*` individually +5. Appends fragment to root + +### New Behavior + +1. At renderer initialization (`createSubtitleRenderer`), create a single template: + ```typescript + const templateSpan = document.createElement('span'); + ``` +2. In `renderWithTokens`, replace every `document.createElement('span')` with: + ```typescript + const span = templateSpan.cloneNode(false) as HTMLSpanElement; + ``` +3. Replace all `innerHTML = ''` calls with `root.replaceChildren()` to avoid the HTML parser invocation on clear. This applies to `renderSubtitle` (primary subtitle root), `renderSecondarySub` (secondary subtitle root), and `renderCharacterLevel` if applicable. +4. Everything else stays the same (setting className, textContent, dataset, appending to fragment). + +### Why cloneNode Over Full Node Recycling + +Full recycling (collecting old nodes, clearing attributes, reusing them) requires carefully resetting every `dataset.*` property that might have been set on a previous render. This is error-prone -- a stale `data-frequency-rank` from a previous subtitle appearing on a new token would cause incorrect styling. `cloneNode(false)` on a bare template is nearly as fast and produces a clean node every time. + +### Expected Savings + +`cloneNode(false)` is ~2-3x faster than `createElement` in most browser engines. For 10-15 tokens per subtitle: ~3-8ms saved per render cycle. + +### Files Affected + +- **Modified:** `src/renderer/subtitle-render.ts` -- template creation + cloneNode usage + +--- + +## Combined Impact Summary + +| Scenario | Before | After | Improvement | +|----------|--------|-------|-------------| +| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% | +| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% | +| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% | + +--- + +## Files Summary + +### New Files +- `src/core/services/subtitle-prefetch.ts` +- `src/core/services/subtitle-cue-parser.ts` + +### Modified Files +- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`) +- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass) +- `src/renderer/subtitle-render.ts` (template cloneNode) +- `src/main.ts` (wire up prefetch service) + +### Test Files +- New tests for subtitle cue parser (SRT, VTT, ASS formats) +- New tests for subtitle prefetch service (priority window, seek, pause/resume) +- Updated tests for annotation stage (same behavior, new implementation) +- Updated tests for subtitle render (template cloning) diff --git a/docs/superpowers/plans/2026-03-15-renderer-performance.md b/docs/superpowers/plans/2026-03-15-renderer-performance.md new file mode 100644 index 0000000..8cf7d9f --- /dev/null +++ b/docs/superpowers/plans/2026-03-15-renderer-performance.md @@ -0,0 +1,1588 @@ +# Renderer Performance Optimizations Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Minimize subtitle-to-annotation latency via prefetching, batched annotation, and DOM template pooling. + +**Architecture:** Three independent optimizations targeting different pipeline stages: (1) a subtitle prefetch service that parses external subtitle files and tokenizes upcoming lines in the background, (2) collapsing 4 sequential annotation passes into a single loop, and (3) using `cloneNode(false)` from a template span instead of `createElement`. + +**Tech Stack:** TypeScript, Electron, Bun test runner (`node:test` + `node:assert/strict`) + +**Spec:** `docs/architecture/2026-03-15-renderer-performance-design.md` + +**Test command:** `bun test ` + +--- + +## File Structure + +### New Files + +| File | Responsibility | +|------|---------------| +| `src/core/services/subtitle-cue-parser.ts` | Parse SRT/VTT/ASS files into `SubtitleCue[]` (timing + text) | +| `src/core/services/subtitle-cue-parser.test.ts` | Tests for cue parser | +| `src/core/services/subtitle-prefetch.ts` | Background tokenization service with priority window + seek handling | +| `src/core/services/subtitle-prefetch.test.ts` | Tests for prefetch service | + +### Modified Files + +| File | Change | +|------|--------| +| `src/core/services/subtitle-processing-controller.ts` | Add `preCacheTokenization` + `isCacheFull` to public interface | +| `src/core/services/subtitle-processing-controller.test.ts` | Add tests for new methods | +| `src/core/services/tokenizer/annotation-stage.ts` | Refactor 4 passes into 1 batched pass + N+1 | +| `src/core/services/tokenizer/annotation-stage.test.ts` | Existing tests must still pass (behavioral equivalence) | +| `src/renderer/subtitle-render.ts` | `cloneNode` template + `replaceChildren()` | +| `src/main.ts` | Wire up prefetch service | + + +--- + +## Chunk 1: Batched Annotation Pass + DOM Template Pooling + +### Task 1: Extend SubtitleProcessingController with `preCacheTokenization` and `isCacheFull` + +**Files:** +- Modify: `src/core/services/subtitle-processing-controller.ts:10-14` (interface), `src/core/services/subtitle-processing-controller.ts:112-133` (return object) +- Test: `src/core/services/subtitle-processing-controller.test.ts` + +- [ ] **Step 1: Write failing tests for `preCacheTokenization` and `isCacheFull`** + +Add to end of `src/core/services/subtitle-processing-controller.test.ts`: + +```typescript +test('preCacheTokenization stores entry that is returned on next subtitle change', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.preCacheTokenization('予め', { text: '予め', tokens: [] }); + controller.onSubtitleChange('予め'); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 0, 'should not call tokenize when pre-cached'); + assert.deepEqual(emitted, [{ text: '予め', tokens: [] }]); +}); + +test('isCacheFull returns false when cache is below limit', () => { + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: null }), + emitSubtitle: () => {}, + }); + + assert.equal(controller.isCacheFull(), false); +}); + +test('isCacheFull returns true when cache reaches limit', async () => { + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + emitSubtitle: () => {}, + }); + + // Fill cache to the 256 limit + for (let i = 0; i < 256; i += 1) { + controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] }); + } + + assert.equal(controller.isCacheFull(), true); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/core/services/subtitle-processing-controller.test.ts` +Expected: FAIL — `preCacheTokenization` and `isCacheFull` are not defined on the controller interface. + +- [ ] **Step 3: Add `preCacheTokenization` and `isCacheFull` to the interface and implementation** + +In `src/core/services/subtitle-processing-controller.ts`, update the interface (line 10-14): + +```typescript +export interface SubtitleProcessingController { + onSubtitleChange: (text: string) => void; + refreshCurrentSubtitle: (textOverride?: string) => void; + invalidateTokenizationCache: () => void; + preCacheTokenization: (text: string, data: SubtitleData) => void; + isCacheFull: () => boolean; +} +``` + +Add to the return object (after `invalidateTokenizationCache` at line 130-132): + +```typescript + invalidateTokenizationCache: () => { + tokenizationCache.clear(); + }, + preCacheTokenization: (text: string, data: SubtitleData) => { + setCachedTokenization(text, data); + }, + isCacheFull: () => { + return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; + }, +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/core/services/subtitle-processing-controller.test.ts` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/services/subtitle-processing-controller.ts src/core/services/subtitle-processing-controller.test.ts +git commit -m "feat: add preCacheTokenization and isCacheFull to SubtitleProcessingController" +``` + +--- + +### Task 2: Batch annotation passes into a single loop + +This refactors `annotateTokens` in `annotation-stage.ts` to perform known-word marking, frequency filtering, and JLPT marking in a single `.map()` call instead of 3 separate passes. `markNPlusOneTargets` remains a separate pass (needs full array with `isKnown` set). + +**Files:** +- Modify: `src/core/services/tokenizer/annotation-stage.ts:448-502` +- Test: `src/core/services/tokenizer/annotation-stage.test.ts` (existing tests must pass unchanged) + +- [ ] **Step 1: Run existing annotation tests to capture baseline** + +Run: `bun test src/core/services/tokenizer/annotation-stage.test.ts` +Expected: All tests PASS. Note the count for regression check. + +- [ ] **Step 2: Extract per-token helper functions** + +These are pure functions extracted from the existing `applyKnownWordMarking`, `applyFrequencyMarking`, and `applyJlptMarking`. They compute a single field per token. Add them above the `annotateTokens` function in `annotation-stage.ts`. + +Add `computeTokenKnownStatus` (extracted from `applyKnownWordMarking` lines 46-59): + +```typescript +function computeTokenKnownStatus( + token: MergedToken, + isKnownWord: (text: string) => boolean, + knownWordMatchMode: NPlusOneMatchMode, +): boolean { + const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode); + return token.isKnown || (matchText ? isKnownWord(matchText) : false); +} +``` + +Add `filterTokenFrequencyRank` (extracted from `applyFrequencyMarking` lines 147-167): + +```typescript +function filterTokenFrequencyRank( + token: MergedToken, + pos1Exclusions: ReadonlySet, + pos2Exclusions: ReadonlySet, +): number | undefined { + if (isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions)) { + return undefined; + } + + if (typeof token.frequencyRank === 'number' && Number.isFinite(token.frequencyRank)) { + return Math.max(1, Math.floor(token.frequencyRank)); + } + + return undefined; +} +``` + +Add `computeTokenJlptLevel` (extracted from `applyJlptMarking` lines 428-446): + +```typescript +function computeTokenJlptLevel( + token: MergedToken, + getJlptLevel: (text: string) => JlptLevel | null, +): JlptLevel | undefined { + if (!isJlptEligibleToken(token)) { + return undefined; + } + + const primaryLevel = getCachedJlptLevel(resolveJlptLookupText(token), getJlptLevel); + const fallbackLevel = + primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null; + + const level = primaryLevel ?? fallbackLevel ?? token.jlptLevel; + return level ?? undefined; +} +``` + +- [ ] **Step 3: Rewrite `annotateTokens` to use single-pass batching** + +Replace the `annotateTokens` function body (lines 448-502) with: + +```typescript +export function annotateTokens( + tokens: MergedToken[], + deps: AnnotationStageDeps, + options: AnnotationStageOptions = {}, +): MergedToken[] { + const pos1Exclusions = resolvePos1Exclusions(options); + const pos2Exclusions = resolvePos2Exclusions(options); + const nPlusOneEnabled = options.nPlusOneEnabled !== false; + const frequencyEnabled = options.frequencyEnabled !== false; + const jlptEnabled = options.jlptEnabled !== false; + + // Single pass: compute known word status, frequency filtering, and JLPT level together + const annotated = tokens.map((token) => { + const isKnown = nPlusOneEnabled + ? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode) + : false; + + const frequencyRank = frequencyEnabled + ? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions) + : undefined; + + const jlptLevel = jlptEnabled + ? computeTokenJlptLevel(token, deps.getJlptLevel) + : undefined; + + return { + ...token, + isKnown, + isNPlusOneTarget: nPlusOneEnabled ? token.isNPlusOneTarget : false, + frequencyRank, + jlptLevel, + }; + }); + + if (!nPlusOneEnabled) { + return annotated; + } + + const minSentenceWordsForNPlusOne = options.minSentenceWordsForNPlusOne; + const sanitizedMinSentenceWordsForNPlusOne = + minSentenceWordsForNPlusOne !== undefined && + Number.isInteger(minSentenceWordsForNPlusOne) && + minSentenceWordsForNPlusOne > 0 + ? minSentenceWordsForNPlusOne + : 3; + + return markNPlusOneTargets( + annotated, + sanitizedMinSentenceWordsForNPlusOne, + pos1Exclusions, + pos2Exclusions, + ); +} +``` + +- [ ] **Step 4: Run existing tests to verify behavioral equivalence** + +Run: `bun test src/core/services/tokenizer/annotation-stage.test.ts` +Expected: All tests PASS with same count as baseline. + +- [ ] **Step 5: Remove dead code** + +Delete the now-unused `applyKnownWordMarking` (lines 46-59), `applyFrequencyMarking` (lines 147-167), and `applyJlptMarking` (lines 428-446) functions. These are replaced by the per-token helpers. + +- [ ] **Step 6: Run tests again after cleanup** + +Run: `bun test src/core/services/tokenizer/annotation-stage.test.ts` +Expected: All tests PASS. + +- [ ] **Step 7: Run full test suite** + +Run: `bun run test` +Expected: Same results as baseline (500 pass, 1 pre-existing fail). + +- [ ] **Step 8: Commit** + +```bash +git add src/core/services/tokenizer/annotation-stage.ts +git commit -m "perf: batch annotation passes into single loop + +Collapse applyKnownWordMarking, applyFrequencyMarking, and +applyJlptMarking into a single .map() call. markNPlusOneTargets +remains a separate pass (needs full array with isKnown set). + +Eliminates 3 intermediate array allocations and 3 redundant +iterations over the token array." +``` + +--- + +### Task 3: DOM template pooling and replaceChildren + +**Files:** +- Modify: `src/renderer/subtitle-render.ts:289,325` (`createElement` calls), `src/renderer/subtitle-render.ts:473,481` (`renderCharacterLevel` createElement calls), `src/renderer/subtitle-render.ts:506,555` (`innerHTML` calls) + +- [ ] **Step 1: Replace `innerHTML = ''` with `replaceChildren()` in all render functions** + +In `src/renderer/subtitle-render.ts`: + +At line 506 in `renderSubtitle`: +```typescript +// Before: +ctx.dom.subtitleRoot.innerHTML = ''; +// After: +ctx.dom.subtitleRoot.replaceChildren(); +``` + +At line 555 in `renderSecondarySub`: +```typescript +// Before: +ctx.dom.secondarySubRoot.innerHTML = ''; +// After: +ctx.dom.secondarySubRoot.replaceChildren(); +``` + +- [ ] **Step 2: Add template span and replace `createElement('span')` with `cloneNode`** + +In `renderWithTokens` (line 250), add the template as a module-level constant near the top of the file (after the type declarations around line 20): + +```typescript +const SPAN_TEMPLATE = document.createElement('span'); +``` + +Then replace the two `document.createElement('span')` calls in `renderWithTokens`: + +At line 289 (sourceText branch): +```typescript +// Before: +const span = document.createElement('span'); +// After: +const span = SPAN_TEMPLATE.cloneNode(false) as HTMLSpanElement; +``` + +At line 325 (no-sourceText branch): +```typescript +// Before: +const span = document.createElement('span'); +// After: +const span = SPAN_TEMPLATE.cloneNode(false) as HTMLSpanElement; +``` + +Also in `renderCharacterLevel` at line 481: +```typescript +// Before: +const span = document.createElement('span'); +// After: +const span = SPAN_TEMPLATE.cloneNode(false) as HTMLSpanElement; +``` + +- [ ] **Step 3: Run full test suite** + +Run: `bun run test` +Expected: Same results as baseline. The renderer changes don't have direct unit tests (they run in Electron's renderer process), but we verify no compilation or type errors break existing tests. + +- [ ] **Step 4: Commit** + +```bash +git add src/renderer/subtitle-render.ts +git commit -m "perf: use cloneNode template and replaceChildren for DOM rendering + +Replace createElement('span') with cloneNode(false) from a pre-created +template span. Replace innerHTML='' with replaceChildren() to avoid +HTML parser invocation on clear." +``` + +--- + +## Chunk 2: Subtitle Cue Parser + +### Task 4: Create SRT/VTT cue parser + +**Files:** +- Create: `src/core/services/subtitle-cue-parser.ts` +- Create: `src/core/services/subtitle-cue-parser.test.ts` + +- [ ] **Step 1: Write failing tests for SRT parsing** + +Create `src/core/services/subtitle-cue-parser.test.ts`: + +```typescript +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser'; +import type { SubtitleCue } from './subtitle-cue-parser'; + +test('parseSrtCues parses basic SRT content', () => { + const content = [ + '1', + '00:00:01,000 --> 00:00:04,000', + 'こんにちは', + '', + '2', + '00:00:05,000 --> 00:00:08,500', + '元気ですか', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 2); + assert.equal(cues[0]!.startTime, 1.0); + assert.equal(cues[0]!.endTime, 4.0); + assert.equal(cues[0]!.text, 'こんにちは'); + assert.equal(cues[1]!.startTime, 5.0); + assert.equal(cues[1]!.endTime, 8.5); + assert.equal(cues[1]!.text, '元気ですか'); +}); + +test('parseSrtCues handles multi-line subtitle text', () => { + const content = [ + '1', + '00:01:00,000 --> 00:01:05,000', + 'これは', + 'テストです', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'これは\nテストです'); +}); + +test('parseSrtCues handles hours in timestamps', () => { + const content = [ + '1', + '01:30:00,000 --> 01:30:05,000', + 'テスト', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues[0]!.startTime, 5400.0); + assert.equal(cues[0]!.endTime, 5405.0); +}); + +test('parseSrtCues handles VTT-style dot separator', () => { + const content = [ + '1', + '00:00:01.000 --> 00:00:04.000', + 'VTTスタイル', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.startTime, 1.0); +}); + +test('parseSrtCues returns empty array for empty content', () => { + assert.deepEqual(parseSrtCues(''), []); + assert.deepEqual(parseSrtCues(' \n\n '), []); +}); + +test('parseSrtCues skips malformed timing lines gracefully', () => { + const content = [ + '1', + 'NOT A TIMING LINE', + 'テスト', + '', + '2', + '00:00:01,000 --> 00:00:02,000', + '有効', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, '有効'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/core/services/subtitle-cue-parser.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement `parseSrtCues`** + +Create `src/core/services/subtitle-cue-parser.ts`: + +```typescript +export interface SubtitleCue { + startTime: number; + endTime: number; + text: string; +} + +const SRT_TIMING_PATTERN = + /^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/; + +function parseTimestamp( + hours: string | undefined, + minutes: string, + seconds: string, + millis: string, +): number { + return ( + Number(hours || 0) * 3600 + + Number(minutes) * 60 + + Number(seconds) + + Number(millis.padEnd(3, '0')) / 1000 + ); +} + +export function parseSrtCues(content: string): SubtitleCue[] { + const cues: SubtitleCue[] = []; + const lines = content.split(/\r?\n/); + let i = 0; + + while (i < lines.length) { + // Skip blank lines and cue index numbers + const line = lines[i]!; + const timingMatch = SRT_TIMING_PATTERN.exec(line); + if (!timingMatch) { + i += 1; + continue; + } + + const startTime = parseTimestamp(timingMatch[1], timingMatch[2]!, timingMatch[3]!, timingMatch[4]!); + const endTime = parseTimestamp(timingMatch[5], timingMatch[6]!, timingMatch[7]!, timingMatch[8]!); + + // Collect text lines until blank line or end of file + i += 1; + const textLines: string[] = []; + while (i < lines.length && lines[i]!.trim() !== '') { + textLines.push(lines[i]!); + i += 1; + } + + const text = textLines.join('\n').trim(); + if (text) { + cues.push({ startTime, endTime, text }); + } + } + + return cues; +} +``` + +- [ ] **Step 4: Run SRT tests to verify they pass** + +Run: `bun test src/core/services/subtitle-cue-parser.test.ts` +Expected: All SRT tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/services/subtitle-cue-parser.ts src/core/services/subtitle-cue-parser.test.ts +git commit -m "feat: add SRT/VTT subtitle cue parser" +``` + +--- + +### Task 5: Add ASS cue parser + +**Files:** +- Modify: `src/core/services/subtitle-cue-parser.ts` +- Modify: `src/core/services/subtitle-cue-parser.test.ts` + +- [ ] **Step 1: Write failing tests for ASS parsing** + +Add to `src/core/services/subtitle-cue-parser.test.ts`: + +```typescript +test('parseAssCues parses basic ASS dialogue lines', () => { + const content = [ + '[Script Info]', + 'Title: Test', + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,こんにちは', + 'Dialogue: 0,0:00:05.00,0:00:08.50,Default,,0,0,0,,元気ですか', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues.length, 2); + assert.equal(cues[0]!.startTime, 1.0); + assert.equal(cues[0]!.endTime, 4.0); + assert.equal(cues[0]!.text, 'こんにちは'); + assert.equal(cues[1]!.startTime, 5.0); + assert.equal(cues[1]!.endTime, 8.5); + assert.equal(cues[1]!.text, '元気ですか'); +}); + +test('parseAssCues strips override tags from text', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\b1}太字{\\b0}テスト', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.text, '太字テスト'); +}); + +test('parseAssCues handles text containing commas', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,はい、そうです、ね', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.text, 'はい、そうです、ね'); +}); + +test('parseAssCues handles \\N line breaks', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,一行目\\N二行目', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.text, '一行目\\N二行目'); +}); + +test('parseAssCues returns empty for content without Events section', () => { + const content = [ + '[Script Info]', + 'Title: Test', + ].join('\n'); + + assert.deepEqual(parseAssCues(content), []); +}); + +test('parseAssCues skips Comment lines', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Comment: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,これはコメント', + 'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'これは字幕'); +}); + +test('parseAssCues handles hour timestamps', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,1:30:00.00,1:30:05.00,Default,,0,0,0,,テスト', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.startTime, 5400.0); + assert.equal(cues[0]!.endTime, 5405.0); +}); +``` + +- [ ] **Step 2: Run tests to verify ASS tests fail** + +Run: `bun test src/core/services/subtitle-cue-parser.test.ts` +Expected: SRT tests PASS, ASS tests FAIL — `parseAssCues` not defined. + +- [ ] **Step 3: Implement `parseAssCues`** + +Add to `src/core/services/subtitle-cue-parser.ts`: + +```typescript +const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g; + +const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/; + +function parseAssTimestamp(raw: string): number | null { + const match = ASS_TIMING_PATTERN.exec(raw.trim()); + if (!match) { + return null; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const centiseconds = Number(match[4]!.padEnd(2, '0')); + return hours * 3600 + minutes * 60 + seconds + centiseconds / 100; +} + +export function parseAssCues(content: string): SubtitleCue[] { + const cues: SubtitleCue[] = []; + const lines = content.split(/\r?\n/); + let inEventsSection = false; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + inEventsSection = trimmed.toLowerCase() === '[events]'; + continue; + } + + if (!inEventsSection) { + continue; + } + + if (!trimmed.startsWith('Dialogue:')) { + continue; + } + + // Split on first 9 commas (ASS v4+ has 10 fields; last is Text which can contain commas) + const afterPrefix = trimmed.slice('Dialogue:'.length); + const fields: string[] = []; + let remaining = afterPrefix; + for (let fieldIndex = 0; fieldIndex < 9; fieldIndex += 1) { + const commaIndex = remaining.indexOf(','); + if (commaIndex < 0) { + break; + } + fields.push(remaining.slice(0, commaIndex)); + remaining = remaining.slice(commaIndex + 1); + } + + if (fields.length < 9) { + continue; + } + + // fields[1] = Start, fields[2] = End (0-indexed: Layer, Start, End, ...) + const startTime = parseAssTimestamp(fields[1]!); + const endTime = parseAssTimestamp(fields[2]!); + if (startTime === null || endTime === null) { + continue; + } + + // remaining = Text field (everything after the 9th comma) + const rawText = remaining.replace(ASS_OVERRIDE_TAG_PATTERN, '').trim(); + if (rawText) { + cues.push({ startTime, endTime, text: rawText }); + } + } + + return cues; +} +``` + +- [ ] **Step 4: Run tests to verify all pass** + +Run: `bun test src/core/services/subtitle-cue-parser.test.ts` +Expected: All SRT + ASS tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/services/subtitle-cue-parser.ts src/core/services/subtitle-cue-parser.test.ts +git commit -m "feat: add ASS subtitle cue parser" +``` + +--- + +### Task 6: Add unified `parseSubtitleCues` with format detection + +**Files:** +- Modify: `src/core/services/subtitle-cue-parser.ts` +- Modify: `src/core/services/subtitle-cue-parser.test.ts` + +- [ ] **Step 1: Write failing tests for `parseSubtitleCues`** + +Add to `src/core/services/subtitle-cue-parser.test.ts`: + +```typescript +test('parseSubtitleCues auto-detects SRT format', () => { + const content = [ + '1', + '00:00:01,000 --> 00:00:04,000', + 'SRTテスト', + '', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.srt'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'SRTテスト'); +}); + +test('parseSubtitleCues auto-detects ASS format', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,ASSテスト', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'ASSテスト'); +}); + +test('parseSubtitleCues auto-detects VTT format', () => { + const content = [ + '1', + '00:00:01.000 --> 00:00:04.000', + 'VTTテスト', + '', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.vtt'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'VTTテスト'); +}); + +test('parseSubtitleCues returns empty for unknown format', () => { + assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []); +}); + +test('parseSubtitleCues returns cues sorted by start time', () => { + const content = [ + '1', + '00:00:10,000 --> 00:00:14,000', + '二番目', + '', + '2', + '00:00:01,000 --> 00:00:04,000', + '一番目', + '', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.srt'); + assert.equal(cues[0]!.text, '一番目'); + assert.equal(cues[1]!.text, '二番目'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/core/services/subtitle-cue-parser.test.ts` +Expected: New tests FAIL — `parseSubtitleCues` not defined. + +- [ ] **Step 3: Implement `parseSubtitleCues`** + +Add to `src/core/services/subtitle-cue-parser.ts`: + +```typescript +function detectSubtitleFormat(filename: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null { + const ext = filename.split('.').pop()?.toLowerCase() ?? ''; + if (ext === 'srt') return 'srt'; + if (ext === 'vtt') return 'vtt'; + if (ext === 'ass' || ext === 'ssa') return 'ass'; + return null; +} + +export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] { + const format = detectSubtitleFormat(filename); + let cues: SubtitleCue[]; + + switch (format) { + case 'srt': + case 'vtt': + cues = parseSrtCues(content); + break; + case 'ass': + case 'ssa': + cues = parseAssCues(content); + break; + default: + return []; + } + + cues.sort((a, b) => a.startTime - b.startTime); + return cues; +} +``` + +- [ ] **Step 4: Run tests to verify all pass** + +Run: `bun test src/core/services/subtitle-cue-parser.test.ts` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/services/subtitle-cue-parser.ts src/core/services/subtitle-cue-parser.test.ts +git commit -m "feat: add unified parseSubtitleCues with format auto-detection" +``` + +--- + +## Chunk 3: Subtitle Prefetch Service + +### Task 7: Create prefetch service core (priority window + background tokenization) + +**Files:** +- Create: `src/core/services/subtitle-prefetch.ts` +- Create: `src/core/services/subtitle-prefetch.test.ts` + +- [ ] **Step 1: Write failing tests for priority window computation and basic prefetching** + +Create `src/core/services/subtitle-prefetch.test.ts`: + +```typescript +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + computePriorityWindow, + createSubtitlePrefetchService, +} from './subtitle-prefetch'; +import type { SubtitleCue } from './subtitle-cue-parser'; +import type { SubtitleData } from '../../types'; + +function makeCues(count: number, startOffset = 0): SubtitleCue[] { + return Array.from({ length: count }, (_, i) => ({ + startTime: startOffset + i * 5, + endTime: startOffset + i * 5 + 4, + text: `line-${i}`, + })); +} + +test('computePriorityWindow returns next N cues from current position', () => { + const cues = makeCues(20); + const window = computePriorityWindow(cues, 12.0, 5); + + assert.equal(window.length, 5); + // Position 12.0 is during cue index 2 (start=10, end=14). Priority window starts from index 3. + assert.equal(window[0]!.text, 'line-3'); + assert.equal(window[4]!.text, 'line-7'); +}); + +test('computePriorityWindow clamps to remaining cues at end of file', () => { + const cues = makeCues(5); + const window = computePriorityWindow(cues, 18.0, 10); + + // Position 18.0 is during cue 3 (start=15). Only cue 4 is ahead. + assert.equal(window.length, 1); + assert.equal(window[0]!.text, 'line-4'); +}); + +test('computePriorityWindow returns empty when past all cues', () => { + const cues = makeCues(3); + const window = computePriorityWindow(cues, 999.0, 10); + assert.equal(window.length, 0); +}); + +test('computePriorityWindow at position 0 returns first N cues', () => { + const cues = makeCues(20); + const window = computePriorityWindow(cues, 0, 5); + + assert.equal(window.length, 5); + assert.equal(window[0]!.text, 'line-0'); +}); + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('prefetch service tokenizes priority window cues and caches them', async () => { + const cues = makeCues(20); + const cached: Map = new Map(); + let tokenizeCalls = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: (text, data) => { + cached.set(text, data); + }, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + // Allow all async tokenization to complete + for (let i = 0; i < 25; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + // Priority window (first 3) should be cached + assert.ok(cached.has('line-0')); + assert.ok(cached.has('line-1')); + assert.ok(cached.has('line-2')); +}); + +test('prefetch service stops when cache is full', async () => { + const cues = makeCues(20); + let tokenizeCalls = 0; + let cacheSize = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: () => { + cacheSize += 1; + }, + isCacheFull: () => cacheSize >= 5, + priorityWindowSize: 3, + }); + + service.start(0); + for (let i = 0; i < 30; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + // Should have stopped at 5 (cache full), not tokenized all 20 + assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`); +}); + +test('prefetch service can be stopped mid-flight', async () => { + const cues = makeCues(100); + let tokenizeCalls = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: () => {}, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + await flushMicrotasks(); + service.stop(); + const callsAtStop = tokenizeCalls; + + // Wait more to confirm no further calls + for (let i = 0; i < 10; i += 1) { + await flushMicrotasks(); + } + + assert.equal(tokenizeCalls, callsAtStop, 'No further tokenize calls after stop'); + assert.ok(tokenizeCalls < 100, 'Should not have tokenized all cues'); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `bun test src/core/services/subtitle-prefetch.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Implement the prefetch service** + +Create `src/core/services/subtitle-prefetch.ts`: + +```typescript +import type { SubtitleCue } from './subtitle-cue-parser'; +import type { SubtitleData } from '../../types'; + +export interface SubtitlePrefetchServiceDeps { + cues: SubtitleCue[]; + tokenizeSubtitle: (text: string) => Promise; + preCacheTokenization: (text: string, data: SubtitleData) => void; + isCacheFull: () => boolean; + priorityWindowSize?: number; +} + +export interface SubtitlePrefetchService { + start: (currentTimeSeconds: number) => void; + stop: () => void; + onSeek: (newTimeSeconds: number) => void; + pause: () => void; + resume: () => void; +} + +const DEFAULT_PRIORITY_WINDOW_SIZE = 10; + +export function computePriorityWindow( + cues: SubtitleCue[], + currentTimeSeconds: number, + windowSize: number, +): SubtitleCue[] { + if (cues.length === 0) { + return []; + } + + // Find the first cue whose start time is >= current position. + // This includes cues that start exactly at the current time (they haven't + // been displayed yet and should be prefetched). + let startIndex = -1; + for (let i = 0; i < cues.length; i += 1) { + if (cues[i]!.startTime >= currentTimeSeconds) { + startIndex = i; + break; + } + } + + if (startIndex < 0) { + // All cues are before current time + return []; + } + + return cues.slice(startIndex, startIndex + windowSize); +} + +export function createSubtitlePrefetchService( + deps: SubtitlePrefetchServiceDeps, +): SubtitlePrefetchService { + const windowSize = deps.priorityWindowSize ?? DEFAULT_PRIORITY_WINDOW_SIZE; + let stopped = true; + let paused = false; + let currentRunId = 0; + + async function tokenizeCueList( + cuesToProcess: SubtitleCue[], + runId: number, + ): Promise { + for (const cue of cuesToProcess) { + if (stopped || runId !== currentRunId) { + return; + } + + // Wait while paused + while (paused && !stopped && runId === currentRunId) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + if (stopped || runId !== currentRunId) { + return; + } + + if (deps.isCacheFull()) { + return; + } + + try { + const result = await deps.tokenizeSubtitle(cue.text); + if (result && !stopped && runId === currentRunId) { + deps.preCacheTokenization(cue.text, result); + } + } catch { + // Skip failed cues, continue prefetching + } + + // Yield to allow live processing to take priority + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + async function startPrefetching(currentTimeSeconds: number, runId: number): Promise { + const cues = deps.cues; + + // Phase 1: Priority window + const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); + await tokenizeCueList(priorityCues, runId); + + if (stopped || runId !== currentRunId) { + return; + } + + // Phase 2: Background - remaining cues forward from current position + const priorityTexts = new Set(priorityCues.map((c) => c.text)); + const remainingCues = cues.filter( + (cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text), + ); + await tokenizeCueList(remainingCues, runId); + + if (stopped || runId !== currentRunId) { + return; + } + + // Phase 3: Background - earlier cues (for rewind support) + const earlierCues = cues.filter( + (cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text), + ); + await tokenizeCueList(earlierCues, runId); + } + + return { + start(currentTimeSeconds: number) { + stopped = false; + paused = false; + currentRunId += 1; + const runId = currentRunId; + void startPrefetching(currentTimeSeconds, runId); + }, + + stop() { + stopped = true; + currentRunId += 1; + }, + + onSeek(newTimeSeconds: number) { + // Cancel current run and restart from new position + currentRunId += 1; + const runId = currentRunId; + void startPrefetching(newTimeSeconds, runId); + }, + + pause() { + paused = true; + }, + + resume() { + paused = false; + }, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `bun test src/core/services/subtitle-prefetch.test.ts` +Expected: All tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/core/services/subtitle-prefetch.ts src/core/services/subtitle-prefetch.test.ts +git commit -m "feat: add subtitle prefetch service with priority window + +Implements background tokenization of upcoming subtitle cues with a +configurable priority window. Supports stop, pause/resume, seek +re-prioritization, and cache-full stopping condition." +``` + +--- + +### Task 8: Add seek detection and pause/resume tests + +**Files:** +- Modify: `src/core/services/subtitle-prefetch.test.ts` + +- [ ] **Step 1: Write tests for seek re-prioritization and pause/resume** + +Add to `src/core/services/subtitle-prefetch.test.ts`: + +```typescript +test('prefetch service onSeek re-prioritizes from new position', async () => { + const cues = makeCues(20); + const cachedTexts: string[] = []; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + // Let a few cues process + for (let i = 0; i < 5; i += 1) { + await flushMicrotasks(); + } + + // Seek to near the end + service.onSeek(80.0); + for (let i = 0; i < 30; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + // After seek to 80.0, cues starting after 80.0 (line-17, line-18, line-19) should appear in cached + const hasPostSeekCue = cachedTexts.some((t) => t === 'line-17' || t === 'line-18' || t === 'line-19'); + assert.ok(hasPostSeekCue, 'Should have cached cues after seek position'); +}); + +test('prefetch service pause/resume halts and continues tokenization', async () => { + const cues = makeCues(20); + let tokenizeCalls = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: () => {}, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + await flushMicrotasks(); + service.pause(); + + const callsWhenPaused = tokenizeCalls; + // Wait while paused + for (let i = 0; i < 5; i += 1) { + await flushMicrotasks(); + } + // Should not have advanced much (may have 1 in-flight) + assert.ok(tokenizeCalls <= callsWhenPaused + 1, 'Should not tokenize much while paused'); + + service.resume(); + for (let i = 0; i < 30; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause'); +}); +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `bun test src/core/services/subtitle-prefetch.test.ts` +Expected: All tests PASS (the implementation from Task 7 already handles these cases). + +- [ ] **Step 3: Commit** + +```bash +git add src/core/services/subtitle-prefetch.test.ts +git commit -m "test: add seek and pause/resume tests for prefetch service" +``` + +--- + +### Task 9: Wire up prefetch service in main.ts + +This is the integration task that connects the prefetch service to the actual MPV events and subtitle processing controller. + +**Files:** +- Modify: `src/main.ts` +- Modify: `src/main/runtime/mpv-main-event-actions.ts` (extend time-pos handler) +- Modify: `src/main/runtime/mpv-main-event-bindings.ts` (pass new dep through) + +**Architecture context:** MPV events flow through a layered system: +1. `MpvIpcClient` (`src/core/services/mpv.ts`) emits events like `'time-pos-change'` +2. `mpv-client-event-bindings.ts` binds these to handler functions (e.g., `deps.onTimePosChange`) +3. `mpv-main-event-bindings.ts` wires up the bindings with concrete handler implementations created by `mpv-main-event-actions.ts` +4. The handlers are constructed via factory functions like `createHandleMpvTimePosChangeHandler` + +Key existing locations: +- `createHandleMpvTimePosChangeHandler` is in `src/main/runtime/mpv-main-event-actions.ts:89-99` +- The `onSubtitleChange` callback is at `src/main.ts:2841-2843` +- The `emitSubtitle` callback is at `src/main.ts:1051-1064` +- `invalidateTokenizationCache` is called at `src/main.ts:1433` (onSyncComplete) and `src/main.ts:2600` (onOptionsChanged) +- `loadSubtitleSourceText` is an inline closure at `src/main.ts:3496-3513` — it needs to be extracted or duplicated + +- [ ] **Step 1: Add imports** + +At the top of `src/main.ts`, add imports for the new modules: + +```typescript +import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; +import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; +import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch'; +``` + +- [ ] **Step 2: Extract `loadSubtitleSourceText` into a reusable function** + +The subtitle file loading logic at `src/main.ts:3496-3513` is currently an inline closure passed to `createShiftSubtitleDelayToAdjacentCueHandler`. Extract it into a standalone function above that usage site so it can be shared with the prefetcher: + +```typescript +async function loadSubtitleSourceText(source: string): Promise { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source; + return fs.promises.readFile(filePath, 'utf8'); +} +``` + +Then update the `createShiftSubtitleDelayToAdjacentCueHandler` call to use `loadSubtitleSourceText: loadSubtitleSourceText,` instead of the inline closure. + +- [ ] **Step 3: Add prefetch service state and init helper** + +Near the other service state declarations (near `tokenizeSubtitleDeferred` at line 1046), add: + +```typescript +let subtitlePrefetchService: SubtitlePrefetchService | null = null; +let lastObservedTimePos = 0; +const SEEK_THRESHOLD_SECONDS = 3; + +async function initSubtitlePrefetch( + externalFilename: string, + currentTimePos: number, +): Promise { + subtitlePrefetchService?.stop(); + subtitlePrefetchService = null; + + try { + const content = await loadSubtitleSourceText(externalFilename); + const cues = parseSubtitleCues(content, externalFilename); + if (cues.length === 0) { + return; + } + + subtitlePrefetchService = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => { + subtitleProcessingController.preCacheTokenization(text, data); + }, + isCacheFull: () => subtitleProcessingController.isCacheFull(), + }); + + subtitlePrefetchService.start(currentTimePos); + logger.info(`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`); + } catch (error) { + logger.warn('[subtitle-prefetch] failed to initialize:', (error as Error).message); + } +} +``` + +- [ ] **Step 4: Hook seek detection into the time-pos handler** + +The existing `createHandleMpvTimePosChangeHandler` in `src/main/runtime/mpv-main-event-actions.ts:89-99` fires on every `time-pos` update. Extend its deps interface to accept an optional seek callback: + +In `src/main/runtime/mpv-main-event-actions.ts`, add to the deps type: + +```typescript +export function createHandleMpvTimePosChangeHandler(deps: { + recordPlaybackPosition: (time: number) => void; + reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + refreshDiscordPresence: () => void; + onTimePosUpdate?: (time: number) => void; // NEW +}) { + return ({ time }: { time: number }): void => { + deps.recordPlaybackPosition(time); + deps.reportJellyfinRemoteProgress(false); + deps.refreshDiscordPresence(); + deps.onTimePosUpdate?.(time); // NEW + }; +} +``` + +Then in `src/main/runtime/mpv-main-event-bindings.ts` (around line 122), pass the new dep when constructing the handler: + +```typescript +const handleMpvTimePosChange = createHandleMpvTimePosChangeHandler({ + recordPlaybackPosition: (time) => deps.recordPlaybackPosition(time), + reportJellyfinRemoteProgress: (forceImmediate) => + deps.reportJellyfinRemoteProgress(forceImmediate), + refreshDiscordPresence: () => deps.refreshDiscordPresence(), + onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time), // NEW +}); +``` + +And add `onTimePosUpdate` to the deps interface of the main event bindings function, passing it through from `main.ts`. + +Finally, in `src/main.ts`, where the MPV main event bindings are wired up, provide the `onTimePosUpdate` callback: + +```typescript +onTimePosUpdate: (time) => { + const delta = time - lastObservedTimePos; + if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { + subtitlePrefetchService.onSeek(time); + } + lastObservedTimePos = time; +}, +``` + +- [ ] **Step 5: Hook prefetch pause/resume into live subtitle processing** + +At `src/main.ts:2841-2843`, the `onSubtitleChange` callback: + +```typescript +onSubtitleChange: (text) => { + subtitlePrefetchService?.pause(); // NEW: pause prefetch during live processing + subtitleProcessingController.onSubtitleChange(text); +}, +``` + +At `src/main.ts:1051-1064`, inside the `emitSubtitle` callback, add resume at the end: + +```typescript +emitSubtitle: (payload) => { + appState.currentSubtitleData = payload; + broadcastToOverlayWindows('subtitle:set', payload); + subtitleWsService.broadcast(payload, { /* ... existing ... */ }); + annotationSubtitleWsService.broadcast(payload, { /* ... existing ... */ }); + subtitlePrefetchService?.resume(); // NEW: resume prefetch after emission +}, +``` + +- [ ] **Step 6: Hook into cache invalidation** + +At `src/main.ts:1433` (onSyncComplete) after `invalidateTokenizationCache()`: + +```typescript +subtitleProcessingController.invalidateTokenizationCache(); +subtitlePrefetchService?.onSeek(lastObservedTimePos); // NEW: re-prefetch after invalidation +``` + +At `src/main.ts:2600` (onOptionsChanged) after `invalidateTokenizationCache()`: + +```typescript +subtitleProcessingController.invalidateTokenizationCache(); +subtitlePrefetchService?.onSeek(lastObservedTimePos); // NEW: re-prefetch after invalidation +``` + +- [ ] **Step 7: Trigger prefetch on subtitle track activation** + +Find where the subtitle track is activated in `main.ts` (where media path changes or subtitle track changes are handled). When a new external subtitle track is detected, call `initSubtitlePrefetch`. The exact location depends on how track changes are wired — search for where `track-list` is processed or where the subtitle track ID changes. Use MPV's `requestProperty('track-list')` to get the external filename, then call: + +```typescript +// When external subtitle track is detected: +const trackList = await appState.mpvClient?.requestProperty('track-list'); +// Find the active sub track, get its external-filename +// Then: +void initSubtitlePrefetch(externalFilename, lastObservedTimePos); +``` + +**Note:** The exact wiring location for track activation needs to be determined during implementation. Search for `media-path-change` handler or `updateCurrentMediaPath` (line 2854) as the likely trigger point — when a new media file is loaded, the subtitle track becomes available. + +- [ ] **Step 8: Run full test suite** + +Run: `bun run test` +Expected: Same results as baseline (500 pass, 1 pre-existing fail). + +- [ ] **Step 9: Commit** + +```bash +git add src/main.ts src/main/runtime/mpv-main-event-actions.ts src/main/runtime/mpv-main-event-bindings.ts +git commit -m "feat: wire up subtitle prefetch service to MPV events + +Initializes prefetch on external subtitle track activation, detects +seeks via time-pos delta threshold, pauses prefetch during live +subtitle processing, and restarts on cache invalidation." +``` + +--- + +## Final Verification + +### Task 10: Full test suite and type check + +- [ ] **Step 1: Run handoff type check** + +Run: `bun run typecheck` +Expected: No type errors. + +- [ ] **Step 2: Run fast test lane** + +Run: `bun run test:fast` +Expected: Fast unit/integration tests pass. + +- [ ] **Step 3: Run environment-sensitive test lane** + +Run: `bun run test:env` +Expected: Environment/runtime-sensitive tests pass. + +- [ ] **Step 4: Run production build** + +Run: `bun run build` +Expected: Production build succeeds. + +- [ ] **Step 5: Run dist smoke test** + +Run: `bun run test:smoke:dist` +Expected: Dist smoke tests pass. + +- [ ] **Step 6: Review all commits on the branch** + +Run: `git log --oneline main..HEAD` +Expected: ~8 focused commits, one per logical change. diff --git a/scripts/get_frequency.ts b/scripts/get_frequency.ts index 893d77e..5b61532 100644 --- a/scripts/get_frequency.ts +++ b/scripts/get_frequency.ts @@ -484,6 +484,7 @@ interface YomitanRuntimeState { yomitanExt: unknown | null; yomitanSession: unknown | null; parserWindow: unknown | null; + yomitanSession: unknown | null; parserReadyPromise: Promise | null; parserInitPromise: Promise | null; available: boolean; @@ -543,6 +544,7 @@ async function createYomitanRuntimeState( yomitanExt: null, yomitanSession: null, parserWindow: null, + yomitanSession: null, parserReadyPromise: null, parserInitPromise: null, available: false, diff --git a/src/core/services/mpv-properties.ts b/src/core/services/mpv-properties.ts index 089270f..e8b81ce 100644 --- a/src/core/services/mpv-properties.ts +++ b/src/core/services/mpv-properties.ts @@ -63,6 +63,8 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [ 'media-title', 'secondary-sub-visibility', 'sub-visibility', + 'sid', + 'track-list', ]; const MPV_INITIAL_PROPERTY_REQUESTS: Array = [ diff --git a/src/core/services/mpv-protocol.test.ts b/src/core/services/mpv-protocol.test.ts index 251b1a9..e2cd13e 100644 --- a/src/core/services/mpv-protocol.test.ts +++ b/src/core/services/mpv-protocol.test.ts @@ -60,6 +60,8 @@ function createDeps(overrides: Partial = {}): { emitSubtitleAssChange: (payload) => state.events.push(payload), emitSubtitleTiming: (payload) => state.events.push(payload), emitSecondarySubtitleChange: (payload) => state.events.push(payload), + emitSubtitleTrackChange: (payload) => state.events.push(payload), + emitSubtitleTrackListChange: (payload) => state.events.push(payload), getCurrentSubText: () => state.subText, setCurrentSubText: (text) => { state.subText = text; @@ -120,6 +122,24 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]); }); +test('dispatchMpvProtocolMessage emits subtitle track changes', async () => { + const { deps, state } = createDeps({ + emitSubtitleTrackChange: (payload) => state.events.push(payload), + emitSubtitleTrackListChange: (payload) => state.events.push(payload), + }); + + await dispatchMpvProtocolMessage( + { event: 'property-change', name: 'sid', data: '3' }, + 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 }] }]); +}); + test('dispatchMpvProtocolMessage enforces sub-visibility hidden when overlay suppression is enabled', async () => { const { deps, state } = createDeps({ isVisibleOverlayVisible: () => true, diff --git a/src/core/services/mpv-protocol.ts b/src/core/services/mpv-protocol.ts index 028d084..03768c3 100644 --- a/src/core/services/mpv-protocol.ts +++ b/src/core/services/mpv-protocol.ts @@ -52,6 +52,8 @@ export interface MpvProtocolHandleMessageDeps { emitSubtitleAssChange: (payload: { text: string }) => void; emitSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; emitSecondarySubtitleChange: (payload: { text: string }) => void; + emitSubtitleTrackChange: (payload: { sid: number | null }) => void; + emitSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void; getCurrentSubText: () => string; setCurrentSubText: (text: string) => void; setCurrentSubStart: (value: number) => void; @@ -160,6 +162,18 @@ export async function dispatchMpvProtocolMessage( const nextSubText = (msg.data as string) || ''; deps.setCurrentSecondarySubText(nextSubText); deps.emitSecondarySubtitleChange({ text: nextSubText }); + } else if (msg.name === 'sid') { + const sid = + typeof msg.data === 'number' + ? msg.data + : typeof msg.data === 'string' + ? Number(msg.data) + : null; + deps.emitSubtitleTrackChange({ sid: sid !== null && Number.isFinite(sid) ? sid : null }); + } else if (msg.name === 'track-list') { + deps.emitSubtitleTrackListChange({ + trackList: Array.isArray(msg.data) ? (msg.data as unknown[]) : null, + }); } else if (msg.name === 'aid') { deps.setCurrentAudioTrackId(typeof msg.data === 'number' ? (msg.data as number) : null); deps.syncCurrentAudioStreamIndex(); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index a5164e0..54a7667 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -118,6 +118,8 @@ export interface MpvIpcClientEventMap { 'duration-change': { duration: number }; 'pause-change': { paused: boolean }; 'secondary-subtitle-change': { text: string }; + 'subtitle-track-change': { sid: number | null }; + 'subtitle-track-list-change': { trackList: unknown[] | null }; 'media-path-change': { path: string }; 'media-title-change': { title: string | null }; 'subtitle-metrics-change': { patch: Partial }; @@ -325,6 +327,12 @@ export class MpvIpcClient implements MpvClient { emitSecondarySubtitleChange: (payload) => { this.emit('secondary-subtitle-change', payload); }, + emitSubtitleTrackChange: (payload) => { + this.emit('subtitle-track-change', payload); + }, + emitSubtitleTrackListChange: (payload) => { + this.emit('subtitle-track-list-change', payload); + }, getCurrentSubText: () => this.currentSubText, setCurrentSubText: (text: string) => { this.currentSubText = text; diff --git a/src/core/services/subtitle-cue-parser.test.ts b/src/core/services/subtitle-cue-parser.test.ts new file mode 100644 index 0000000..be7fae9 --- /dev/null +++ b/src/core/services/subtitle-cue-parser.test.ts @@ -0,0 +1,274 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { parseSrtCues, parseAssCues, parseSubtitleCues } from './subtitle-cue-parser'; +import type { SubtitleCue } from './subtitle-cue-parser'; + +test('parseSrtCues parses basic SRT content', () => { + const content = [ + '1', + '00:00:01,000 --> 00:00:04,000', + 'こんにちは', + '', + '2', + '00:00:05,000 --> 00:00:08,500', + '元気ですか', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 2); + assert.equal(cues[0]!.startTime, 1.0); + assert.equal(cues[0]!.endTime, 4.0); + assert.equal(cues[0]!.text, 'こんにちは'); + assert.equal(cues[1]!.startTime, 5.0); + assert.equal(cues[1]!.endTime, 8.5); + assert.equal(cues[1]!.text, '元気ですか'); +}); + +test('parseSrtCues handles multi-line subtitle text', () => { + const content = [ + '1', + '00:01:00,000 --> 00:01:05,000', + 'これは', + 'テストです', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'これは\nテストです'); +}); + +test('parseSrtCues handles hours in timestamps', () => { + const content = [ + '1', + '01:30:00,000 --> 01:30:05,000', + 'テスト', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues[0]!.startTime, 5400.0); + assert.equal(cues[0]!.endTime, 5405.0); +}); + +test('parseSrtCues handles VTT-style dot separator', () => { + const content = [ + '1', + '00:00:01.000 --> 00:00:04.000', + 'VTTスタイル', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.startTime, 1.0); +}); + +test('parseSrtCues returns empty array for empty content', () => { + assert.deepEqual(parseSrtCues(''), []); + assert.deepEqual(parseSrtCues(' \n\n '), []); +}); + +test('parseSrtCues skips malformed timing lines gracefully', () => { + const content = [ + '1', + 'NOT A TIMING LINE', + 'テスト', + '', + '2', + '00:00:01,000 --> 00:00:02,000', + '有効', + '', + ].join('\n'); + + const cues = parseSrtCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, '有効'); +}); + +test('parseAssCues parses basic ASS dialogue lines', () => { + const content = [ + '[Script Info]', + 'Title: Test', + '', + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,こんにちは', + 'Dialogue: 0,0:00:05.00,0:00:08.50,Default,,0,0,0,,元気ですか', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues.length, 2); + assert.equal(cues[0]!.startTime, 1.0); + assert.equal(cues[0]!.endTime, 4.0); + assert.equal(cues[0]!.text, 'こんにちは'); + assert.equal(cues[1]!.startTime, 5.0); + assert.equal(cues[1]!.endTime, 8.5); + assert.equal(cues[1]!.text, '元気ですか'); +}); + +test('parseAssCues strips override tags from text', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,{\\b1}太字{\\b0}テスト', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.text, '太字テスト'); +}); + +test('parseAssCues handles text containing commas', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,はい、そうです、ね', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.text, 'はい、そうです、ね'); +}); + +test('parseAssCues handles \\N line breaks', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,一行目\\N二行目', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.text, '一行目\\N二行目'); +}); + +test('parseAssCues returns empty for content without Events section', () => { + const content = [ + '[Script Info]', + 'Title: Test', + ].join('\n'); + + assert.deepEqual(parseAssCues(content), []); +}); + +test('parseAssCues skips Comment lines', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Comment: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,これはコメント', + 'Dialogue: 0,0:00:05.00,0:00:08.00,Default,,0,0,0,,これは字幕', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'これは字幕'); +}); + +test('parseAssCues handles hour timestamps', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,1:30:00.00,1:30:05.00,Default,,0,0,0,,テスト', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues[0]!.startTime, 5400.0); + assert.equal(cues[0]!.endTime, 5405.0); +}); + +test('parseAssCues respects dynamic field ordering from the Format row', () => { + const content = [ + '[Events]', + 'Format: Layer, Style, Start, End, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,Default,0:00:01.00,0:00:04.00,,0,0,0,,順番が違う', + ].join('\n'); + + const cues = parseAssCues(content); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.startTime, 1.0); + assert.equal(cues[0]!.endTime, 4.0); + assert.equal(cues[0]!.text, '順番が違う'); +}); + +test('parseSubtitleCues auto-detects SRT format', () => { + const content = [ + '1', + '00:00:01,000 --> 00:00:04,000', + 'SRTテスト', + '', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.srt'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'SRTテスト'); +}); + +test('parseSubtitleCues auto-detects ASS format', () => { + const content = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:04.00,Default,,0,0,0,,ASSテスト', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.ass'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'ASSテスト'); +}); + +test('parseSubtitleCues auto-detects VTT format', () => { + const content = [ + '1', + '00:00:01.000 --> 00:00:04.000', + 'VTTテスト', + '', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.vtt'); + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'VTTテスト'); +}); + +test('parseSubtitleCues returns empty for unknown format', () => { + assert.deepEqual(parseSubtitleCues('random content', 'test.xyz'), []); +}); + +test('parseSubtitleCues returns cues sorted by start time', () => { + const content = [ + '1', + '00:00:10,000 --> 00:00:14,000', + '二番目', + '', + '2', + '00:00:01,000 --> 00:00:04,000', + '一番目', + '', + ].join('\n'); + + const cues = parseSubtitleCues(content, 'test.srt'); + assert.equal(cues[0]!.text, '一番目'); + assert.equal(cues[1]!.text, '二番目'); +}); + +test('parseSubtitleCues detects subtitle formats from remote URLs', () => { + const assContent = [ + '[Events]', + 'Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text', + 'Dialogue: 0,0:00:01.00,0:00:02.00,Default,,0,0,0,,URLテスト', + ].join('\n'); + + const cues = parseSubtitleCues(assContent, 'https://host/subs.ass?lang=ja#track'); + + assert.equal(cues.length, 1); + assert.equal(cues[0]!.text, 'URLテスト'); +}); diff --git a/src/core/services/subtitle-cue-parser.ts b/src/core/services/subtitle-cue-parser.ts new file mode 100644 index 0000000..8faa9cc --- /dev/null +++ b/src/core/services/subtitle-cue-parser.ts @@ -0,0 +1,180 @@ +export interface SubtitleCue { + startTime: number; + endTime: number; + text: string; +} + +const SRT_TIMING_PATTERN = + /^\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})\s*-->\s*(?:(\d{1,2}):)?(\d{2}):(\d{2})[,.](\d{1,3})/; + +function parseTimestamp( + hours: string | undefined, + minutes: string, + seconds: string, + millis: string, +): number { + return ( + Number(hours || 0) * 3600 + + Number(minutes) * 60 + + Number(seconds) + + Number(millis.padEnd(3, '0')) / 1000 + ); +} + +export function parseSrtCues(content: string): SubtitleCue[] { + const cues: SubtitleCue[] = []; + const lines = content.split(/\r?\n/); + let i = 0; + + while (i < lines.length) { + const line = lines[i]!; + const timingMatch = SRT_TIMING_PATTERN.exec(line); + if (!timingMatch) { + i += 1; + continue; + } + + const startTime = parseTimestamp(timingMatch[1], timingMatch[2]!, timingMatch[3]!, timingMatch[4]!); + const endTime = parseTimestamp(timingMatch[5], timingMatch[6]!, timingMatch[7]!, timingMatch[8]!); + + i += 1; + const textLines: string[] = []; + while (i < lines.length && lines[i]!.trim() !== '') { + textLines.push(lines[i]!); + i += 1; + } + + const text = textLines.join('\n').trim(); + if (text) { + cues.push({ startTime, endTime, text }); + } + } + + return cues; +} + +const ASS_OVERRIDE_TAG_PATTERN = /\{[^}]*\}/g; + +const ASS_TIMING_PATTERN = /^(\d+):(\d{2}):(\d{2})\.(\d{1,2})$/; +const ASS_FORMAT_PREFIX = 'Format:'; +const ASS_DIALOGUE_PREFIX = 'Dialogue:'; + +function parseAssTimestamp(raw: string): number | null { + const match = ASS_TIMING_PATTERN.exec(raw.trim()); + if (!match) { + return null; + } + const hours = Number(match[1]); + const minutes = Number(match[2]); + const seconds = Number(match[3]); + const centiseconds = Number(match[4]!.padEnd(2, '0')); + return hours * 3600 + minutes * 60 + seconds + centiseconds / 100; +} + +export function parseAssCues(content: string): SubtitleCue[] { + const cues: SubtitleCue[] = []; + const lines = content.split(/\r?\n/); + let inEventsSection = false; + let startFieldIndex = -1; + let endFieldIndex = -1; + let textFieldIndex = -1; + + for (const line of lines) { + const trimmed = line.trim(); + + if (trimmed.startsWith('[') && trimmed.endsWith(']')) { + inEventsSection = trimmed.toLowerCase() === '[events]'; + if (!inEventsSection) { + startFieldIndex = -1; + endFieldIndex = -1; + textFieldIndex = -1; + } + continue; + } + + if (!inEventsSection) { + continue; + } + + if (trimmed.startsWith(ASS_FORMAT_PREFIX)) { + const formatFields = trimmed + .slice(ASS_FORMAT_PREFIX.length) + .split(',') + .map((field) => field.trim().toLowerCase()); + startFieldIndex = formatFields.indexOf('start'); + endFieldIndex = formatFields.indexOf('end'); + textFieldIndex = formatFields.indexOf('text'); + continue; + } + + if (!trimmed.startsWith(ASS_DIALOGUE_PREFIX)) { + continue; + } + + if (startFieldIndex < 0 || endFieldIndex < 0 || textFieldIndex < 0) { + continue; + } + + const fields = trimmed.slice(ASS_DIALOGUE_PREFIX.length).split(','); + if ( + startFieldIndex >= fields.length || + endFieldIndex >= fields.length || + textFieldIndex >= fields.length + ) { + continue; + } + + const startTime = parseAssTimestamp(fields[startFieldIndex]!); + const endTime = parseAssTimestamp(fields[endFieldIndex]!); + if (startTime === null || endTime === null) { + continue; + } + + const rawText = fields + .slice(textFieldIndex) + .join(',') + .replace(ASS_OVERRIDE_TAG_PATTERN, '') + .trim(); + if (rawText) { + cues.push({ startTime, endTime, text: rawText }); + } + } + + return cues; +} + +function detectSubtitleFormat(source: string): 'srt' | 'vtt' | 'ass' | 'ssa' | null { + const [normalizedSource = source] = (() => { + try { + return /^[a-z]+:\/\//i.test(source) ? new URL(source).pathname : source; + } catch { + return source; + } + })().split(/[?#]/, 1)[0] ?? ''; + const ext = normalizedSource.split('.').pop()?.toLowerCase() ?? ''; + if (ext === 'srt') return 'srt'; + if (ext === 'vtt') return 'vtt'; + if (ext === 'ass' || ext === 'ssa') return 'ass'; + return null; +} + +export function parseSubtitleCues(content: string, filename: string): SubtitleCue[] { + const format = detectSubtitleFormat(filename); + let cues: SubtitleCue[]; + + switch (format) { + case 'srt': + case 'vtt': + cues = parseSrtCues(content); + break; + case 'ass': + case 'ssa': + cues = parseAssCues(content); + break; + default: + return []; + } + + cues.sort((a, b) => a.startTime - b.startTime); + return cues; +} diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts new file mode 100644 index 0000000..c27191f --- /dev/null +++ b/src/core/services/subtitle-prefetch.test.ts @@ -0,0 +1,234 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + computePriorityWindow, + createSubtitlePrefetchService, +} from './subtitle-prefetch'; +import type { SubtitleCue } from './subtitle-cue-parser'; +import type { SubtitleData } from '../../types'; + +function makeCues(count: number, startOffset = 0): SubtitleCue[] { + return Array.from({ length: count }, (_, i) => ({ + startTime: startOffset + i * 5, + endTime: startOffset + i * 5 + 4, + text: `line-${i}`, + })); +} + +test('computePriorityWindow returns next N cues from current position', () => { + const cues = makeCues(20); + const window = computePriorityWindow(cues, 12.0, 5); + + assert.equal(window.length, 5); + // Position 12.0 falls during cue 2, so the window starts at cue 3 (startTime >= 12.0). + assert.equal(window[0]!.text, 'line-3'); + assert.equal(window[4]!.text, 'line-7'); +}); + +test('computePriorityWindow clamps to remaining cues at end of file', () => { + const cues = makeCues(5); + const window = computePriorityWindow(cues, 18.0, 10); + + // Position 18.0 is during cue 3 (start=15). Only cue 4 is ahead. + assert.equal(window.length, 1); + assert.equal(window[0]!.text, 'line-4'); +}); + +test('computePriorityWindow returns empty when past all cues', () => { + const cues = makeCues(3); + const window = computePriorityWindow(cues, 999.0, 10); + assert.equal(window.length, 0); +}); + +test('computePriorityWindow at position 0 returns first N cues', () => { + const cues = makeCues(20); + const window = computePriorityWindow(cues, 0, 5); + + assert.equal(window.length, 5); + assert.equal(window[0]!.text, 'line-0'); +}); + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('prefetch service tokenizes priority window cues and caches them', async () => { + const cues = makeCues(20); + const cached: Map = new Map(); + let tokenizeCalls = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: (text, data) => { + cached.set(text, data); + }, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + // Allow all async tokenization to complete + for (let i = 0; i < 25; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + // Priority window (first 3) should be cached + assert.ok(cached.has('line-0')); + assert.ok(cached.has('line-1')); + assert.ok(cached.has('line-2')); +}); + +test('prefetch service stops when cache is full', async () => { + const cues = makeCues(20); + let tokenizeCalls = 0; + let cacheSize = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: () => { + cacheSize += 1; + }, + isCacheFull: () => cacheSize >= 5, + priorityWindowSize: 3, + }); + + service.start(0); + for (let i = 0; i < 30; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + // Should have stopped at 5 (cache full), not tokenized all 20 + assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`); +}); + +test('prefetch service can be stopped mid-flight', async () => { + const cues = makeCues(100); + let tokenizeCalls = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: () => {}, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + await flushMicrotasks(); + service.stop(); + const callsAtStop = tokenizeCalls; + + // Wait more to confirm no further calls + for (let i = 0; i < 10; i += 1) { + await flushMicrotasks(); + } + + assert.equal(tokenizeCalls, callsAtStop, 'No further tokenize calls after stop'); + assert.ok(tokenizeCalls < 100, 'Should not have tokenized all cues'); +}); + +test('prefetch service onSeek re-prioritizes from new position', async () => { + const cues = makeCues(20); + const cachedTexts: string[] = []; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + // Let a few cues process + for (let i = 0; i < 5; i += 1) { + await flushMicrotasks(); + } + + // Seek to near the end + service.onSeek(80.0); + for (let i = 0; i < 30; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + // After seek to 80.0, cues starting after 80.0 (line-17, line-18, line-19) should appear in cached + const hasPostSeekCue = cachedTexts.some((t) => t === 'line-17' || t === 'line-18' || t === 'line-19'); + assert.ok(hasPostSeekCue, 'Should have cached cues after seek position'); +}); + +test('prefetch service still warms the priority window when cache is full', async () => { + const cues = makeCues(20); + const cachedTexts: string[] = []; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + isCacheFull: () => true, + priorityWindowSize: 3, + }); + + service.start(0); + for (let i = 0; i < 10; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + assert.deepEqual(cachedTexts.slice(0, 3), ['line-0', 'line-1', 'line-2']); +}); + +test('prefetch service pause/resume halts and continues tokenization', async () => { + const cues = makeCues(20); + let tokenizeCalls = 0; + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + preCacheTokenization: () => {}, + isCacheFull: () => false, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + await flushMicrotasks(); + service.pause(); + + const callsWhenPaused = tokenizeCalls; + // Wait while paused + for (let i = 0; i < 5; i += 1) { + await flushMicrotasks(); + } + // Should not have advanced much (may have 1 in-flight) + assert.ok(tokenizeCalls <= callsWhenPaused + 1, 'Should not tokenize much while paused'); + + service.resume(); + for (let i = 0; i < 30; i += 1) { + await flushMicrotasks(); + } + service.stop(); + + assert.ok(tokenizeCalls > callsWhenPaused + 1, 'Should resume tokenizing after unpause'); +}); diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts new file mode 100644 index 0000000..4258e94 --- /dev/null +++ b/src/core/services/subtitle-prefetch.ts @@ -0,0 +1,153 @@ +import type { SubtitleCue } from './subtitle-cue-parser'; +import type { SubtitleData } from '../../types'; + +export interface SubtitlePrefetchServiceDeps { + cues: SubtitleCue[]; + tokenizeSubtitle: (text: string) => Promise; + preCacheTokenization: (text: string, data: SubtitleData) => void; + isCacheFull: () => boolean; + priorityWindowSize?: number; +} + +export interface SubtitlePrefetchService { + start: (currentTimeSeconds: number) => void; + stop: () => void; + onSeek: (newTimeSeconds: number) => void; + pause: () => void; + resume: () => void; +} + +const DEFAULT_PRIORITY_WINDOW_SIZE = 10; + +export function computePriorityWindow( + cues: SubtitleCue[], + currentTimeSeconds: number, + windowSize: number, +): SubtitleCue[] { + if (cues.length === 0) { + return []; + } + + // Find the first cue whose start time is >= current position. + // This includes cues that start exactly at the current time (they haven't + // been displayed yet and should be prefetched). + let startIndex = -1; + for (let i = 0; i < cues.length; i += 1) { + if (cues[i]!.startTime >= currentTimeSeconds) { + startIndex = i; + break; + } + } + + if (startIndex < 0) { + // All cues are before current time + return []; + } + + return cues.slice(startIndex, startIndex + windowSize); +} + +export function createSubtitlePrefetchService( + deps: SubtitlePrefetchServiceDeps, +): SubtitlePrefetchService { + const windowSize = deps.priorityWindowSize ?? DEFAULT_PRIORITY_WINDOW_SIZE; + let stopped = true; + let paused = false; + let currentRunId = 0; + + async function tokenizeCueList( + cuesToProcess: SubtitleCue[], + runId: number, + options: { allowWhenCacheFull?: boolean } = {}, + ): Promise { + for (const cue of cuesToProcess) { + if (stopped || runId !== currentRunId) { + return; + } + + // Wait while paused + while (paused && !stopped && runId === currentRunId) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + + if (stopped || runId !== currentRunId) { + return; + } + + if (!options.allowWhenCacheFull && deps.isCacheFull()) { + return; + } + + try { + const result = await deps.tokenizeSubtitle(cue.text); + if (result && !stopped && runId === currentRunId) { + deps.preCacheTokenization(cue.text, result); + } + } catch { + // Skip failed cues, continue prefetching + } + + // Yield to allow live processing to take priority + await new Promise((resolve) => setTimeout(resolve, 0)); + } + } + + async function startPrefetching(currentTimeSeconds: number, runId: number): Promise { + const cues = deps.cues; + + // Phase 1: Priority window + const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize); + await tokenizeCueList(priorityCues, runId, { allowWhenCacheFull: true }); + + if (stopped || runId !== currentRunId) { + return; + } + + // Phase 2: Background - remaining cues forward from current position + const priorityTexts = new Set(priorityCues.map((c) => c.text)); + const remainingCues = cues.filter( + (cue) => cue.startTime > currentTimeSeconds && !priorityTexts.has(cue.text), + ); + await tokenizeCueList(remainingCues, runId); + + if (stopped || runId !== currentRunId) { + return; + } + + // Phase 3: Background - earlier cues (for rewind support) + const earlierCues = cues.filter( + (cue) => cue.startTime <= currentTimeSeconds && !priorityTexts.has(cue.text), + ); + await tokenizeCueList(earlierCues, runId); + } + + return { + start(currentTimeSeconds: number) { + stopped = false; + paused = false; + currentRunId += 1; + const runId = currentRunId; + void startPrefetching(currentTimeSeconds, runId); + }, + + stop() { + stopped = true; + currentRunId += 1; + }, + + onSeek(newTimeSeconds: number) { + // Cancel current run and restart from new position + currentRunId += 1; + const runId = currentRunId; + void startPrefetching(newTimeSeconds, runId); + }, + + pause() { + paused = true; + }, + + resume() { + paused = false; + }, + }; +} diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index 860eb5e..cc6095b 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -170,3 +170,45 @@ test('subtitle processing cache invalidation only affects future subtitle events assert.equal(callsByText.get('same'), 2); }); + +test('preCacheTokenization stores entry that is returned on next subtitle change', async () => { + const emitted: SubtitleData[] = []; + let tokenizeCalls = 0; + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => { + tokenizeCalls += 1; + return { text, tokens: [] }; + }, + emitSubtitle: (payload) => emitted.push(payload), + }); + + controller.preCacheTokenization('予め', { text: '予め', tokens: [] }); + controller.onSubtitleChange('予め'); + await flushMicrotasks(); + + assert.equal(tokenizeCalls, 0, 'should not call tokenize when pre-cached'); + assert.deepEqual(emitted, [{ text: '予め', tokens: [] }]); +}); + +test('isCacheFull returns false when cache is below limit', () => { + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: null }), + emitSubtitle: () => {}, + }); + + assert.equal(controller.isCacheFull(), false); +}); + +test('isCacheFull returns true when cache reaches limit', async () => { + const controller = createSubtitleProcessingController({ + tokenizeSubtitle: async (text) => ({ text, tokens: [] }), + emitSubtitle: () => {}, + }); + + // Fill cache to the 256 limit + for (let i = 0; i < 256; i += 1) { + controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] }); + } + + assert.equal(controller.isCacheFull(), true); +}); diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index 20ea805..aebe820 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -11,6 +11,8 @@ export interface SubtitleProcessingController { onSubtitleChange: (text: string) => void; refreshCurrentSubtitle: (textOverride?: string) => void; invalidateTokenizationCache: () => void; + preCacheTokenization: (text: string, data: SubtitleData) => void; + isCacheFull: () => boolean; } export function createSubtitleProcessingController( @@ -130,5 +132,11 @@ export function createSubtitleProcessingController( invalidateTokenizationCache: () => { tokenizationCache.clear(); }, + preCacheTokenization: (text: string, data: SubtitleData) => { + setCachedTokenization(text, data); + }, + isCacheFull: () => { + return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT; + }, }; } diff --git a/src/core/services/tokenizer.test.ts b/src/core/services/tokenizer.test.ts index d0d295e..1aa86bf 100644 --- a/src/core/services/tokenizer.test.ts +++ b/src/core/services/tokenizer.test.ts @@ -2707,6 +2707,63 @@ test('tokenizeSubtitle excludes merged function/content token from frequency hig assert.equal(result.tokens?.[0]?.isNPlusOneTarget, true); }); +test('tokenizeSubtitle keeps frequency for content-led merged token with trailing colloquial suffixes', async () => { + const result = await tokenizeSubtitle( + '張り切ってんじゃ', + makeDepsFromYomitanTokens([{ surface: '張り切ってん', reading: 'はき', headword: '張り切る' }], { + getFrequencyDictionaryEnabled: () => true, + getFrequencyRank: (text) => (text === '張り切る' ? 5468 : null), + tokenizeWithMecab: async () => [ + { + headword: '張り切る', + surface: '張り切っ', + reading: 'ハリキッ', + startPos: 0, + endPos: 4, + partOfSpeech: PartOfSpeech.verb, + pos1: '動詞', + pos2: '自立', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'て', + surface: 'て', + reading: 'テ', + startPos: 4, + endPos: 5, + partOfSpeech: PartOfSpeech.particle, + pos1: '助詞', + pos2: '接続助詞', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + { + headword: 'んじゃ', + surface: 'んじゃ', + reading: 'ンジャ', + startPos: 5, + endPos: 8, + partOfSpeech: PartOfSpeech.other, + pos1: '接続詞', + pos2: '*', + isMerged: false, + isKnown: false, + isNPlusOneTarget: false, + }, + ], + getMinSentenceWordsForNPlusOne: () => 1, + }), + ); + + assert.equal(result.tokens?.length, 1); + assert.equal(result.tokens?.[0]?.surface, '張り切ってん'); + assert.equal(result.tokens?.[0]?.pos1, '動詞|助詞|接続詞'); + assert.equal(result.tokens?.[0]?.frequencyRank, 5468); +}); + test('tokenizeSubtitle excludes default non-independent pos2 from N+1 when JLPT/frequency are disabled', async () => { let mecabCalls = 0; const result = await tokenizeSubtitle( diff --git a/src/core/services/tokenizer/annotation-stage.ts b/src/core/services/tokenizer/annotation-stage.ts index e957696..3e61386 100644 --- a/src/core/services/tokenizer/annotation-stage.ts +++ b/src/core/services/tokenizer/annotation-stage.ts @@ -43,33 +43,24 @@ function resolveKnownWordText( return matchMode === 'surface' ? surface : headword; } -function applyKnownWordMarking( - tokens: MergedToken[], - isKnownWord: (text: string) => boolean, - knownWordMatchMode: NPlusOneMatchMode, -): MergedToken[] { - return tokens.map((token) => { - const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode); - - return { - ...token, - isKnown: token.isKnown || (matchText ? isKnownWord(matchText) : false), - }; - }); -} function normalizePos1Tag(pos1: string | undefined): string { return typeof pos1 === 'string' ? pos1.trim() : ''; } -function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet): boolean { +function splitNormalizedTagParts(normalizedTag: string): string[] { if (!normalizedTag) { - return false; + return []; } - const parts = normalizedTag + + return normalizedTag .split('|') .map((part) => part.trim()) .filter((part) => part.length > 0); +} + +function isExcludedByTagSet(normalizedTag: string, exclusions: ReadonlySet): boolean { + const parts = splitNormalizedTagParts(normalizedTag); if (parts.length === 0) { return false; } @@ -98,6 +89,44 @@ function normalizePos2Tag(pos2: string | undefined): string { return typeof pos2 === 'string' ? pos2.trim() : ''; } +function isExcludedComponent( + pos1: string | undefined, + pos2: string | undefined, + pos1Exclusions: ReadonlySet, + pos2Exclusions: ReadonlySet, +): boolean { + return ( + (typeof pos1 === 'string' && pos1Exclusions.has(pos1)) || + (typeof pos2 === 'string' && pos2Exclusions.has(pos2)) + ); +} + +function shouldAllowContentLedMergedTokenFrequency( + normalizedPos1: string, + normalizedPos2: string, + pos1Exclusions: ReadonlySet, + pos2Exclusions: ReadonlySet, +): boolean { + const pos1Parts = splitNormalizedTagParts(normalizedPos1); + if (pos1Parts.length < 2) { + return false; + } + + const pos2Parts = splitNormalizedTagParts(normalizedPos2); + if (isExcludedComponent(pos1Parts[0], pos2Parts[0], pos1Exclusions, pos2Exclusions)) { + return false; + } + + const componentCount = Math.max(pos1Parts.length, pos2Parts.length); + for (let index = 1; index < componentCount; index += 1) { + if (!isExcludedComponent(pos1Parts[index], pos2Parts[index], pos1Exclusions, pos2Exclusions)) { + return false; + } + } + + return true; +} + function isFrequencyExcludedByPos( token: MergedToken, pos1Exclusions: ReadonlySet, @@ -109,13 +138,20 @@ function isFrequencyExcludedByPos( const normalizedPos1 = normalizePos1Tag(token.pos1); const hasPos1 = normalizedPos1.length > 0; - if (isExcludedByTagSet(normalizedPos1, pos1Exclusions)) { + const normalizedPos2 = normalizePos2Tag(token.pos2); + const hasPos2 = normalizedPos2.length > 0; + const allowContentLedMergedToken = shouldAllowContentLedMergedTokenFrequency( + normalizedPos1, + normalizedPos2, + pos1Exclusions, + pos2Exclusions, + ); + + if (isExcludedByTagSet(normalizedPos1, pos1Exclusions) && !allowContentLedMergedToken) { return true; } - const normalizedPos2 = normalizePos2Tag(token.pos2); - const hasPos2 = normalizedPos2.length > 0; - if (isExcludedByTagSet(normalizedPos2, pos2Exclusions)) { + if (isExcludedByTagSet(normalizedPos2, pos2Exclusions) && !allowContentLedMergedToken) { return true; } @@ -144,27 +180,6 @@ export function shouldExcludeTokenFromVocabularyPersistence( ); } -function applyFrequencyMarking( - tokens: MergedToken[], - pos1Exclusions: ReadonlySet, - pos2Exclusions: ReadonlySet, -): MergedToken[] { - return tokens.map((token) => { - if (isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions)) { - return { ...token, frequencyRank: undefined }; - } - - if (typeof token.frequencyRank === 'number' && Number.isFinite(token.frequencyRank)) { - const rank = Math.max(1, Math.floor(token.frequencyRank)); - return { ...token, frequencyRank: rank }; - } - - return { - ...token, - frequencyRank: undefined, - }; - }); -} function getCachedJlptLevel( lookupText: string, @@ -425,24 +440,45 @@ function isJlptEligibleToken(token: MergedToken): boolean { return true; } -function applyJlptMarking( - tokens: MergedToken[], +function computeTokenKnownStatus( + token: MergedToken, + isKnownWord: (text: string) => boolean, + knownWordMatchMode: NPlusOneMatchMode, +): boolean { + const matchText = resolveKnownWordText(token.surface, token.headword, knownWordMatchMode); + return token.isKnown || (matchText ? isKnownWord(matchText) : false); +} + +function filterTokenFrequencyRank( + token: MergedToken, + pos1Exclusions: ReadonlySet, + pos2Exclusions: ReadonlySet, +): number | undefined { + if (isFrequencyExcludedByPos(token, pos1Exclusions, pos2Exclusions)) { + return undefined; + } + + if (typeof token.frequencyRank === 'number' && Number.isFinite(token.frequencyRank)) { + return Math.max(1, Math.floor(token.frequencyRank)); + } + + return undefined; +} + +function computeTokenJlptLevel( + token: MergedToken, getJlptLevel: (text: string) => JlptLevel | null, -): MergedToken[] { - return tokens.map((token) => { - if (!isJlptEligibleToken(token)) { - return { ...token, jlptLevel: undefined }; - } +): JlptLevel | undefined { + if (!isJlptEligibleToken(token)) { + return undefined; + } - const primaryLevel = getCachedJlptLevel(resolveJlptLookupText(token), getJlptLevel); - const fallbackLevel = - primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null; + const primaryLevel = getCachedJlptLevel(resolveJlptLookupText(token), getJlptLevel); + const fallbackLevel = + primaryLevel === null ? getCachedJlptLevel(token.surface, getJlptLevel) : null; - return { - ...token, - jlptLevel: primaryLevel ?? fallbackLevel ?? token.jlptLevel, - }; - }); + const level = primaryLevel ?? fallbackLevel ?? token.jlptLevel; + return level ?? undefined; } export function annotateTokens( @@ -453,36 +489,34 @@ export function annotateTokens( const pos1Exclusions = resolvePos1Exclusions(options); const pos2Exclusions = resolvePos2Exclusions(options); const nPlusOneEnabled = options.nPlusOneEnabled !== false; - const knownMarkedTokens = nPlusOneEnabled - ? applyKnownWordMarking(tokens, deps.isKnownWord, deps.knownWordMatchMode) - : tokens.map((token) => ({ - ...token, - isKnown: false, - isNPlusOneTarget: false, - })); - const frequencyEnabled = options.frequencyEnabled !== false; - const frequencyMarkedTokens = frequencyEnabled - ? applyFrequencyMarking(knownMarkedTokens, pos1Exclusions, pos2Exclusions) - : knownMarkedTokens.map((token) => ({ - ...token, - frequencyRank: undefined, - })); - const jlptEnabled = options.jlptEnabled !== false; - const jlptMarkedTokens = jlptEnabled - ? applyJlptMarking(frequencyMarkedTokens, deps.getJlptLevel) - : frequencyMarkedTokens.map((token) => ({ - ...token, - jlptLevel: undefined, - })); + + // Single pass: compute known word status, frequency filtering, and JLPT level together + const annotated = tokens.map((token) => { + const isKnown = nPlusOneEnabled + ? computeTokenKnownStatus(token, deps.isKnownWord, deps.knownWordMatchMode) + : false; + + const frequencyRank = frequencyEnabled + ? filterTokenFrequencyRank(token, pos1Exclusions, pos2Exclusions) + : undefined; + + const jlptLevel = jlptEnabled + ? computeTokenJlptLevel(token, deps.getJlptLevel) + : undefined; + + return { + ...token, + isKnown, + isNPlusOneTarget: nPlusOneEnabled ? token.isNPlusOneTarget : false, + frequencyRank, + jlptLevel, + }; + }); if (!nPlusOneEnabled) { - return jlptMarkedTokens.map((token) => ({ - ...token, - isKnown: false, - isNPlusOneTarget: false, - })); + return annotated; } const minSentenceWordsForNPlusOne = options.minSentenceWordsForNPlusOne; @@ -494,7 +528,7 @@ export function annotateTokens( : 3; return markNPlusOneTargets( - jlptMarkedTokens, + annotated, sanitizedMinSentenceWordsForNPlusOne, pos1Exclusions, pos2Exclusions, diff --git a/src/main.ts b/src/main.ts index 95c1a9e..86869e4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -419,6 +419,14 @@ import { generateConfigTemplate, } from './config'; import { resolveConfigDir } from './config/path-resolution'; +import { parseSubtitleCues } from './core/services/subtitle-cue-parser'; +import { createSubtitlePrefetchService } from './core/services/subtitle-prefetch'; +import type { SubtitlePrefetchService } from './core/services/subtitle-prefetch'; +import { + getActiveExternalSubtitleSource, + resolveSubtitleSourcePath, +} from './main/runtime/subtitle-prefetch-source'; +import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -1068,6 +1076,7 @@ const buildSubtitleProcessingControllerMainDepsHandler = topX: getResolvedConfig().subtitleStyle.frequencyDictionary.topX, mode: getResolvedConfig().subtitleStyle.frequencyDictionary.mode, }); + subtitlePrefetchService?.resume(); }, logDebug: (message) => { logger.debug(`[subtitle-processing] ${message}`); @@ -1078,6 +1087,67 @@ const subtitleProcessingControllerMainDeps = buildSubtitleProcessingControllerMa const subtitleProcessingController = createSubtitleProcessingController( subtitleProcessingControllerMainDeps, ); + +let subtitlePrefetchService: SubtitlePrefetchService | null = null; +let subtitlePrefetchRefreshTimer: ReturnType | null = null; +let lastObservedTimePos = 0; +const SEEK_THRESHOLD_SECONDS = 3; + +function clearScheduledSubtitlePrefetchRefresh(): void { + if (subtitlePrefetchRefreshTimer) { + clearTimeout(subtitlePrefetchRefreshTimer); + subtitlePrefetchRefreshTimer = null; + } +} + +const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ + getCurrentService: () => subtitlePrefetchService, + setCurrentService: (service) => { + subtitlePrefetchService = service; + }, + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), + createSubtitlePrefetchService: (deps) => createSubtitlePrefetchService(deps), + tokenizeSubtitle: async (text) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => { + subtitleProcessingController.preCacheTokenization(text, data); + }, + isCacheFull: () => subtitleProcessingController.isCacheFull(), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), +}); + +async function refreshSubtitlePrefetchFromActiveTrack(): Promise { + const client = appState.mpvClient; + if (!client?.connected) { + return; + } + + try { + const [trackListRaw, sidRaw] = await Promise.all([ + client.requestProperty('track-list'), + client.requestProperty('sid'), + ]); + const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw); + if (!externalFilename) { + subtitlePrefetchInitController.cancelPendingInit(); + return; + } + await subtitlePrefetchInitController.initSubtitlePrefetch(externalFilename, lastObservedTimePos); + } catch { + // Track list query failed; skip subtitle prefetch refresh. + } +} + +function scheduleSubtitlePrefetchRefresh(delayMs = 0): void { + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchRefreshTimer = setTimeout(() => { + subtitlePrefetchRefreshTimer = null; + void refreshSubtitlePrefetchFromActiveTrack(); + }, delayMs); +} + const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( createBuildOverlayShortcutsRuntimeMainDepsHandler({ getConfiguredShortcuts: () => getConfiguredShortcuts(), @@ -1438,6 +1508,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt clearYomitanParserCachesForWindow(appState.yomitanParserWindow); } subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); subtitleProcessingController.refreshCurrentSubtitle(appState.currentSubText); logger.info( `[dictionary:auto-sync] refreshed current subtitle after sync (AniList ${mediaId}, changed=${changed ? 'yes' : 'no'}, title=${mediaTitle})`, @@ -2616,6 +2687,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ getSubtitleStyleConfig: () => configService.getConfig().subtitleStyle, onOptionsChanged: () => { subtitleProcessingController.invalidateTokenizationCache(); + subtitlePrefetchService?.onSeek(lastObservedTimePos); broadcastRuntimeOptionsChanged(); refreshOverlayShortcuts(); }, @@ -2857,6 +2929,7 @@ const { broadcastToOverlayWindows(channel, payload); }, onSubtitleChange: (text) => { + subtitlePrefetchService?.pause(); subtitleProcessingController.onSubtitleChange(text); }, refreshDiscordPresence: () => { @@ -2871,8 +2944,12 @@ const { autoPlayReadySignalMediaPath = null; currentMediaTokenizationGate.updateCurrentMediaPath(path); startupOsdSequencer.reset(); + clearScheduledSubtitlePrefetchRefresh(); + subtitlePrefetchInitController.cancelPendingInit(); if (path) { ensureImmersionTrackerStarted(); + // Delay slightly to allow MPV's track-list to be populated. + scheduleSubtitlePrefetchRefresh(500); } mediaRuntime.updateCurrentMediaPath(path); }, @@ -2916,6 +2993,19 @@ const { reportJellyfinRemoteProgress: (forceImmediate) => { void reportJellyfinRemoteProgress(forceImmediate); }, + onTimePosUpdate: (time) => { + const delta = time - lastObservedTimePos; + if (subtitlePrefetchService && (delta > SEEK_THRESHOLD_SECONDS || delta < 0)) { + subtitlePrefetchService.onSeek(time); + } + lastObservedTimePos = time; + }, + onSubtitleTrackChange: () => { + scheduleSubtitlePrefetchRefresh(); + }, + onSubtitleTrackListChange: () => { + scheduleSubtitlePrefetchRefresh(); + }, updateSubtitleRenderMetrics: (patch) => { updateMpvSubtitleRenderMetrics(patch as Partial); }, @@ -3509,26 +3599,28 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand appendClipboardVideoToQueueMainDeps, ); +async function loadSubtitleSourceText(source: string): Promise { + if (/^https?:\/\//i.test(source)) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 4000); + try { + const response = await fetch(source, { signal: controller.signal }); + if (!response.ok) { + throw new Error(`Failed to download subtitle source (${response.status})`); + } + return await response.text(); + } finally { + clearTimeout(timeoutId); + } + } + + const filePath = resolveSubtitleSourcePath(source); + return fs.promises.readFile(filePath, 'utf8'); +} + const shiftSubtitleDelayToAdjacentCueHandler = createShiftSubtitleDelayToAdjacentCueHandler({ getMpvClient: () => appState.mpvClient, - loadSubtitleSourceText: async (source) => { - if (/^https?:\/\//i.test(source)) { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 4000); - try { - const response = await fetch(source, { signal: controller.signal }); - if (!response.ok) { - throw new Error(`Failed to download subtitle source (${response.status})`); - } - return await response.text(); - } finally { - clearTimeout(timeoutId); - } - } - - const filePath = source.startsWith('file://') ? decodeURI(new URL(source).pathname) : source; - return fs.promises.readFile(filePath, 'utf8'); - }, + loadSubtitleSourceText, sendMpvCommand: (command) => sendMpvCommandRuntime(appState.mpvClient, command), showMpvOsd: (text) => showMpvOsd(text), }); diff --git a/src/main/runtime/mpv-client-event-bindings.test.ts b/src/main/runtime/mpv-client-event-bindings.test.ts index fa56d57..2126193 100644 --- a/src/main/runtime/mpv-client-event-bindings.test.ts +++ b/src/main/runtime/mpv-client-event-bindings.test.ts @@ -71,6 +71,8 @@ test('mpv event bindings register all expected events', () => { onSubtitleChange: () => {}, onSubtitleAssChange: () => {}, onSecondarySubtitleChange: () => {}, + onSubtitleTrackChange: () => {}, + onSubtitleTrackListChange: () => {}, onSubtitleTiming: () => {}, onMediaPathChange: () => {}, onMediaTitleChange: () => {}, @@ -92,6 +94,8 @@ test('mpv event bindings register all expected events', () => { 'subtitle-change', 'subtitle-ass-change', 'secondary-subtitle-change', + 'subtitle-track-change', + 'subtitle-track-list-change', 'subtitle-timing', 'media-path-change', 'media-title-change', diff --git a/src/main/runtime/mpv-client-event-bindings.ts b/src/main/runtime/mpv-client-event-bindings.ts index 9a6b747..c1b759e 100644 --- a/src/main/runtime/mpv-client-event-bindings.ts +++ b/src/main/runtime/mpv-client-event-bindings.ts @@ -3,6 +3,8 @@ type MpvBindingEventName = | 'subtitle-change' | 'subtitle-ass-change' | 'secondary-subtitle-change' + | 'subtitle-track-change' + | 'subtitle-track-list-change' | 'subtitle-timing' | 'media-path-change' | 'media-title-change' @@ -69,6 +71,8 @@ export function createBindMpvClientEventHandlers(deps: { onSubtitleChange: (payload: { text: string }) => void; onSubtitleAssChange: (payload: { text: string }) => void; onSecondarySubtitleChange: (payload: { text: string }) => void; + onSubtitleTrackChange: (payload: { sid: number | null }) => void; + onSubtitleTrackListChange: (payload: { trackList: unknown[] | null }) => void; onSubtitleTiming: (payload: { text: string; start: number; end: number }) => void; onMediaPathChange: (payload: { path: string | null }) => void; onMediaTitleChange: (payload: { title: string | null }) => void; @@ -83,6 +87,8 @@ export function createBindMpvClientEventHandlers(deps: { mpvClient.on('subtitle-change', deps.onSubtitleChange); mpvClient.on('subtitle-ass-change', deps.onSubtitleAssChange); mpvClient.on('secondary-subtitle-change', deps.onSecondarySubtitleChange); + mpvClient.on('subtitle-track-change', deps.onSubtitleTrackChange); + mpvClient.on('subtitle-track-list-change', deps.onSubtitleTrackListChange); mpvClient.on('subtitle-timing', deps.onSubtitleTiming); mpvClient.on('media-path-change', deps.onMediaPathChange); mpvClient.on('media-title-change', deps.onMediaTitleChange); diff --git a/src/main/runtime/mpv-main-event-actions.ts b/src/main/runtime/mpv-main-event-actions.ts index 14cf793..7e09051 100644 --- a/src/main/runtime/mpv-main-event-actions.ts +++ b/src/main/runtime/mpv-main-event-actions.ts @@ -90,11 +90,13 @@ export function createHandleMpvTimePosChangeHandler(deps: { recordPlaybackPosition: (time: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; refreshDiscordPresence: () => void; + onTimePosUpdate?: (time: number) => void; }) { return ({ time }: { time: number }): void => { deps.recordPlaybackPosition(time); deps.reportJellyfinRemoteProgress(false); deps.refreshDiscordPresence(); + deps.onTimePosUpdate?.(time); }; } diff --git a/src/main/runtime/mpv-main-event-bindings.test.ts b/src/main/runtime/mpv-main-event-bindings.test.ts index 256b4ee..44c3552 100644 --- a/src/main/runtime/mpv-main-event-bindings.test.ts +++ b/src/main/runtime/mpv-main-event-bindings.test.ts @@ -34,6 +34,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { setCurrentSubAssText: (text) => calls.push(`set-ass:${text}`), broadcastSubtitleAss: (text) => calls.push(`broadcast-ass:${text}`), broadcastSecondarySubtitle: (text) => calls.push(`broadcast-secondary:${text}`), + onSubtitleTrackChange: () => calls.push('subtitle-track-change'), + onSubtitleTrackListChange: () => calls.push('subtitle-track-list-change'), updateCurrentMediaPath: (path) => calls.push(`media-path:${path}`), restoreMpvSubVisibility: () => calls.push('restore-mpv-sub'), @@ -65,6 +67,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { }); handlers.get('subtitle-change')?.({ text: 'line' }); + handlers.get('subtitle-track-change')?.({ sid: 3 }); + handlers.get('subtitle-track-list-change')?.({ trackList: [] }); handlers.get('media-path-change')?.({ path: '' }); handlers.get('media-title-change')?.({ title: 'Episode 1' }); handlers.get('time-pos-change')?.({ time: 2.5 }); @@ -73,6 +77,8 @@ test('main mpv event binder wires callbacks through to runtime deps', () => { assert.ok(calls.includes('set-sub:line')); assert.ok(calls.includes('broadcast-sub:line')); assert.ok(calls.includes('subtitle-change:line')); + assert.ok(calls.includes('subtitle-track-change')); + assert.ok(calls.includes('subtitle-track-list-change')); assert.ok(calls.includes('media-title:Episode 1')); assert.ok(calls.includes('restore-mpv-sub')); assert.ok(calls.includes('reset-guess-state')); diff --git a/src/main/runtime/mpv-main-event-bindings.ts b/src/main/runtime/mpv-main-event-bindings.ts index 63d7220..4783308 100644 --- a/src/main/runtime/mpv-main-event-bindings.ts +++ b/src/main/runtime/mpv-main-event-bindings.ts @@ -42,6 +42,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { setCurrentSubAssText: (text: string) => void; broadcastSubtitleAss: (text: string) => void; broadcastSecondarySubtitle: (text: string) => void; + onSubtitleTrackChange?: (sid: number | null) => void; + onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; updateCurrentMediaPath: (path: string) => void; restoreMpvSubVisibility: () => void; @@ -59,6 +61,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { recordPlaybackPosition: (time: number) => void; recordMediaDuration: (durationSec: number) => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + onTimePosUpdate?: (time: number) => void; recordPauseState: (paused: boolean) => void; updateSubtitleRenderMetrics: (patch: Record) => void; @@ -124,6 +127,7 @@ export function createBindMpvMainEventHandlersHandler(deps: { reportJellyfinRemoteProgress: (forceImmediate) => deps.reportJellyfinRemoteProgress(forceImmediate), refreshDiscordPresence: () => deps.refreshDiscordPresence(), + onTimePosUpdate: (time) => deps.onTimePosUpdate?.(time), }); const handleMpvPauseChange = createHandleMpvPauseChangeHandler({ recordPauseState: (paused) => deps.recordPauseState(paused), @@ -144,6 +148,8 @@ export function createBindMpvMainEventHandlersHandler(deps: { onSubtitleChange: handleMpvSubtitleChange, onSubtitleAssChange: handleMpvSubtitleAssChange, onSecondarySubtitleChange: handleMpvSecondarySubtitleChange, + onSubtitleTrackChange: ({ sid }) => deps.onSubtitleTrackChange?.(sid), + onSubtitleTrackListChange: ({ trackList }) => deps.onSubtitleTrackListChange?.(trackList), onSubtitleTiming: handleMpvSubtitleTiming, onMediaPathChange: handleMpvMediaPathChange, onMediaTitleChange: handleMpvMediaTitleChange, diff --git a/src/main/runtime/mpv-main-event-main-deps.ts b/src/main/runtime/mpv-main-event-main-deps.ts index 914b8be..669f27d 100644 --- a/src/main/runtime/mpv-main-event-main-deps.ts +++ b/src/main/runtime/mpv-main-event-main-deps.ts @@ -35,6 +35,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { logSubtitleTimingError: (message: string, error: unknown) => void; broadcastToOverlayWindows: (channel: string, payload: unknown) => void; onSubtitleChange: (text: string) => void; + onSubtitleTrackChange?: (sid: number | null) => void; + onSubtitleTrackListChange?: (trackList: unknown[] | null) => void; updateCurrentMediaPath: (path: string) => void; restoreMpvSubVisibility: () => void; getCurrentAnilistMediaKey: () => string | null; @@ -47,6 +49,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { updateCurrentMediaTitle: (title: string) => void; resetAnilistMediaGuessState: () => void; reportJellyfinRemoteProgress: (forceImmediate: boolean) => void; + onTimePosUpdate?: (time: number) => void; updateSubtitleRenderMetrics: (patch: Record) => void; refreshDiscordPresence: () => void; ensureImmersionTrackerInitialized: () => void; @@ -100,6 +103,12 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { broadcastSubtitle: (payload: { text: string; tokens: null }) => deps.broadcastToOverlayWindows('subtitle:set', payload), onSubtitleChange: (text: string) => deps.onSubtitleChange(text), + onSubtitleTrackChange: deps.onSubtitleTrackChange + ? (sid: number | null) => deps.onSubtitleTrackChange!(sid) + : undefined, + onSubtitleTrackListChange: deps.onSubtitleTrackListChange + ? (trackList: unknown[] | null) => deps.onSubtitleTrackListChange!(trackList) + : undefined, refreshDiscordPresence: () => deps.refreshDiscordPresence(), setCurrentSubAssText: (text: string) => { deps.appState.currentSubAssText = text; @@ -134,6 +143,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: { }, reportJellyfinRemoteProgress: (forceImmediate: boolean) => deps.reportJellyfinRemoteProgress(forceImmediate), + onTimePosUpdate: deps.onTimePosUpdate ? (time: number) => deps.onTimePosUpdate!(time) : undefined, recordPauseState: (paused: boolean) => { deps.appState.playbackPaused = paused; deps.ensureImmersionTrackerInitialized(); diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts new file mode 100644 index 0000000..e076d1c --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SubtitleCue } from '../../core/services/subtitle-cue-parser'; +import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch'; +import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init'; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('latest subtitle prefetch init wins over stale async loads', async () => { + const loads = new Map>>(); + const started: string[] = []; + const stopped: string[] = []; + let currentService: SubtitlePrefetchService | null = null; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async (source) => { + const deferred = createDeferred(); + loads.set(source, deferred); + return await deferred.promise; + }, + parseSubtitleCues: (_content, filename): SubtitleCue[] => [ + { startTime: 0, endTime: 1, text: filename }, + ], + createSubtitlePrefetchService: ({ cues }) => ({ + start: () => { + started.push(cues[0]!.text); + }, + stop: () => { + stopped.push(cues[0]!.text); + }, + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }), + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + isCacheFull: () => false, + logInfo: () => {}, + logWarn: () => {}, + }); + + const firstInit = controller.initSubtitlePrefetch('old.ass', 1); + const secondInit = controller.initSubtitlePrefetch('new.ass', 2); + + loads.get('new.ass')!.resolve('new'); + await flushMicrotasks(); + + assert.deepEqual(started, ['new.ass']); + + loads.get('old.ass')!.resolve('old'); + await Promise.all([firstInit, secondInit]); + + assert.deepEqual(started, ['new.ass']); + assert.deepEqual(stopped, []); +}); + +test('cancelPendingInit prevents an in-flight load from attaching a stale service', async () => { + const deferred = createDeferred(); + let currentService: SubtitlePrefetchService | null = null; + const started: string[] = []; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async () => await deferred.promise, + parseSubtitleCues: (_content, filename): SubtitleCue[] => [ + { startTime: 0, endTime: 1, text: filename }, + ], + createSubtitlePrefetchService: ({ cues }) => ({ + start: () => { + started.push(cues[0]!.text); + }, + stop: () => {}, + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }), + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + isCacheFull: () => false, + logInfo: () => {}, + logWarn: () => {}, + }); + + const initPromise = controller.initSubtitlePrefetch('stale.ass', 1); + controller.cancelPendingInit(); + deferred.resolve('stale'); + await initPromise; + + assert.equal(currentService, null); + assert.deepEqual(started, []); +}); diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts new file mode 100644 index 0000000..5d11b30 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -0,0 +1,83 @@ +import type { SubtitleCue } from '../../core/services/subtitle-cue-parser'; +import type { + SubtitlePrefetchService, + SubtitlePrefetchServiceDeps, +} from '../../core/services/subtitle-prefetch'; +import type { SubtitleData } from '../../types'; + +export interface SubtitlePrefetchInitControllerDeps { + getCurrentService: () => SubtitlePrefetchService | null; + setCurrentService: (service: SubtitlePrefetchService | null) => void; + loadSubtitleSourceText: (source: string) => Promise; + parseSubtitleCues: (content: string, filename: string) => SubtitleCue[]; + createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; + tokenizeSubtitle: (text: string) => Promise; + preCacheTokenization: (text: string, data: SubtitleData) => void; + isCacheFull: () => boolean; + logInfo: (message: string) => void; + logWarn: (message: string) => void; +} + +export interface SubtitlePrefetchInitController { + cancelPendingInit: () => void; + initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise; +} + +export function createSubtitlePrefetchInitController( + deps: SubtitlePrefetchInitControllerDeps, +): SubtitlePrefetchInitController { + let initRevision = 0; + + const cancelPendingInit = (): void => { + initRevision += 1; + deps.getCurrentService()?.stop(); + deps.setCurrentService(null); + }; + + const initSubtitlePrefetch = async ( + externalFilename: string, + currentTimePos: number, + ): Promise => { + const revision = ++initRevision; + deps.getCurrentService()?.stop(); + deps.setCurrentService(null); + + try { + const content = await deps.loadSubtitleSourceText(externalFilename); + if (revision !== initRevision) { + return; + } + + const cues = deps.parseSubtitleCues(content, externalFilename); + if (revision !== initRevision || cues.length === 0) { + return; + } + + const nextService = deps.createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), + preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), + isCacheFull: () => deps.isCacheFull(), + }); + + if (revision !== initRevision) { + return; + } + + deps.setCurrentService(nextService); + nextService.start(currentTimePos); + deps.logInfo( + `[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`, + ); + } catch (error) { + if (revision === initRevision) { + deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`); + } + } + }; + + return { + cancelPendingInit, + initSubtitlePrefetch, + }; +} diff --git a/src/main/runtime/subtitle-prefetch-source.test.ts b/src/main/runtime/subtitle-prefetch-source.test.ts new file mode 100644 index 0000000..9704c47 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-source.test.ts @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + getActiveExternalSubtitleSource, + resolveSubtitleSourcePath, +} from './subtitle-prefetch-source'; + +test('getActiveExternalSubtitleSource returns the active external subtitle path', () => { + const source = getActiveExternalSubtitleSource( + [ + { type: 'sub', id: 1, external: false }, + { type: 'sub', id: 2, external: true, 'external-filename': ' https://host/subs.ass ' }, + ], + '2', + ); + + assert.equal(source, 'https://host/subs.ass'); +}); + +test('getActiveExternalSubtitleSource returns null when the selected track is not external', () => { + const source = getActiveExternalSubtitleSource( + [{ type: 'sub', id: 2, external: false, 'external-filename': '/tmp/subs.ass' }], + 2, + ); + + assert.equal(source, null); +}); + +test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem paths', () => { + const fileUrl = process.platform === 'win32' + ? 'file:///C:/Users/test/Sub%20Folder/subs.ass' + : 'file:///tmp/Sub%20Folder/subs.ass'; + + const resolved = resolveSubtitleSourcePath(fileUrl); + + assert.ok(resolved.endsWith('/Sub Folder/subs.ass') || resolved.endsWith('\\Sub Folder\\subs.ass')); +}); + +test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => { + assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass'); +}); + +test('resolveSubtitleSourcePath returns the original source for malformed file URLs', () => { + const source = 'file://invalid[path'; + + assert.equal(resolveSubtitleSourcePath(source), source); +}); diff --git a/src/main/runtime/subtitle-prefetch-source.ts b/src/main/runtime/subtitle-prefetch-source.ts new file mode 100644 index 0000000..b740ff6 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-source.ts @@ -0,0 +1,42 @@ +import { fileURLToPath } from 'node:url'; + +export function getActiveExternalSubtitleSource( + trackListRaw: unknown, + sidRaw: unknown, +): string | null { + if (!Array.isArray(trackListRaw) || sidRaw == null) { + return null; + } + + const sid = + typeof sidRaw === 'number' ? sidRaw : typeof sidRaw === 'string' ? Number(sidRaw) : null; + if (sid == null || !Number.isFinite(sid)) { + return null; + } + + const activeTrack = trackListRaw.find((entry: unknown) => { + if (!entry || typeof entry !== 'object') { + return false; + } + const track = entry as Record; + return track.type === 'sub' && track.id === sid && track.external === true; + }) as Record | undefined; + + const externalFilename = + typeof activeTrack?.['external-filename'] === 'string' + ? activeTrack['external-filename'].trim() + : ''; + return externalFilename || null; +} + +export function resolveSubtitleSourcePath(source: string): string { + if (!source.startsWith('file://')) { + return source; + } + + try { + return fileURLToPath(new URL(source)); + } catch { + return source; + } +} diff --git a/src/renderer/subtitle-render.test.ts b/src/renderer/subtitle-render.test.ts index 3eb7517..8c1ba7f 100644 --- a/src/renderer/subtitle-render.test.ts +++ b/src/renderer/subtitle-render.test.ts @@ -90,6 +90,15 @@ class FakeElement { this.ownTextContent = ''; } } + + replaceChildren(): void { + this.childNodes = []; + this.ownTextContent = ''; + } + + cloneNode(_deep: boolean): FakeElement { + return new FakeElement(this.tagName); + } } function installFakeDocument() { diff --git a/src/renderer/subtitle-render.ts b/src/renderer/subtitle-render.ts index 1fb3276..023cd05 100644 --- a/src/renderer/subtitle-render.ts +++ b/src/renderer/subtitle-render.ts @@ -19,6 +19,14 @@ export type SubtitleTokenHoverRange = { tokenIndex: number; }; +let _spanTemplate: HTMLSpanElement | null = null; +function getSpanTemplate(): HTMLSpanElement { + if (!_spanTemplate) { + _spanTemplate = document.createElement('span'); + } + return _spanTemplate; +} + export function shouldRenderTokenizedSubtitle(tokenCount: number): boolean { return tokenCount > 0; } @@ -286,7 +294,7 @@ function renderWithTokens( } const token = segment.token; - const span = document.createElement('span'); + const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement; span.className = computeWordClass(token, resolvedTokenRenderSettings); span.textContent = token.surface; span.dataset.tokenIndex = String(segment.tokenIndex); @@ -322,7 +330,7 @@ function renderWithTokens( continue; } - const span = document.createElement('span'); + const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement; span.className = computeWordClass(token, resolvedTokenRenderSettings); span.textContent = surface; span.dataset.tokenIndex = String(index); @@ -478,7 +486,7 @@ function renderCharacterLevel(root: HTMLElement, text: string): void { fragment.appendChild(document.createElement('br')); continue; } - const span = document.createElement('span'); + const span = getSpanTemplate().cloneNode(false) as HTMLSpanElement; span.className = 'c'; span.textContent = char; fragment.appendChild(span); @@ -503,7 +511,7 @@ function renderPlainTextPreserveLineBreaks(root: ParentNode, text: string): void export function createSubtitleRenderer(ctx: RendererContext) { function renderSubtitle(data: SubtitleData | string): void { - ctx.dom.subtitleRoot.innerHTML = ''; + ctx.dom.subtitleRoot.replaceChildren(); let text: string; let tokens: MergedToken[] | null; @@ -552,7 +560,7 @@ export function createSubtitleRenderer(ctx: RendererContext) { } function renderSecondarySub(text: string): void { - ctx.dom.secondarySubRoot.innerHTML = ''; + ctx.dom.secondarySubRoot.replaceChildren(); if (!text) return; const normalized = text