mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 03:16:46 -07:00
Feature/renderer performance (#24)
This commit is contained in:
283
docs/architecture/2026-03-15-renderer-performance-design.md
Normal file
283
docs/architecture/2026-03-15-renderer-performance-design.md
Normal file
@@ -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)
|
||||
1588
docs/superpowers/plans/2026-03-15-renderer-performance.md
Normal file
1588
docs/superpowers/plans/2026-03-15-renderer-performance.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -484,6 +484,7 @@ interface YomitanRuntimeState {
|
||||
yomitanExt: unknown | null;
|
||||
yomitanSession: unknown | null;
|
||||
parserWindow: unknown | null;
|
||||
yomitanSession: unknown | null;
|
||||
parserReadyPromise: Promise<void> | null;
|
||||
parserInitPromise: Promise<boolean> | null;
|
||||
available: boolean;
|
||||
@@ -543,6 +544,7 @@ async function createYomitanRuntimeState(
|
||||
yomitanExt: null,
|
||||
yomitanSession: null,
|
||||
parserWindow: null,
|
||||
yomitanSession: null,
|
||||
parserReadyPromise: null,
|
||||
parserInitPromise: null,
|
||||
available: false,
|
||||
|
||||
@@ -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<MpvProtocolCommand> = [
|
||||
|
||||
@@ -60,6 +60,8 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<MpvSubtitleRenderMetrics> };
|
||||
@@ -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;
|
||||
|
||||
274
src/core/services/subtitle-cue-parser.test.ts
Normal file
274
src/core/services/subtitle-cue-parser.test.ts
Normal file
@@ -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テスト');
|
||||
});
|
||||
180
src/core/services/subtitle-cue-parser.ts
Normal file
180
src/core/services/subtitle-cue-parser.ts
Normal file
@@ -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;
|
||||
}
|
||||
234
src/core/services/subtitle-prefetch.test.ts
Normal file
234
src/core/services/subtitle-prefetch.test.ts
Normal file
@@ -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<void> {
|
||||
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<string, SubtitleData> = 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');
|
||||
});
|
||||
153
src/core/services/subtitle-prefetch.ts
Normal file
153
src/core/services/subtitle-prefetch.ts
Normal file
@@ -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<SubtitleData | null>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<string>): 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<string>): 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<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): boolean {
|
||||
return (
|
||||
(typeof pos1 === 'string' && pos1Exclusions.has(pos1)) ||
|
||||
(typeof pos2 === 'string' && pos2Exclusions.has(pos2))
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAllowContentLedMergedTokenFrequency(
|
||||
normalizedPos1: string,
|
||||
normalizedPos2: string,
|
||||
pos1Exclusions: ReadonlySet<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): 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<string>,
|
||||
@@ -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<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): 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<string>,
|
||||
pos2Exclusions: ReadonlySet<string>,
|
||||
): 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,
|
||||
|
||||
128
src/main.ts
128
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<typeof setTimeout> | 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<void> {
|
||||
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<MpvSubtitleRenderMetrics>);
|
||||
},
|
||||
@@ -3509,26 +3599,28 @@ const appendClipboardVideoToQueueHandler = createAppendClipboardVideoToQueueHand
|
||||
appendClipboardVideoToQueueMainDeps,
|
||||
);
|
||||
|
||||
async function loadSubtitleSourceText(source: string): Promise<string> {
|
||||
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),
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<string, unknown>) => 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,
|
||||
|
||||
@@ -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<string, unknown>) => 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();
|
||||
|
||||
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
@@ -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<T>(): {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
} {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function flushMicrotasks(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
|
||||
test('latest subtitle prefetch init wins over stale async loads', async () => {
|
||||
const loads = new Map<string, ReturnType<typeof createDeferred<string>>>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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, []);
|
||||
});
|
||||
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
@@ -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<string>;
|
||||
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
|
||||
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||
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<void>;
|
||||
}
|
||||
|
||||
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<void> => {
|
||||
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,
|
||||
};
|
||||
}
|
||||
47
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
47
src/main/runtime/subtitle-prefetch-source.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
42
src/main/runtime/subtitle-prefetch-source.ts
Normal file
42
src/main/runtime/subtitle-prefetch-source.ts
Normal file
@@ -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<string, unknown>;
|
||||
return track.type === 'sub' && track.id === sid && track.external === true;
|
||||
}) as Record<string, unknown> | 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;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user