mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-25 23:35:21 -07:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3a94536a8
|
No files matched your search
@@ -25,7 +25,7 @@ Look up words with Yomitan or Hachidori, mine them to Anki, and track your immer
|
||||
|
||||
Hover over any word in the subtitles to open the full Yomitan popup with definitions, pitch accent, and frequency data. SubMiner bundles its own Yomitan, separate from any browser install.
|
||||
|
||||
Yomitan remains the default. Select the bundled Hachidori backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
Yomitan remains the default. Select the bundled [Hachidori](https://github.com/bee-san/hachidori) backend with `dictionaryBackend: "hachidori"` and restart SubMiner. The tray opens the selected backend's settings. See [dictionary setup](https://docs.subminer.moe/usage#hachidori-setup) for importing dictionaries, linking an external Hachidori host, and configuring Anki.
|
||||
|
||||
<div align="center">
|
||||
<img src="docs-site/public/screenshots/yomitan-lookup.png" width="800" alt="Yomitan dictionary popup over annotated subtitles in mpv">
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- mpv's manual subtitle retiming (`sub-delay`, z/x and sub-step) now carries through SubMiner: mined card audio, images, and timestamps clip the retimed line, and the subtitle sidebar highlight, click-to-seek, subtitle navigation, overlay line matching, and replay pause point all follow the retimed timing.
|
||||
+1
-1
@@ -22,7 +22,7 @@ The bundled Yomitan is separate from any Yomitan in your browser. It has its own
|
||||
|
||||
### Hachidori setup
|
||||
|
||||
Hachidori is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||
[Hachidori](https://github.com/bee-san/hachidori) is an alternative lookup backend. Set `dictionaryBackend` to `"hachidori"` in settings or `config.jsonc`, then restart SubMiner. Set it back to `"yomitan"` and restart to switch back.
|
||||
|
||||
Open Hachidori settings with `Alt+Shift+Y`, the tray's **Open Hachidori Settings**, or `subminer app --hachidori`. Import dictionary ZIPs or use Hachidori's recommended dictionary installer, then set up its Anki template (SubMiner [fills in what it can](/anki-integration#hachidori-settings-from-subminer)). Yomitan and Hachidori keep separate dictionaries and settings. Yomitan profiles, custom Handlebars templates, and `yomitan.externalProfilePath` do not carry over. Subtitle annotations and lookups wait while Hachidori imports a dictionary, so large imports pause them until the import finishes.
|
||||
|
||||
|
||||
@@ -37,12 +37,10 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// A recording AnkiConnect stand-in: one deck and one Lapis-shaped note type.
|
||||
// Added notes (with later `updateNoteFields` writes) are echoed back by `notesInfo`
|
||||
// so the app can resolve their fields; scenarios still assert on `requests`.
|
||||
// A recording AnkiConnect stand-in: an empty collection with one deck and one
|
||||
// Lapis-shaped note type. Scenarios assert on `requests`, not on stored notes.
|
||||
export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
const requests: AnkiRequest[] = [];
|
||||
const addedNotes = new Map<number, Record<string, unknown>>();
|
||||
let nextNoteId = 1000;
|
||||
|
||||
const handle = (body: unknown): unknown => {
|
||||
@@ -61,38 +59,10 @@ export async function startFakeAnki(): Promise<FakeAnki> {
|
||||
return [FAKE_ANKI_MODEL];
|
||||
case 'modelFieldNames':
|
||||
return MODEL_FIELDS;
|
||||
case 'addNote': {
|
||||
const noteId = nextNoteId++;
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
addedNotes.set(noteId, isRecord(note.fields) ? { ...note.fields } : {});
|
||||
return noteId;
|
||||
}
|
||||
case 'updateNoteFields': {
|
||||
const note = isRecord(params.note) ? params.note : {};
|
||||
const fields = typeof note.id === 'number' ? addedNotes.get(note.id) : undefined;
|
||||
if (fields && isRecord(note.fields)) Object.assign(fields, note.fields);
|
||||
return null;
|
||||
}
|
||||
case 'notesInfo': {
|
||||
// Like AnkiConnect: one entry per requested id, `{}` for unknown notes.
|
||||
const ids = Array.isArray(params.notes) ? params.notes : [];
|
||||
return ids.map((id) => {
|
||||
const fields = typeof id === 'number' ? addedNotes.get(id) : undefined;
|
||||
if (!fields) return {};
|
||||
return {
|
||||
noteId: id,
|
||||
modelName: FAKE_ANKI_MODEL,
|
||||
tags: [],
|
||||
fields: Object.fromEntries(
|
||||
MODEL_FIELDS.map((name, order) => [
|
||||
name,
|
||||
{ value: String(fields[name] ?? ''), order },
|
||||
]),
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
case 'addNote':
|
||||
return nextNoteId++;
|
||||
case 'findNotes':
|
||||
case 'notesInfo':
|
||||
return [];
|
||||
case 'storeMediaFile':
|
||||
return params.filename ?? null;
|
||||
|
||||
@@ -91,14 +91,6 @@ async function showCue(cue: (typeof FIXTURE_CUES)[number]): Promise<RenderedToke
|
||||
});
|
||||
}
|
||||
|
||||
// Card-kind flags are written before media, so wait for the update carrying audio.
|
||||
function findMediaUpdate(session: E2eSession) {
|
||||
return session.anki.requests.find(
|
||||
(request) =>
|
||||
request.action === 'updateNoteFields' && JSON.stringify(request.params).includes('[sound:'),
|
||||
);
|
||||
}
|
||||
|
||||
function hasToken(tokens: RenderedToken[], surface: string, headword: string): boolean {
|
||||
return tokens.some((token) => token.surface === surface && token.headword === headword);
|
||||
}
|
||||
@@ -134,10 +126,10 @@ test(
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const update = await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
const update = await waitUntil(
|
||||
() => session.anki.requests.find((request) => request.action === 'updateNoteFields'),
|
||||
{ description: 'media fields to be written to the mined note', timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
const added = session.anki.requests.filter((request) => request.action === 'addNote');
|
||||
assert.equal(added.length, 1);
|
||||
@@ -164,38 +156,6 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'mining a line retimed with sub-delay clips it where mpv plays it',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// Past the cue's own end, so the line is only on screen because of the delay.
|
||||
await session.mpv.command('seek', cue.end + delay / 2, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
session.anki.requests.length = 0;
|
||||
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
await waitUntil(() => findMediaUpdate(session), {
|
||||
description: 'media fields to be written to the mined note',
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
|
||||
// MiscInfo stamps the clip start: the cue's start on the video clock.
|
||||
const written = JSON.stringify(session.anki.requests);
|
||||
assert.ok(written.includes(`(00:00:0${cue.start + delay})`), written);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'open subtitle sidebar lists the parsed cues and follows playback',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
@@ -224,49 +184,6 @@ test(
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'subtitle sidebar follows and seeks a sub-delay retimed track',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const delay = 2;
|
||||
await session.mpv.command('set_property', 'sub-delay', delay);
|
||||
try {
|
||||
// At 9s cue 2 starts on the file's clock, but mpv is still showing cue 1.
|
||||
await session.mpv.command('seek', FIXTURE_CUES[2].start, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(FIXTURE_CUES[1].text)}`,
|
||||
{ description: 'overlay to show the delayed line' },
|
||||
);
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item.active')?.dataset.index === '1'`,
|
||||
{ description: 'sidebar to highlight the delayed line' },
|
||||
);
|
||||
|
||||
await overlay.evaluate(
|
||||
`document.querySelector('#subtitleSidebarList .subtitle-sidebar-item[data-index="2"]').click()`,
|
||||
);
|
||||
const target = FIXTURE_CUES[2].start + delay;
|
||||
await waitUntil(
|
||||
async () => {
|
||||
const time = Number(await session.mpv.command('get_property', 'time-pos'));
|
||||
return time >= target && time < target + 0.5;
|
||||
},
|
||||
{ description: `click to seek to cue 2 at ${target}s on the video clock` },
|
||||
);
|
||||
|
||||
await session.runAppCommand('--toggle-subtitle-sidebar');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleSidebarModal').classList.contains('hidden')`,
|
||||
{ description: 'sidebar to close' },
|
||||
);
|
||||
} finally {
|
||||
await session.mpv.command('set_property', 'sub-delay', 0);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Runs last: it leaves the lookup popup open over the overlay.
|
||||
test(
|
||||
'holding Shift over a word opens the Yomitan popup with its dictionary entry',
|
||||
|
||||
@@ -45,7 +45,6 @@ import {
|
||||
} from './anki-field-config';
|
||||
import { createLogger } from './logger';
|
||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||
import { shiftSubtitleRangeToVideoClock } from './core/services/subtitle-clock';
|
||||
import {
|
||||
createUiFeedbackState,
|
||||
beginUpdateProgress,
|
||||
@@ -1030,10 +1029,10 @@ export class AnkiIntegration {
|
||||
Number.isFinite(this.mpvClient.currentSubEnd) &&
|
||||
this.mpvClient.currentSubEnd > this.mpvClient.currentSubStart
|
||||
) {
|
||||
return shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: this.mpvClient.currentSubStart, endTime: this.mpvClient.currentSubEnd },
|
||||
this.mpvClient.currentSubDelay,
|
||||
);
|
||||
return {
|
||||
startTime: this.mpvClient.currentSubStart,
|
||||
endTime: this.mpvClient.currentSubEnd,
|
||||
};
|
||||
}
|
||||
|
||||
const currentTime = this.mpvClient.currentTimePos || 0;
|
||||
@@ -1673,7 +1672,7 @@ export class AnkiIntegration {
|
||||
if (this.config.fields?.miscInfo) {
|
||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||
audioFilename,
|
||||
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||
this.mpvClient.currentSubStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1696,7 +1695,7 @@ export class AnkiIntegration {
|
||||
if (this.config.fields?.miscInfo && !result.miscInfoValue) {
|
||||
result.miscInfoValue = this.formatMiscInfoPattern(
|
||||
imageFilename,
|
||||
this.mpvClient.currentSubStart + this.mpvClient.currentSubDelay,
|
||||
this.mpvClient.currentSubStart,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type MediaTimingReviewRequest,
|
||||
type WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { shiftSubtitleRangeToVideoClock } from '../core/services/subtitle-clock';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -429,12 +428,10 @@ export class CardCreationService {
|
||||
return;
|
||||
}
|
||||
|
||||
let { startTime, endTime } = shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||
mpvClient.currentSubDelay,
|
||||
);
|
||||
let startTime = mpvClient.currentSubStart;
|
||||
let endTime = mpvClient.currentSubEnd;
|
||||
|
||||
if (mpvClient.currentSubStart === undefined || mpvClient.currentSubEnd === undefined) {
|
||||
if (startTime === undefined || endTime === undefined) {
|
||||
const currentTime = mpvClient.currentTimePos || 0;
|
||||
const fallback = this.deps.getFallbackDurationSeconds() / 2;
|
||||
startTime = currentTime - fallback;
|
||||
|
||||
@@ -55,7 +55,6 @@ export {
|
||||
subtitleCueListSeekTime,
|
||||
subtitleCueSeekTime,
|
||||
} from './subtitle-cue-navigation';
|
||||
export { shiftSubtitleRangeToVideoClock, toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||
export { createFrequencyDictionaryLookup } from './frequency-dictionary';
|
||||
export { createJlptVocabularyLookup } from './jlpt-vocab';
|
||||
export {
|
||||
|
||||
@@ -89,7 +89,6 @@ function createSubtitleSidebarSnapshotFixture(): SubtitleSidebarSnapshot {
|
||||
cuesRevision: 1,
|
||||
cues: [],
|
||||
currentTimeSec: null,
|
||||
subDelay: 0,
|
||||
currentSubtitle: { text: '', startTime: null, endTime: null },
|
||||
config: {
|
||||
enabled: false,
|
||||
|
||||
@@ -77,7 +77,6 @@ test('mineSentenceCard handles missing integration and disconnected mpv', async
|
||||
currentSubText: 'line',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentSubDelay: 0,
|
||||
},
|
||||
showMpvOsd: (text) => osd.push(text),
|
||||
}),
|
||||
@@ -110,7 +109,6 @@ test('mineSentenceCard creates sentence card from mpv subtitle state', async ()
|
||||
currentSubText: 'subtitle line',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: 'secondary line',
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
@@ -150,7 +148,6 @@ test('mineSentenceCard prefers a canonical primary subtitle snapshot', async ()
|
||||
currentSubText: '今今今手手手',
|
||||
currentSubStart: 11.4,
|
||||
currentSubEnd: 11.8,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: 'English subtitle',
|
||||
},
|
||||
primarySubtitle: {
|
||||
@@ -190,7 +187,6 @@ test('mineSentenceCard uses normalized secondary subtitle state instead of raw m
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: 'Your\nmosaic',
|
||||
requestProperty: async () => {
|
||||
requestedRawSecondaryText = true;
|
||||
@@ -222,7 +218,6 @@ test('mineSentenceCard omits normalized secondary text that matches the primary
|
||||
currentSubText: '日本語字幕',
|
||||
currentSubStart: 10,
|
||||
currentSubEnd: 12,
|
||||
currentSubDelay: 0,
|
||||
currentSecondarySubText: '日本語字幕',
|
||||
},
|
||||
showMpvOsd: () => {},
|
||||
@@ -401,7 +396,7 @@ test('handleMineSentenceDigit joins per-entry secondary subtitles when available
|
||||
|
||||
test('captureLiveSubtitleMiningContext snapshots the current line and timings', () => {
|
||||
const context = captureLiveSubtitleMiningContext(
|
||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25, currentSubDelay: 0 },
|
||||
{ currentSubText: ' 食べる ', currentSubStart: 12.5, currentSubEnd: 15.25 },
|
||||
() => 1234,
|
||||
);
|
||||
|
||||
@@ -414,16 +409,6 @@ test('captureLiveSubtitleMiningContext snapshots the current line and timings',
|
||||
});
|
||||
});
|
||||
|
||||
test('captureLiveSubtitleMiningContext moves sub-delay retimed lines onto the video clock', () => {
|
||||
const context = captureLiveSubtitleMiningContext(
|
||||
{ currentSubText: '食べる', currentSubStart: 5, currentSubEnd: 8, currentSubDelay: -1.5 },
|
||||
() => 1234,
|
||||
);
|
||||
|
||||
assert.equal(context?.startTime, 3.5);
|
||||
assert.equal(context?.endTime, 6.5);
|
||||
});
|
||||
|
||||
test('captureLiveSubtitleMiningContext rejects missing client, empty text and bad timings', () => {
|
||||
assert.equal(captureLiveSubtitleMiningContext(null), null);
|
||||
assert.equal(
|
||||
@@ -431,7 +416,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
||||
currentSubText: ' ',
|
||||
currentSubStart: 1,
|
||||
currentSubEnd: 2,
|
||||
currentSubDelay: 0,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
@@ -440,7 +424,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
||||
currentSubText: 'line',
|
||||
currentSubStart: 5,
|
||||
currentSubEnd: 5,
|
||||
currentSubDelay: 0,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
@@ -449,7 +432,6 @@ test('captureLiveSubtitleMiningContext rejects missing client, empty text and ba
|
||||
currentSubText: 'line',
|
||||
currentSubStart: NaN,
|
||||
currentSubEnd: 2,
|
||||
currentSubDelay: 0,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { SubtitleTimingBlock } from '../../subtitle-timing-tracker';
|
||||
import type { SubtitleMiningContext } from '../../types/subtitle';
|
||||
import { shiftSubtitleRangeToVideoClock } from './subtitle-clock';
|
||||
|
||||
interface SubtitleTimingTrackerLike {
|
||||
getRecentBlocks: (count: number) => string[];
|
||||
@@ -26,7 +25,6 @@ interface MpvClientLike {
|
||||
currentSubText: string;
|
||||
currentSubStart: number;
|
||||
currentSubEnd: number;
|
||||
currentSubDelay: number;
|
||||
currentSecondarySubText?: string;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
}
|
||||
@@ -64,10 +62,7 @@ export function handleMultiCopyDigit(
|
||||
* line is on screen by then; callers capture here at mining time instead.
|
||||
*/
|
||||
export function captureLiveSubtitleMiningContext(
|
||||
client: Pick<
|
||||
MpvClientLike,
|
||||
'currentSubText' | 'currentSubStart' | 'currentSubEnd' | 'currentSubDelay'
|
||||
> | null,
|
||||
client: Pick<MpvClientLike, 'currentSubText' | 'currentSubStart' | 'currentSubEnd'> | null,
|
||||
now: () => number = Date.now,
|
||||
): SubtitleMiningContext | null {
|
||||
if (!client) {
|
||||
@@ -78,10 +73,7 @@ export function captureLiveSubtitleMiningContext(
|
||||
if (!text || !Number.isFinite(startTime) || !Number.isFinite(endTime) || endTime <= startTime) {
|
||||
return null;
|
||||
}
|
||||
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||
{ source: 'overlay', text, startTime, endTime, capturedAtMs: now() },
|
||||
client.currentSubDelay,
|
||||
);
|
||||
return { source: 'overlay', text, startTime, endTime, capturedAtMs: now() };
|
||||
}
|
||||
|
||||
export function copyCurrentSubtitle(deps: {
|
||||
@@ -175,7 +167,6 @@ export async function markLastCardAsAudioCard(deps: {
|
||||
export async function mineSentenceCard(deps: {
|
||||
ankiIntegration: AnkiIntegrationLike | null;
|
||||
mpvClient: MpvClientLike | null;
|
||||
/** Already on the video clock, like every `SubtitleMiningContext`. */
|
||||
primarySubtitle?: Pick<SubtitleMiningContext, 'text' | 'startTime' | 'endTime'>;
|
||||
showMpvOsd: (text: string) => void;
|
||||
}): Promise<boolean> {
|
||||
@@ -194,16 +185,10 @@ export async function mineSentenceCard(deps: {
|
||||
}
|
||||
|
||||
const secondarySubText = getCurrentSecondarySubTextForSentenceCard(mpvClient, primaryText);
|
||||
const range =
|
||||
deps.primarySubtitle ??
|
||||
shiftSubtitleRangeToVideoClock(
|
||||
{ startTime: mpvClient.currentSubStart, endTime: mpvClient.currentSubEnd },
|
||||
mpvClient.currentSubDelay,
|
||||
);
|
||||
return await anki.createSentenceCard(
|
||||
primaryText,
|
||||
range.startTime,
|
||||
range.endTime,
|
||||
deps.primarySubtitle?.startTime ?? mpvClient.currentSubStart,
|
||||
deps.primarySubtitle?.endTime ?? mpvClient.currentSubEnd,
|
||||
secondarySubText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ const MPV_SUBTITLE_PROPERTY_OBSERVATIONS: string[] = [
|
||||
'path',
|
||||
'sub-start',
|
||||
'sub-end',
|
||||
'sub-delay',
|
||||
'time-pos',
|
||||
'secondary-sub-text',
|
||||
'aid',
|
||||
|
||||
@@ -73,8 +73,6 @@ function createDeps(overrides: Partial<MpvProtocolHandleMessageDeps> = {}): {
|
||||
getCurrentSubStart: () => 0,
|
||||
setCurrentSubEnd: () => {},
|
||||
getCurrentSubEnd: () => 0,
|
||||
setCurrentSubDelay: () => {},
|
||||
getCurrentSubDelay: () => 0,
|
||||
emitMediaPathChange: (payload) => {
|
||||
state.mediaPath = payload.path;
|
||||
},
|
||||
@@ -133,22 +131,6 @@ test('dispatchMpvProtocolMessage emits subtitle text on property change', async
|
||||
assert.deepEqual(state.events, [{ text: '字幕', isOverlayVisible: false }]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage tracks sub-delay retiming', async () => {
|
||||
const delays: number[] = [];
|
||||
const { deps } = createDeps({ setCurrentSubDelay: (value) => delays.push(value) });
|
||||
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-delay', data: 1.5 },
|
||||
deps,
|
||||
);
|
||||
await dispatchMpvProtocolMessage(
|
||||
{ event: 'property-change', name: 'sub-delay', data: null },
|
||||
deps,
|
||||
);
|
||||
|
||||
assert.deepEqual(delays, [1.5, 0]);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage widens halfwidth katakana in subtitle text', async () => {
|
||||
const { deps, state } = createDeps();
|
||||
|
||||
@@ -397,29 +379,6 @@ test('dispatchMpvProtocolMessage pauses on sub-end when pendingPauseAtSubEnd is
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage pauses at the sub-delay retimed sub-end', async () => {
|
||||
let pauseAtTime: number | null = null;
|
||||
let subDelay = 1.5;
|
||||
const { deps } = createDeps({
|
||||
getPendingPauseAtSubEnd: () => true,
|
||||
getCurrentSubDelay: () => subDelay,
|
||||
setCurrentSubDelay: (next) => {
|
||||
subDelay = next;
|
||||
},
|
||||
getPauseAtTime: () => pauseAtTime,
|
||||
setPauseAtTime: (next) => {
|
||||
pauseAtTime = next;
|
||||
},
|
||||
});
|
||||
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-end', data: 42 }, deps);
|
||||
assert.equal(pauseAtTime, 43.5);
|
||||
|
||||
// Retiming before the replay reaches its end moves the pause with the line.
|
||||
await dispatchMpvProtocolMessage({ event: 'property-change', name: 'sub-delay', data: 2 }, deps);
|
||||
assert.equal(pauseAtTime, 44);
|
||||
});
|
||||
|
||||
test('dispatchMpvProtocolMessage updates current time before emitting time-pos change', async () => {
|
||||
const calls: string[] = [];
|
||||
let currentTimePos = 0;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||
import { toVideoClock } from './subtitle-clock';
|
||||
|
||||
export type MpvMessage = {
|
||||
event?: string;
|
||||
@@ -65,8 +64,6 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
getCurrentSubStart: () => number;
|
||||
setCurrentSubEnd: (value: number) => void;
|
||||
getCurrentSubEnd: () => number;
|
||||
setCurrentSubDelay: (value: number) => void;
|
||||
getCurrentSubDelay: () => number;
|
||||
emitMediaPathChange: (payload: { path: string }) => void;
|
||||
emitMediaTitleChange: (payload: { title: string | null }) => void;
|
||||
emitTimePosChange: (payload: { time: number }) => void;
|
||||
@@ -266,8 +263,7 @@ export async function dispatchMpvProtocolMessage(
|
||||
const subEnd = (msg.data as number) || 0;
|
||||
deps.setCurrentSubEnd(subEnd);
|
||||
if (deps.getPendingPauseAtSubEnd() && subEnd > 0) {
|
||||
// `sub-end` is on the subtitle clock; the pause check compares it with `time-pos`.
|
||||
deps.setPauseAtTime(toVideoClock(subEnd, deps.getCurrentSubDelay()));
|
||||
deps.setPauseAtTime(subEnd);
|
||||
deps.setPendingPauseAtSubEnd(false);
|
||||
deps.sendCommand({ command: ['set_property', 'pause', false] });
|
||||
}
|
||||
@@ -276,16 +272,6 @@ export async function dispatchMpvProtocolMessage(
|
||||
start: deps.getCurrentSubStart(),
|
||||
end: deps.getCurrentSubEnd(),
|
||||
});
|
||||
} else if (msg.name === 'sub-delay') {
|
||||
const parsed = typeof msg.data === 'number' ? msg.data : Number(msg.data);
|
||||
const delay = Number.isFinite(parsed) ? parsed : 0;
|
||||
// A scheduled replay pause sits at the line's end on the video clock; retiming
|
||||
// mid-replay moves that end, so the pause moves with it.
|
||||
const pauseAtTime = deps.getPauseAtTime();
|
||||
if (pauseAtTime !== null) {
|
||||
deps.setPauseAtTime(pauseAtTime + delay - deps.getCurrentSubDelay());
|
||||
}
|
||||
deps.setCurrentSubDelay(delay);
|
||||
} else if (msg.name === 'secondary-sub-text') {
|
||||
const nextSubText = (msg.data as string) || '';
|
||||
deps.setCurrentSecondarySubText(nextSubText);
|
||||
|
||||
@@ -160,8 +160,6 @@ export class MpvIpcClient implements MpvClient {
|
||||
public currentTimePos = 0;
|
||||
public currentSubStart = 0;
|
||||
public currentSubEnd = 0;
|
||||
/** mpv `sub-delay`: seconds each primary line plays after its `sub-start`/`sub-end`. */
|
||||
public currentSubDelay = 0;
|
||||
public currentSubText = '';
|
||||
public currentSecondarySubText = '';
|
||||
public currentAudioStreamIndex: number | null = null;
|
||||
@@ -461,10 +459,6 @@ export class MpvIpcClient implements MpvClient {
|
||||
this.currentSubEnd = value;
|
||||
},
|
||||
getCurrentSubEnd: () => this.currentSubEnd,
|
||||
setCurrentSubDelay: (value: number) => {
|
||||
this.currentSubDelay = value;
|
||||
},
|
||||
getCurrentSubDelay: () => this.currentSubDelay,
|
||||
emitMediaPathChange: (payload) => {
|
||||
this.emit('media-path-change', payload);
|
||||
},
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* mpv's `sub-start`/`sub-end` and parsed cues carry the subtitle file's own timestamps
|
||||
* (the subtitle clock), but mpv plays each line `sub-delay` seconds later (z/x retiming,
|
||||
* `sub-step`). Playback position, seeks, and media cuts are on the video clock, so any
|
||||
* value crossing between the two converts here.
|
||||
*/
|
||||
|
||||
function finiteDelay(subDelay: number): number {
|
||||
return Number.isFinite(subDelay) ? subDelay : 0;
|
||||
}
|
||||
|
||||
/** Playback position (`time-pos`) on the subtitle clock, comparable with cue times. */
|
||||
export function toSubtitleClock(videoTimeSec: number, subDelay: number): number {
|
||||
return videoTimeSec - finiteDelay(subDelay);
|
||||
}
|
||||
|
||||
/** Subtitle-clock seconds on the video clock, for seeks. */
|
||||
export function toVideoClock(subtitleTimeSec: number, subDelay: number): number {
|
||||
return subtitleTimeSec + finiteDelay(subDelay);
|
||||
}
|
||||
|
||||
/** A subtitle-clock range on the video clock, for mining and media generation. */
|
||||
export function shiftSubtitleRangeToVideoClock<T extends { startTime: number; endTime: number }>(
|
||||
range: T,
|
||||
subDelay: number,
|
||||
): T {
|
||||
const delay = finiteDelay(subDelay);
|
||||
if (delay === 0) {
|
||||
return range;
|
||||
}
|
||||
return { ...range, startTime: range.startTime + delay, endTime: range.endTime + delay };
|
||||
}
|
||||
@@ -26,7 +26,7 @@ test('next subtitle navigation skips generated ASS events and seeks to the next
|
||||
},
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), [
|
||||
'seek',
|
||||
13.08,
|
||||
'absolute+exact',
|
||||
@@ -41,7 +41,7 @@ test('next subtitle navigation treats simultaneous sanitized cues as one line bo
|
||||
{ startTime: 13.02, endTime: 16, text: 'Next English' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.1), [
|
||||
'seek',
|
||||
13.08,
|
||||
'absolute+exact',
|
||||
@@ -55,7 +55,7 @@ test('next subtitle navigation advances past the latest overlapping lyric', () =
|
||||
{ startTime: 16, endTime: 19, text: 'next lyric' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 13.2), [
|
||||
'seek',
|
||||
16.08,
|
||||
'absolute+exact',
|
||||
@@ -68,7 +68,7 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
||||
{ startTime: 13, endTime: 16, text: 'current line' },
|
||||
];
|
||||
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5, 0), [
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', -1], cues, 14.5), [
|
||||
'seek',
|
||||
10.08,
|
||||
'absolute+exact',
|
||||
@@ -78,22 +78,8 @@ test('previous subtitle navigation leaves the current cue and seeks to the prior
|
||||
test('subtitle navigation falls back when no sanitized destination exists', () => {
|
||||
const cues = [{ startTime: 10, endTime: 13, text: 'only line' }];
|
||||
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2, 0), null);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2, 0), null);
|
||||
});
|
||||
|
||||
test('subtitle cue navigation finds and seeks cues on the sub-delay retimed timeline', () => {
|
||||
const cues = [
|
||||
{ startTime: 10, endTime: 13, text: 'one' },
|
||||
{ startTime: 13, endTime: 16, text: 'two' },
|
||||
];
|
||||
|
||||
// At 14.5 with a 2s delay, mpv is showing "one" (12-15 on the video clock).
|
||||
assert.deepEqual(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 14.5, 2), [
|
||||
'seek',
|
||||
15.08,
|
||||
'absolute+exact',
|
||||
]);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['sub-seek', 1], cues, 10.2), null);
|
||||
assert.equal(resolveSanitizedSubtitleSeekCommand(['seek', 5], cues, 10.2), null);
|
||||
});
|
||||
|
||||
test('sidebar cue seeks share the boundary-safe sanitized cue timestamp', () => {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toSubtitleClock, toVideoClock } from './subtitle-clock';
|
||||
import type { SubtitleCue } from './subtitle-cue-parser';
|
||||
|
||||
const CUE_START_GROUP_TOLERANCE_SECONDS = 0.05;
|
||||
@@ -81,10 +80,8 @@ export function subtitleCueListSeekTime(
|
||||
export function resolveSanitizedSubtitleSeekCommand(
|
||||
command: readonly (string | number)[],
|
||||
cues: readonly SubtitleCue[],
|
||||
videoTimeSec: number,
|
||||
subDelay: number,
|
||||
currentTimeSec: number,
|
||||
): (string | number)[] | null {
|
||||
const currentTimeSec = toSubtitleClock(videoTimeSec, subDelay);
|
||||
if (
|
||||
command.length < 2 ||
|
||||
command[0] !== 'sub-seek' ||
|
||||
@@ -127,5 +124,5 @@ export function resolveSanitizedSubtitleSeekCommand(
|
||||
if (!destination) {
|
||||
return null;
|
||||
}
|
||||
return ['seek', toVideoClock(subtitleCueSeekTime(destination.cue), subDelay), 'absolute+exact'];
|
||||
return ['seek', subtitleCueSeekTime(destination.cue), 'absolute+exact'];
|
||||
}
|
||||
+7
-30
@@ -328,11 +328,9 @@ import {
|
||||
setMpvSubVisibilityRuntime,
|
||||
setOverlayDebugVisualizationEnabledRuntime,
|
||||
setVisibleOverlayVisible as setVisibleOverlayVisibleCore,
|
||||
shiftSubtitleRangeToVideoClock,
|
||||
showMpvOsdRuntime,
|
||||
JellyfinPlaybackReporter,
|
||||
tokenizeSubtitle as tokenizeSubtitleCore,
|
||||
toSubtitleClock,
|
||||
triggerFieldGrouping as triggerFieldGroupingCore,
|
||||
upsertYomitanDictionarySettings,
|
||||
updateLastCardFromClipboard as updateLastCardFromClipboardCore,
|
||||
@@ -1939,10 +1937,7 @@ let tokenizeSubtitleDeferred: ((text: string) => Promise<SubtitleData>) | null =
|
||||
function withCurrentSubtitleTiming(payload: SubtitleData): SubtitleData {
|
||||
const canonical = resolveCanonicalPrimarySubtitle({
|
||||
liveText: payload.text,
|
||||
currentTimeSec: toSubtitleClock(
|
||||
Number(appState.mpvClient?.currentTimePos),
|
||||
appState.mpvClient?.currentSubDelay ?? 0,
|
||||
),
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
return {
|
||||
@@ -1956,11 +1951,9 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
||||
// Mine what the overlay shows, not raw mpv `sub-text`: the raw text lists every active
|
||||
// event, so a finished caption row lingering beside a fresh line would end up on the
|
||||
// card. The parsed view also carries the cue's own timings for the clip range.
|
||||
// Cues sit on the subtitle clock, so the lookup undoes `sub-delay` first.
|
||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||
const resolved = resolvePrimarySubtitle({
|
||||
liveText: appState.mpvClient?.currentSubText ?? '',
|
||||
currentTimeSec: toSubtitleClock(Number(appState.mpvClient?.currentTimePos), subDelay),
|
||||
currentTimeSec: Number(appState.mpvClient?.currentTimePos),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
});
|
||||
// Same validity bar as the live capture path: an unusable resolved span must fall
|
||||
@@ -1975,16 +1968,13 @@ function captureCurrentPrimarySubtitleMiningContext(): SubtitleMiningContext | n
|
||||
) {
|
||||
return captureLiveSubtitleMiningContext(appState.mpvClient);
|
||||
}
|
||||
return shiftSubtitleRangeToVideoClock<SubtitleMiningContext>(
|
||||
{
|
||||
return {
|
||||
source: 'overlay',
|
||||
text: resolvedText,
|
||||
startTime: resolved.startTime,
|
||||
endTime: resolved.endTime,
|
||||
capturedAtMs: Date.now(),
|
||||
},
|
||||
subDelay,
|
||||
);
|
||||
};
|
||||
}
|
||||
function emitSubtitlePayload(payload: SubtitleData, options?: { resumePrefetch?: boolean }): void {
|
||||
const timedPayload = withCurrentSubtitleTiming(payload);
|
||||
@@ -2126,18 +2116,13 @@ function getSubtitleSidebarPlaybackState(): SubtitleSidebarPlaybackState {
|
||||
subtitleSidebarCuesRef = appState.activeParsedSubtitleCues;
|
||||
subtitleSidebarCuesRevision += 1;
|
||||
}
|
||||
const subDelay = appState.mpvClient?.currentSubDelay ?? 0;
|
||||
return {
|
||||
sourceKey: JSON.stringify([
|
||||
appState.activeParsedSubtitleMediaPath,
|
||||
appState.activeParsedSubtitleSource,
|
||||
]),
|
||||
cuesRevision: subtitleSidebarCuesRevision,
|
||||
currentTimeSec:
|
||||
appState.mpvClient?.currentTimePos === undefined
|
||||
? null
|
||||
: toSubtitleClock(appState.mpvClient.currentTimePos, subDelay),
|
||||
subDelay,
|
||||
currentTimeSec: appState.mpvClient?.currentTimePos ?? null,
|
||||
currentSubtitle: {
|
||||
text: appState.currentSubText,
|
||||
startTime: appState.mpvClient?.currentSubStart ?? null,
|
||||
@@ -2176,7 +2161,6 @@ function sendRendererMpvCommand(rawCommand: (string | number)[]): void {
|
||||
rawCommand,
|
||||
appState.activeParsedSubtitleCues,
|
||||
appState.mpvClient?.currentTimePos ?? Number.NaN,
|
||||
appState.mpvClient?.currentSubDelay ?? 0,
|
||||
) ?? rawCommand;
|
||||
if (isExplicitMpvSeekCommand(command)) {
|
||||
explicitSeekIntentExpiresAtMs = Date.now() + EXPLICIT_SEEK_INTENT_TTL_MS;
|
||||
@@ -3131,10 +3115,7 @@ const mediaTimingReviewRuntime = createMediaTimingReviewRuntime({
|
||||
acquireMediaWindow: (source, range) => getSharedRemoteMediaWindowCache().acquire(source, range),
|
||||
getSubtitleContextLines: (range) =>
|
||||
collectMediaTimingContextLines({
|
||||
// Review ranges and tracker entries are on the video clock; parsed cues are not.
|
||||
cues: appState.activeParsedSubtitleCues.map((cue) =>
|
||||
shiftSubtitleRangeToVideoClock(cue, appState.mpvClient?.currentSubDelay ?? 0),
|
||||
),
|
||||
cues: appState.activeParsedSubtitleCues,
|
||||
fallbackPrevious: appState.subtitleTimingTracker?.getRecentEntries(40) ?? [],
|
||||
startTime: range.startTime,
|
||||
endTime: range.endTime,
|
||||
@@ -6047,11 +6028,7 @@ const { registerIpcRuntimeHandlers } = composeIpcRuntimeHandlers({
|
||||
// live mpv sub timings at lookup time so media generation clips the mined line even
|
||||
// when extraction finishes long after playback has moved on.
|
||||
recordSubtitleMiningContext: (context) =>
|
||||
recordSubtitleMiningContext(
|
||||
context
|
||||
? shiftSubtitleRangeToVideoClock(context, appState.mpvClient?.currentSubDelay ?? 0)
|
||||
: captureCurrentPrimarySubtitleMiningContext(),
|
||||
),
|
||||
recordSubtitleMiningContext(context ?? captureCurrentPrimarySubtitleMiningContext()),
|
||||
tokenizeCurrentSubtitle: async () => {
|
||||
const tokenizeSubtitleForCurrent = tokenizeSubtitleDeferred;
|
||||
return resolveCurrentSubtitleForRenderer({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||
import type { SubtitleCue, SubtitleData } from '../../types';
|
||||
import { selectAutoplayStartupCue } from './autoplay-subtitle-primer';
|
||||
import { primeVisibleOverlaySubtitleFromMpv } from './current-subtitle-snapshot';
|
||||
@@ -13,7 +12,6 @@ type AutoplaySubtitlePrimingMpvClient = {
|
||||
requestProperty: (name: string) => Promise<unknown>;
|
||||
currentVideoPath?: string;
|
||||
currentTimePos?: number;
|
||||
currentSubDelay?: number;
|
||||
currentSubText?: string;
|
||||
currentSecondarySubText?: string;
|
||||
setCurrentSecondarySubText?: (text: string) => void;
|
||||
@@ -158,7 +156,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
const currentTimeSec = Number(client?.currentTimePos ?? deps.getLastObservedTimePos());
|
||||
return resolvePrimarySubtitleText({
|
||||
liveText: text,
|
||||
currentTimeSec: toSubtitleClock(currentTimeSec, client?.currentSubDelay ?? 0),
|
||||
currentTimeSec,
|
||||
cues: deps.getActiveParsedSubtitleCues(),
|
||||
});
|
||||
}
|
||||
@@ -272,10 +270,7 @@ export function createAutoplaySubtitlePrimingRuntime(deps: AutoplaySubtitlePrimi
|
||||
const currentTimeSeconds = Number(
|
||||
timePosRaw ?? client?.currentTimePos ?? deps.getLastObservedTimePos() ?? 0,
|
||||
);
|
||||
const resolvedTimeSeconds = toSubtitleClock(
|
||||
Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0,
|
||||
client?.currentSubDelay ?? 0,
|
||||
);
|
||||
const resolvedTimeSeconds = Number.isFinite(currentTimeSeconds) ? currentTimeSeconds : 0;
|
||||
const cue = selectAutoplayStartupCue(
|
||||
cues,
|
||||
resolvedTimeSeconds,
|
||||
|
||||
@@ -176,21 +176,6 @@ test('subtitle-track transitions ignore stale parsed cues until replacement cues
|
||||
assert.deepEqual(recordedStarts.slice(-1), [20]);
|
||||
});
|
||||
|
||||
test('subtitle timing history records lines on the video clock under sub-delay', () => {
|
||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||
const { handlers } = buildMainDeps({
|
||||
mpvClient: { currentSubDelay: 2 },
|
||||
subtitleTimingTracker: {
|
||||
recordSubtitle: (text: string, start: number, end: number) =>
|
||||
timing.push({ text, start, end }),
|
||||
},
|
||||
});
|
||||
|
||||
handlers.recordSubtitleTiming('食べる', 5, 8);
|
||||
|
||||
assert.deepEqual(timing, [{ text: '食べる', start: 7, end: 10 }]);
|
||||
});
|
||||
|
||||
test('canonical ASS cues replace live glyph spam for display, history, and immersion', () => {
|
||||
const immersion: Array<{ text: string; start: number; end: number }> = [];
|
||||
const timing: Array<{ text: string; start: number; end: number }> = [];
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { toSubtitleClock } from '../../core/services/subtitle-clock';
|
||||
import { createSubtitleLineDedupGate } from '../../core/services/subtitle-line-dedup-gate';
|
||||
import type { MergedToken, SubtitleCue, SubtitleData } from '../../types';
|
||||
import {
|
||||
@@ -24,7 +23,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
currentSubText?: string;
|
||||
currentSecondarySubText?: string;
|
||||
currentTimePos?: number;
|
||||
currentSubDelay?: number;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
} | null;
|
||||
immersionTracker: {
|
||||
@@ -163,10 +161,7 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
resolveSubtitleText: (liveText: string) =>
|
||||
resolvePrimarySubtitleText({
|
||||
liveText,
|
||||
currentTimeSec: toSubtitleClock(
|
||||
Number(deps.appState.mpvClient?.currentTimePos),
|
||||
deps.appState.mpvClient?.currentSubDelay ?? 0,
|
||||
),
|
||||
currentTimeSec: Number(deps.appState.mpvClient?.currentTimePos),
|
||||
cues: deps.appState.activeParsedSubtitleCues,
|
||||
}),
|
||||
getCurrentLiveSubtitleText: () => deps.appState.mpvClient?.currentSubText ?? '',
|
||||
@@ -236,9 +231,6 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
hasSubtitleTimingTracker: () => Boolean(deps.appState.subtitleTimingTracker),
|
||||
recordSubtitleTiming: (text: string, start: number, end: number) => {
|
||||
const secondaryText = deps.appState.mpvClient?.currentSecondarySubText || undefined;
|
||||
// The tracker feeds multi-line mining, so it stores video-clock times: the subtitle
|
||||
// timings plus the `sub-delay` in effect while the line was on screen.
|
||||
const subDelay = deps.appState.mpvClient?.currentSubDelay ?? 0;
|
||||
const canonical = resolveCanonicalSample(text, start);
|
||||
if (!canonical) {
|
||||
const recordableText = resolveTextForRecording(text, start);
|
||||
@@ -247,8 +239,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
}
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
recordableText,
|
||||
start + subDelay,
|
||||
end + subDelay,
|
||||
start,
|
||||
end,
|
||||
secondaryText,
|
||||
);
|
||||
return;
|
||||
@@ -261,8 +253,8 @@ export function createBuildBindMpvMainEventHandlersMainDepsHandler(deps: {
|
||||
recordedTimingCanonicalKeys.add(key);
|
||||
deps.appState.subtitleTimingTracker?.recordSubtitle?.(
|
||||
cue.text,
|
||||
cue.startTime + subDelay,
|
||||
cue.endTime + subDelay,
|
||||
cue.startTime,
|
||||
cue.endTime,
|
||||
secondaryText,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ export async function setup() {
|
||||
],
|
||||
currentSubtitle: { text: '最初の台詞', startTime: 0, endTime: 1 },
|
||||
currentTimeSec: 0,
|
||||
subDelay: 0,
|
||||
config: {
|
||||
...SUBTITLE_DEFAULT_CONFIG.subtitleSidebar,
|
||||
enabled: true,
|
||||
|
||||
@@ -199,7 +199,6 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
||||
cues: options.cues ?? [{ startTime: 1, endTime: 2, text: 'first' }],
|
||||
currentSubtitle: options.currentSubtitle ?? { text: 'first', startTime: 1, endTime: 2 },
|
||||
currentTimeSec: 'currentTimeSec' in options ? (options.currentTimeSec ?? null) : 1.1,
|
||||
subDelay: 0,
|
||||
config: { ...DEFAULT_SIDEBAR_CONFIG, ...options.config },
|
||||
};
|
||||
const requests = { snapshot: 0, playback: 0 };
|
||||
@@ -227,8 +226,8 @@ function createSidebarHarness(options: SidebarHarnessOptions = {}) {
|
||||
},
|
||||
getSubtitleSidebarPlaybackState: async (): Promise<SubtitleSidebarPlaybackState> => {
|
||||
requests.playback += 1;
|
||||
const { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle } = snapshot;
|
||||
return { sourceKey, cuesRevision, currentTimeSec, subDelay, currentSubtitle };
|
||||
const { sourceKey, cuesRevision, currentTimeSec, currentSubtitle } = snapshot;
|
||||
return { sourceKey, cuesRevision, currentTimeSec, currentSubtitle };
|
||||
},
|
||||
getPlaybackPaused: async () => false,
|
||||
sendMpvCommand: (command: MpvCommand) => {
|
||||
@@ -529,16 +528,6 @@ test('subtitle sidebar seeks to the selected cue start using the overlap-aware s
|
||||
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.48, 'absolute+exact']);
|
||||
});
|
||||
|
||||
test('subtitle sidebar seeks to where mpv plays a sub-delay retimed cue', async () => {
|
||||
const h = createSidebarHarness();
|
||||
h.setSnapshot({ subDelay: 2 });
|
||||
await h.modal.openSubtitleSidebarModal();
|
||||
|
||||
h.modal.seekToCue({ startTime: 1, endTime: 2, text: 'first' });
|
||||
|
||||
assert.deepEqual(h.mpvCommands.at(-1), ['seek', 3.08, 'absolute+exact']);
|
||||
});
|
||||
|
||||
test('subtitle sidebar close reports hidden visibility and notifies the modal close', async () => {
|
||||
const h = createSidebarHarness();
|
||||
await h.modal.openSubtitleSidebarModal();
|
||||
|
||||
@@ -4,7 +4,6 @@ import type {
|
||||
SubtitleMiningContext,
|
||||
SubtitleSidebarSnapshot,
|
||||
} from '../../types';
|
||||
import { toVideoClock } from '../../core/services/subtitle-clock.js';
|
||||
import { subtitleCueListSeekTime } from '../../core/services/subtitle-cue-navigation.js';
|
||||
import type { ModalStateReader, RendererContext } from '../context';
|
||||
import { syncOverlayMouseIgnoreState } from '../overlay-mouse-ignore.js';
|
||||
@@ -232,8 +231,6 @@ export function createSubtitleSidebarModal(
|
||||
let lastSubtitleSidebarLookupCueIndex = -1;
|
||||
let subtitleSourceKey: string | null = null;
|
||||
let subtitleCuesRevision: number | null = null;
|
||||
// Latest mpv `sub-delay` from main; cue times plus this are where mpv plays the cue.
|
||||
let subtitleSubDelay = 0;
|
||||
|
||||
function restoreEmbeddedSidebarPassthrough(): void {
|
||||
syncOverlayMouseIgnoreState(ctx);
|
||||
@@ -419,7 +416,7 @@ export function createSubtitleSidebarModal(
|
||||
function seekToCue(cue: SubtitleCue): void {
|
||||
window.electronAPI.sendMpvCommand([
|
||||
'seek',
|
||||
toVideoClock(subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue), subtitleSubDelay),
|
||||
subtitleCueListSeekTime(ctx.state.subtitleSidebarCues, cue),
|
||||
'absolute+exact',
|
||||
]);
|
||||
}
|
||||
@@ -594,7 +591,6 @@ export function createSubtitleSidebarModal(
|
||||
const snapshot = await window.electronAPI.getSubtitleSidebarSnapshot();
|
||||
pollsSinceFullSnapshot = 0;
|
||||
subtitleCuesRevision = snapshot.cuesRevision;
|
||||
subtitleSubDelay = snapshot.subDelay;
|
||||
if (snapshot.sourceKey !== subtitleSourceKey) {
|
||||
clearSubtitleSidebarSelection(ctx.dom.subtitleSidebarList);
|
||||
lastSubtitleSidebarLookupCueIndex = -1;
|
||||
@@ -644,7 +640,6 @@ export function createSubtitleSidebarModal(
|
||||
await refreshSnapshot();
|
||||
return;
|
||||
}
|
||||
subtitleSubDelay = playback.subDelay;
|
||||
updateActiveCue(playback.currentSubtitle, playback.currentTimeSec);
|
||||
}
|
||||
|
||||
|
||||
@@ -90,8 +90,6 @@ export interface MpvClient {
|
||||
currentTimePos: number;
|
||||
currentSubStart: number;
|
||||
currentSubEnd: number;
|
||||
/** mpv `sub-delay`; `sub-start`/`sub-end` do not include it. */
|
||||
currentSubDelay: number;
|
||||
currentAudioStreamIndex: number | null;
|
||||
requestProperty?: (name: string) => Promise<unknown>;
|
||||
send(command: { command: unknown[]; request_id?: number }): boolean;
|
||||
|
||||
@@ -235,10 +235,7 @@ export interface SubtitleSidebarPlaybackState {
|
||||
sourceKey: string | null;
|
||||
/** Bumps whenever main swaps in a different parsed cue array. */
|
||||
cuesRevision: number;
|
||||
/** Playback position on the subtitle clock (`time-pos` minus `sub-delay`), like cue times. */
|
||||
currentTimeSec: number | null;
|
||||
/** mpv `sub-delay`: cue times plus this give the video-clock seek target. */
|
||||
subDelay: number;
|
||||
currentSubtitle: {
|
||||
text: string;
|
||||
startTime: number | null;
|
||||
@@ -254,7 +251,6 @@ export interface SubtitleSidebarSnapshot extends SubtitleSidebarPlaybackState {
|
||||
export interface SubtitleMiningContext {
|
||||
source: 'subtitle-sidebar' | 'overlay';
|
||||
text: string;
|
||||
/** Video-clock seconds: subtitle timings plus mpv `sub-delay` at capture time. */
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
capturedAtMs?: number;
|
||||
|
||||
Reference in new issue
Block a user