# 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.