Files
SubMiner/docs/architecture/2026-03-15-renderer-performance-design.md
sudacode 7fcd3e8e94 docs: add renderer performance optimization design spec
Covers three optimizations to minimize subtitle-to-annotation latency:
subtitle prefetching with prioritized sliding window, batched annotation
passes, and DOM template pooling via cloneNode.
2026-03-15 12:15:46 -07:00

11 KiB

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)

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

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:

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 commas to get timing and text fields.

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.
  6. Seek handling: On seek (detected via playback position jump), re-compute the priority window from the new position. 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:

  • Pauses when a live subtitle change arrives.
  • Resumes after the live subtitle has been processed and emitted.
  • Yields between each background cue tokenization (e.g., via setTimeout(0) or checking a pause flag) so live processing is never blocked.

Cache Integration

The prefetcher writes into the existing SubtitleProcessingController tokenization cache. This requires exposing a method to insert pre-computed results:

// New method on SubtitleProcessingController
preCacheTokenization: (text: string, data: SubtitleData) => void;

This uses the same setCachedTokenization logic internally (LRU eviction, Map-based storage).

Integration Points

  • MPV property subscriptions: Needs track-list (to detect external subtitle file path) and time-pos or sub-start/sub-end (to track playback position for window calculation).
  • File loading: Uses existing loadSubtitleSourceText dependency.
  • Tokenization: Calls the same tokenizeSubtitle function used by live processing.
  • Cache: Writes into SubtitleProcessingController's cache.

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.

Current Flow (4 passes, 4 array copies)

tokens
  -> applyKnownWordMarking()   // .map() -> new array
  -> applyFrequencyMarking()   // .map() -> new array
  -> 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 marking: Uses pos1Exclusions and pos2Exclusions to filter out particles and noise tokens. 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 and JLPT filtering 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)

function annotateTokens(tokens, deps, options): MergedToken[] {
  const pos1Exclusions = resolvePos1Exclusions(options);
  const pos2Exclusions = resolvePos2Exclusions(options);

  // Single pass: known word + frequency + JLPT computed together
  const annotated = tokens.map((token) => {
    const isKnown = nPlusOneEnabled
      ? token.isKnown || computeIsKnown(token, deps)
      : false;

    const frequencyRank = frequencyEnabled
      ? computeFrequencyRank(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 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).
  • 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:
    const templateSpan = document.createElement('span');
    
  2. In renderWithTokens, replace every document.createElement('span') with:
    const span = templateSpan.cloneNode(false) as HTMLSpanElement;
    
  3. 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)