feat(subsync): add reference and target subtitle track picker (#181)

This commit is contained in:
2026-08-03 01:00:14 -07:00
committed by GitHub
parent 176edd67f1
commit 5b8848518a
19 changed files with 1025 additions and 227 deletions
@@ -0,0 +1,7 @@
type: changed
area: subsync
- The subsync modal now lets you pick both sides of an alass run: the reference subtitle (correct timing, defaults to the loaded secondary subtitle track) and the out-of-sync subtitle that gets retimed (defaults to the active primary track).
- alass can now use the loaded video file itself as the reference (audio-based, local files only). It is offered in the reference list but is never the default.
- The out-of-sync subtitle picker also applies to ffsubsync, so a track other than the active primary one can be retimed.
- Retiming the secondary subtitle track now reloads the synced result back into the secondary slot and leaves the primary track selected, instead of replacing the primary subtitle.
+2 -2
View File
@@ -1196,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
### Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
```json
+6 -3
View File
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
1. Open the subsync modal from the overlay.
2. Select the sync engine (alass or ffsubsync).
3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle.
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
+1 -1
View File
@@ -227,7 +227,7 @@ Install ffsubsync or configure the path:
If subtitle sync fails (the error message is prefixed with the engine name):
- Ensure the reference subtitle track exists in the video (alass requires a source track).
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
- Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
-32
View File
@@ -1,32 +0,0 @@
## Highlights
### Added
- Kiku/Lapis Word Card Type Setting
- A new setting (Mining/Anki > Kiku/Lapis Features > "Word Card Type") lets you choose which card-type flag gets marked on Kiku/Lapis word cards, including a click-card option that SubMiner couldn't set before.
- Handy if you only want click cards flagged instead of the default word-and-sentence marking.
- Choosing a card type now clears any other flags automatically, so a note can't end up marked as two types at once.
### Fixed
- Yomitan Popup on macOS
- Fixed the popup going unresponsive after mining a card — clicks outside it no longer leak through to mpv, and the overlay no longer flickers hidden and shown.
- Scrolling over the popup now scrolls its definitions instead of seeking the video.
- YouTube Playlist Links
- Opening a video from a playlist URL (like a Watch Later link with `list=`/`index=`) no longer times out while loading subtitles, metadata, or playback info.
## What's Changed
- feat(anki): add configurable word card type for Kiku/Lapis by @ksyasuda in #175
- fix(overlay): keep Yomitan popup interactive on macOS/Windows by @ksyasuda in #177
- fix(youtube): prevent playlist URLs from stalling yt-dlp probes by @ksyasuda in #180
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+397 -22
View File
@@ -8,6 +8,7 @@ import {
runSubsyncManual,
triggerSubsyncFromConfig,
} from './subsync';
import type { SubsyncManualPayload } from '../../types';
function makeDeps(
overrides: Partial<TriggerSubsyncFromConfigDeps> = {},
@@ -76,7 +77,7 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
},
showMpvOsd: (text) => {
@@ -88,9 +89,9 @@ test('triggerSubsyncFromConfig opens manual picker', async () => {
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(payloadTrackCount, 2);
assert.equal(ffsubsyncAvailable, true);
assert.ok(osd.includes('Subsync: choose engine and source'));
assert.ok(osd.includes('Subsync: choose engine and subtitles'));
assert.equal(inProgressState, false);
});
@@ -140,7 +141,7 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
await triggerSubsyncFromConfig(
makeDeps({
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
@@ -152,9 +153,9 @@ test('triggerSubsyncFromConfig does not run automatic sync', async () => {
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(payloadTrackCount, 2);
assert.equal(spinnerRan, false);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
});
test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async () => {
@@ -195,12 +196,71 @@ test('triggerSubsyncFromConfig dedupes repeated subtitle source tracks', async (
},
}),
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
},
}),
);
assert.equal(payloadTrackCount, 1);
assert.equal(payloadTrackCount, 2);
});
test('triggerSubsyncFromConfig keeps both active tracks when they share a file', async () => {
let payload: SubsyncManualPayload | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
// mpv appends a duplicate entry when the same file is re-added, so
// the primary and secondary slots can point at one path.
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': '/tmp/ref.srt',
},
{
id: 2,
type: 'sub',
selected: true,
external: true,
'external-filename': '/tmp/ref.srt',
},
{
id: 3,
type: 'sub',
selected: false,
external: true,
'external-filename': '/tmp/ref.srt',
},
];
}
return null;
},
}),
openManualPicker: (nextPayload) => {
payload = nextPayload;
},
}),
);
assert.ok(payload);
const resolved = payload as SubsyncManualPayload;
assert.deepEqual(
resolved.subtitleTracks.map((track) => track.id),
[1, 2],
);
assert.equal(resolved.defaultReferenceTrackId, 2);
assert.equal(resolved.defaultTargetTrackId, 1);
});
test('triggerSubsyncFromConfig reports failures to OSD', async () => {
@@ -217,15 +277,157 @@ test('triggerSubsyncFromConfig reports failures to OSD', async () => {
assert.ok(osd.some((line) => line.startsWith('Subsync failed: MPV not connected')));
});
test('runSubsyncManual requires a source track for alass', async () => {
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: null }, makeDeps());
test('runSubsyncManual requires a reference track for alass', async () => {
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: null }, makeDeps());
assert.deepEqual(result, {
ok: false,
message: 'Select a subtitle source track for alass',
message: 'Select a reference subtitle track for alass',
});
});
test('runSubsyncManual rejects alass when reference and target are the same track', async () => {
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 2 },
makeDeps(),
);
assert.deepEqual(result, {
ok: false,
message: 'Reference and out-of-sync subtitles must be different tracks',
});
});
test('runSubsyncManual rejects an unknown target track', async () => {
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 2, targetTrackId: 99 },
makeDeps(),
);
assert.deepEqual(result, {
ok: false,
message: 'Select the out-of-sync subtitle track to retime',
});
});
test('runSubsyncManual rejects the video reference for remote media', async () => {
const result = await runSubsyncManual(
{ engine: 'alass', referenceMode: 'video' },
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return 'https://jellyfin.example/Videos/movie/stream.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [{ id: 1, type: 'sub', selected: true, lang: 'jpn' }];
}
return null;
},
}),
}),
);
assert.equal(result.ok, false);
assert.match(result.message, /cannot use a stream URL as reference/);
});
test('openSubsyncManualPicker defaults the reference to the secondary subtitle track', async () => {
let payload: SubsyncManualPayload | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 3;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
{
id: 2,
type: 'sub',
selected: false,
external: true,
lang: 'eng',
'external-filename': '/tmp/other.srt',
},
{
id: 3,
type: 'sub',
selected: true,
external: true,
lang: 'eng',
'external-filename': '/tmp/secondary.srt',
},
];
}
return null;
},
}),
openManualPicker: (nextPayload) => {
payload = nextPayload;
},
}),
);
assert.ok(payload);
const resolved = payload as SubsyncManualPayload;
assert.deepEqual(
resolved.subtitleTracks.map((track) => track.id),
[1, 2, 3],
);
assert.equal(resolved.defaultReferenceTrackId, 3);
assert.equal(resolved.defaultTargetTrackId, 1);
assert.equal(resolved.videoReferenceAvailable, true);
});
test('openSubsyncManualPicker never defaults to a reference missing from the track list', async () => {
let payload: SubsyncManualPayload | null = null;
await triggerSubsyncFromConfig(
makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return '/tmp/video.mkv';
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') {
return [
{ id: 1, type: 'sub', selected: true, lang: 'jpn' },
// Secondary track with no usable file path: filtered out of the picker.
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': '' },
{ id: 3, type: 'sub', selected: false, lang: 'eng' },
];
}
return null;
},
}),
openManualPicker: (nextPayload) => {
payload = nextPayload;
},
}),
);
assert.ok(payload);
const resolved = payload as SubsyncManualPayload;
assert.deepEqual(
resolved.subtitleTracks.map((track) => track.id),
[1, 3],
);
assert.equal(resolved.defaultReferenceTrackId, 3);
});
test('triggerSubsyncFromConfig does not validate sync tool paths before manual selection', async () => {
const osd: string[] = [];
const inProgress: boolean[] = [];
@@ -242,7 +444,7 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
inProgress.push(value);
},
openManualPicker: (payload) => {
payloadTrackCount = payload.sourceTracks.length;
payloadTrackCount = payload.subtitleTracks.length;
},
showMpvOsd: (text) => {
osd.push(text);
@@ -251,8 +453,8 @@ test('triggerSubsyncFromConfig does not validate sync tool paths before manual s
);
assert.deepEqual(inProgress, [false]);
assert.equal(payloadTrackCount, 1);
assert.deepEqual(osd, ['Subsync: choose engine and source']);
assert.equal(payloadTrackCount, 2);
assert.deepEqual(osd, ['Subsync: choose engine and subtitles']);
});
function writeExecutableScript(filePath: string, content: string): void {
@@ -333,7 +535,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
@@ -346,7 +548,7 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true);
assert.equal(ffArgs[ffOutputFlagIndex + 1], toShellPath(primaryPath));
assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
});
@@ -399,7 +601,7 @@ test('runSubsyncManual writes deterministic _retimed filename when replace is fa
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, true);
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
@@ -453,7 +655,7 @@ test('runSubsyncManual reports ffsubsync command failures with details', async (
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, false);
assert.equal(result.message.startsWith('ffsubsync synchronization failed'), true);
@@ -518,7 +720,7 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
}),
});
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
assert.equal(result.ok, false);
assert.equal(typeof result.message, 'string');
@@ -528,6 +730,179 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
assert.equal(alassArgs[1], toShellPath(primaryPath));
});
function makeAlassSelectionDeps(tmpDir: string): {
deps: TriggerSubsyncFromConfigDeps;
alassLogPath: string;
videoPath: string;
primaryPath: string;
sourcePath: string;
sentCommands: Array<Array<string | number>>;
} {
const alassLogPath = path.join(tmpDir, 'alass-args.log');
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'primary.srt');
const sourcePath = path.join(tmpDir, 'source.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'sub');
fs.writeFileSync(sourcePath, 'sub2');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
alassPath,
`#!/bin/sh\n: > "${toShellPath(alassLogPath)}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${toShellPath(alassLogPath)}"; done\n: > "$3"\nexit 0\n`,
);
const trackList: Array<Record<string, unknown>> = [
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': sourcePath },
];
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
if (payload.command[0] === 'sub-add' || payload.command[0] === 'sub_add') {
trackList.push({
id: trackList.length + 1,
type: 'sub',
selected: false,
external: true,
'external-filename': payload.command[1],
});
}
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return trackList;
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath,
ffmpegPath,
replace: false,
}),
});
return { deps, alassLogPath, videoPath, primaryPath, sourcePath, sentCommands };
}
test('runSubsyncManual uses the video file as alass reference when requested', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-video-ref-'));
const { deps, alassLogPath, videoPath, primaryPath } = makeAlassSelectionDeps(tmpDir);
const result = await runSubsyncManual({ engine: 'alass', referenceMode: 'video' }, deps);
assert.equal(result.ok, true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs[0], toShellPath(videoPath));
assert.equal(alassArgs[1], toShellPath(primaryPath));
});
test('runSubsyncManual retimes the selected target track instead of the primary', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-target-'));
const { deps, alassLogPath, primaryPath, sourcePath, sentCommands } =
makeAlassSelectionDeps(tmpDir);
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
deps,
);
assert.equal(result.ok, true);
const alassArgs = fs.readFileSync(alassLogPath, 'utf8').trim().split('\n');
assert.equal(alassArgs[0], toShellPath(primaryPath));
assert.equal(alassArgs[1], toShellPath(sourcePath));
assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.equal(sentCommands[0]?.[2], 'auto');
});
test('runSubsyncManual keeps a retimed secondary track in the secondary slot', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-secondary-slot-'));
const alassPath = path.join(tmpDir, 'alass.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'ja.srt');
const secondaryPath = path.join(tmpDir, 'en.srt');
const retimedPath = path.join(tmpDir, 'en_retimed.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'ja');
fs.writeFileSync(secondaryPath, 'en');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(alassPath, '#!/bin/sh\n: > "$3"\nexit 0\n');
const trackList: Array<Record<string, unknown>> = [
{ id: 1, type: 'sub', selected: true, external: true, 'external-filename': primaryPath },
{ id: 2, type: 'sub', selected: true, external: true, 'external-filename': secondaryPath },
];
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
if (payload.command[0] === 'sub-add') {
trackList.push({
id: 3,
type: 'sub',
selected: false,
external: true,
'external-filename': payload.command[1],
});
}
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return 2;
if (name === 'track-list') return trackList;
return null;
},
}),
getResolvedConfig: () => ({
alassPath,
ffsubsyncPath,
ffmpegPath,
replace: false,
}),
});
const result = await runSubsyncManual(
{ engine: 'alass', referenceTrackId: 1, targetTrackId: 2 },
deps,
);
assert.equal(result.ok, true);
assert.deepEqual(sentCommands[0], ['sub-add', retimedPath, 'auto']);
assert.deepEqual(sentCommands[1], ['set_property', 'secondary-sub-delay', 0]);
assert.deepEqual(sentCommands[2], ['set_property', 'secondary-sid', 3]);
assert.equal(
sentCommands.some((command) => command[1] === 'sub-delay'),
false,
);
assert.equal(
sentCommands.some((command) => command[1] === 'sid'),
false,
);
assert.equal(
sentCommands.some((command) => command[1] === 'sid'),
false,
);
});
test('runSubsyncManual keeps internal alass source file alive until sync finishes', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-internal-source-'));
const alassPath = path.join(tmpDir, 'alass.sh');
@@ -589,11 +964,11 @@ test('runSubsyncManual keeps internal alass source file alive until sync finishe
}),
});
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
const result = await runSubsyncManual({ engine: 'alass', referenceTrackId: 2 }, deps);
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with alass');
assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.equal(sentCommands[0]?.[0], 'sub-add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
});
@@ -645,7 +1020,7 @@ test('runSubsyncManual resolves string sid values from mpv stream properties', a
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
const result = await runSubsyncManual({ engine: 'ffsubsync' }, deps);
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with ffsubsync');
+201 -40
View File
@@ -21,6 +21,11 @@ interface FileExtractionResult {
temporary: boolean;
}
type SubtitleSlot = 'primary' | 'secondary';
const SYNCED_TRACK_LOOKUP_ATTEMPTS = 5;
const SYNCED_TRACK_LOOKUP_RETRY_MS = 100;
function summarizeCommandFailure(command: string, result: CommandResult): string {
const parts = [
`code=${result.code ?? 'n/a'}`,
@@ -90,16 +95,28 @@ function getSourceTrackIdentity(track: MpvTrack): string {
return 'unknown';
}
function dedupeSourceTracks(tracks: MpvTrack[]): MpvTrack[] {
const deduped = new Map<string, MpvTrack>();
function isPinned(track: MpvTrack, pinnedIds: Set<number>): boolean {
return typeof track.id === 'number' && pinnedIds.has(track.id);
}
// Pinned tracks (the active primary/secondary) always survive, even when two of
// them point at the same file; only unpinned duplicates are collapsed.
function dedupeSubtitleTracks(tracks: MpvTrack[], pinnedIds: Set<number>): MpvTrack[] {
const pinnedIdentities = new Set(
tracks.filter((track) => isPinned(track, pinnedIds)).map(getSourceTrackIdentity),
);
const winners = new Map<string, MpvTrack>();
for (const track of tracks) {
if (isPinned(track, pinnedIds)) continue;
const identity = getSourceTrackIdentity(track);
const existing = deduped.get(identity);
if (pinnedIdentities.has(identity)) continue;
const existing = winners.get(identity);
if (!existing || (track.selected && !existing.selected)) {
deduped.set(identity, track);
winners.set(identity, track);
}
}
return [...deduped.values()];
const kept = new Set(winners.values());
return tracks.filter((track) => isPinned(track, pinnedIds) || kept.has(track));
}
export interface TriggerSubsyncFromConfigDeps extends SubsyncCoreDeps {
@@ -142,20 +159,21 @@ async function gatherSubsyncContext(client: MpvClientLike): Promise<SubsyncConte
}
const secondaryTrack = subtitleTracks.find((track) => track.id === secondarySid) ?? null;
const sourceTracks = subtitleTracks
.filter((track) => track.id !== sid)
.filter((track) => {
if (!track.external) return true;
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
const uniqueSourceTracks = dedupeSourceTracks(sourceTracks);
const usableTracks = subtitleTracks.filter((track) => {
if (typeof track.id !== 'number') return false;
if (!track.external) return true;
const filename = track['external-filename'];
return typeof filename === 'string' && filename.length > 0;
});
return {
videoPath,
primaryTrack,
secondaryTrack,
sourceTracks: uniqueSourceTracks,
subtitleTracks: dedupeSubtitleTracks(
usableTracks,
new Set([sid, secondarySid].filter((id): id is number => typeof id === 'number')),
),
audioStreamIndex: client.currentAudioStreamIndex,
};
}
@@ -271,41 +289,104 @@ async function runFfsubsyncSync(
return runCommand(ffsubsyncPath, args);
}
function loadSyncedSubtitle(client: MpvClientLike, pathToLoad: string): void {
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// mpv may echo the path back with different separators, and Windows paths are
// case-insensitive, so compare normalized forms instead of raw strings.
function normalizeSubtitlePathForCompare(value: string): string {
const normalized = value.replace(/\\/g, '/');
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
async function findAddedSubtitleTrackId(
client: MpvClientLike,
pathToLoad: string,
): Promise<number | null> {
const wanted = normalizeSubtitlePathForCompare(pathToLoad);
// sub-add is queued, so the track may not appear in the first track-list reply.
for (let attempt = 0; attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS; attempt += 1) {
let tracks: MpvTrack[] = [];
try {
const trackListRaw = await client.requestProperty('track-list');
tracks = Array.isArray(trackListRaw) ? normalizeTrackIds(trackListRaw as MpvTrack[]) : [];
} catch {
return null;
}
// Re-adding a file mpv already knows appends a duplicate entry; the newest
// one holds the retimed content, so prefer the last match.
const matches = tracks.filter((track) => {
if (track.type !== 'sub') return false;
const filename = track['external-filename'];
return typeof filename === 'string' && normalizeSubtitlePathForCompare(filename) === wanted;
});
const added = matches[matches.length - 1];
if (added && typeof added.id === 'number') {
return added.id;
}
if (attempt < SYNCED_TRACK_LOOKUP_ATTEMPTS - 1) {
await delay(SYNCED_TRACK_LOOKUP_RETRY_MS);
}
}
return null;
}
async function loadSyncedSubtitle(
client: MpvClientLike,
pathToLoad: string,
slot: SubtitleSlot,
): Promise<void> {
if (!client.connected) {
throw new Error('MPV disconnected while loading subtitle');
}
client.send({ command: ['sub_add', pathToLoad] });
if (slot === 'secondary') {
// Keep the primary track untouched: load without selecting, then point
// secondary-sid at the freshly added track.
client.send({ command: ['sub-add', pathToLoad, 'auto'] });
client.send({ command: ['set_property', 'secondary-sub-delay', 0] });
const addedTrackId = await findAddedSubtitleTrackId(client, pathToLoad);
if (addedTrackId === null) {
throw new Error('Synchronized subtitle did not appear in the mpv track list');
}
client.send({ command: ['set_property', 'secondary-sid', addedTrackId] });
return;
}
client.send({ command: ['sub-add', pathToLoad] });
client.send({ command: ['set_property', 'sub-delay', 0] });
}
async function subsyncToReference(
engine: 'alass' | 'ffsubsync',
referenceFilePath: string,
targetTrack: MpvTrack,
context: SubsyncContext,
resolved: SubsyncResolvedConfig,
client: MpvClientLike,
slot: SubtitleSlot,
): Promise<SubsyncResult> {
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
const primaryExtraction = await extractSubtitleTrackToFile(
const targetExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
context.primaryTrack,
targetTrack,
);
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
const replaceTarget = resolved.replace !== false && !targetExtraction.temporary;
const outputPath = buildRetimedPath(targetExtraction.path, replaceTarget);
try {
let result: CommandResult;
if (engine === 'alass') {
const alassPath = ensureExecutablePath(resolved.alassPath, 'alass');
result = await runAlassSync(alassPath, referenceFilePath, primaryExtraction.path, outputPath);
result = await runAlassSync(alassPath, referenceFilePath, targetExtraction.path, outputPath);
} else {
const ffsubsyncPath = ensureExecutablePath(resolved.ffsubsyncPath, 'ffsubsync');
result = await runFfsubsyncSync(
ffsubsyncPath,
context.videoPath,
primaryExtraction.path,
targetExtraction.path,
outputPath,
context.audioStreamIndex,
);
@@ -319,13 +400,13 @@ async function subsyncToReference(
};
}
loadSyncedSubtitle(client, outputPath);
await loadSyncedSubtitle(client, outputPath, slot);
return {
ok: true,
message: `Subtitle synchronized with ${engine}`,
};
} finally {
cleanupTemporaryFile(primaryExtraction);
cleanupTemporaryFile(targetExtraction);
}
}
@@ -337,6 +418,25 @@ function validateFfsubsyncReference(videoPath: string): void {
}
}
function resolveTargetTrack(
request: SubsyncManualRunRequest,
context: SubsyncContext,
): MpvTrack | null {
if (request.targetTrackId === undefined || request.targetTrackId === null) {
return context.primaryTrack;
}
return getTrackById(context.subtitleTracks, request.targetTrackId);
}
// Retiming the secondary track must not steal the primary slot: the synced file
// goes back where the out-of-sync one was.
function resolveTargetSlot(targetTrack: MpvTrack, context: SubsyncContext): SubtitleSlot {
if (typeof targetTrack.id !== 'number') return 'primary';
if (targetTrack.id === context.primaryTrack.id) return 'primary';
if (context.secondaryTrack && targetTrack.id === context.secondaryTrack.id) return 'secondary';
return 'primary';
}
export async function runSubsyncManual(
request: SubsyncManualRunRequest,
deps: SubsyncCoreDeps,
@@ -345,6 +445,12 @@ export async function runSubsyncManual(
const context = await gatherSubsyncContext(client);
const resolved = deps.getResolvedConfig();
const targetTrack = resolveTargetTrack(request, context);
if (!targetTrack) {
return { ok: false, message: 'Select the out-of-sync subtitle track to retime' };
}
const targetSlot = resolveTargetSlot(targetTrack, context);
if (request.engine === 'ffsubsync') {
try {
validateFfsubsyncReference(context.videoPath);
@@ -354,22 +460,64 @@ export async function runSubsyncManual(
message: `ffsubsync synchronization failed: ${(error as Error).message}`,
};
}
return subsyncToReference('ffsubsync', context.videoPath, context, resolved, client);
return subsyncToReference(
'ffsubsync',
context.videoPath,
targetTrack,
context,
resolved,
client,
targetSlot,
);
}
const sourceTrack = getTrackById(context.sourceTracks, request.sourceTrackId ?? null);
if (!sourceTrack) {
return { ok: false, message: 'Select a subtitle source track for alass' };
if (request.referenceMode === 'video') {
if (isRemoteMediaPath(context.videoPath)) {
return {
ok: false,
message:
'alass cannot use a stream URL as reference. Pick a reference subtitle track instead.',
};
}
return subsyncToReference(
'alass',
context.videoPath,
targetTrack,
context,
resolved,
client,
targetSlot,
);
}
const referenceTrack = getTrackById(context.subtitleTracks, request.referenceTrackId ?? null);
if (!referenceTrack) {
return { ok: false, message: 'Select a reference subtitle track for alass' };
}
if (referenceTrack.id === targetTrack.id) {
return { ok: false, message: 'Reference and out-of-sync subtitles must be different tracks' };
}
const ffmpegPath = ensureExecutablePath(resolved.ffmpegPath, 'ffmpeg');
let sourceExtraction: FileExtractionResult | null = null;
let referenceExtraction: FileExtractionResult | null = null;
try {
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
referenceExtraction = await extractSubtitleTrackToFile(
ffmpegPath,
context.videoPath,
referenceTrack,
);
return await subsyncToReference(
'alass',
referenceExtraction.path,
targetTrack,
context,
resolved,
client,
targetSlot,
);
} finally {
if (sourceExtraction) {
cleanupTemporaryFile(sourceExtraction);
if (referenceExtraction) {
cleanupTemporaryFile(referenceExtraction);
}
}
}
@@ -377,14 +525,27 @@ export async function runSubsyncManual(
export async function openSubsyncManualPicker(deps: TriggerSubsyncFromConfigDeps): Promise<void> {
const client = getMpvClientForSubsync(deps);
const context = await gatherSubsyncContext(client);
const subtitleTracks = context.subtitleTracks
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
label: formatTrackLabel(track),
}));
const primaryTrackId =
typeof context.primaryTrack.id === 'number' ? context.primaryTrack.id : null;
const secondaryTrackId =
typeof context.secondaryTrack?.id === 'number' ? context.secondaryTrack.id : null;
const payload: SubsyncManualPayload = {
subtitleTracks,
// The secondary track can be filtered or deduped out of the emitted list,
// so only default to it when the picker actually offers it.
defaultReferenceTrackId:
subtitleTracks.find((track) => track.id === secondaryTrackId)?.id ??
subtitleTracks.find((track) => track.id !== primaryTrackId)?.id ??
null,
defaultTargetTrackId: primaryTrackId,
videoReferenceAvailable: !isRemoteMediaPath(context.videoPath),
ffsubsyncAvailable: !isRemoteMediaPath(context.videoPath),
sourceTracks: context.sourceTracks
.filter((track) => typeof track.id === 'number')
.map((track) => ({
id: track.id as number,
label: formatTrackLabel(track),
})),
};
deps.openManualPicker(payload);
}
@@ -397,7 +558,7 @@ export async function triggerSubsyncFromConfig(deps: TriggerSubsyncFromConfigDep
try {
await openSubsyncManualPicker(deps);
deps.showMpvOsd('Subsync: choose engine and source');
deps.showMpvOsd('Subsync: choose engine and subtitles');
} catch (error) {
deps.showMpvOsd(`Subsync failed: ${(error as Error).message}`);
} finally {
+14 -2
View File
@@ -326,7 +326,13 @@ test('handleOverlayModalClosed hides modal window only after all pending modals
});
runtime.sendToActiveOverlayWindow(
'subsync:open-manual',
{ ffsubsyncAvailable: true, sourceTracks: [] },
{
ffsubsyncAvailable: true,
videoReferenceAvailable: true,
subtitleTracks: [],
defaultReferenceTrackId: null,
defaultTargetTrackId: null,
},
{
restoreOnModalClose: 'subsync',
},
@@ -560,7 +566,13 @@ test('modal runtime notifies callers when modal input state becomes active/inact
});
runtime.sendToActiveOverlayWindow(
'subsync:open-manual',
{ ffsubsyncAvailable: true, sourceTracks: [] },
{
ffsubsyncAvailable: true,
videoReferenceAvailable: true,
subtitleTracks: [],
defaultReferenceTrackId: null,
defaultTargetTrackId: null,
},
{
restoreOnModalClose: 'subsync',
},
@@ -4,7 +4,7 @@ import { composeIpcRuntimeHandlers } from './ipc-runtime-composer';
test('composeIpcRuntimeHandlers returns callable IPC handlers and registration bridge', async () => {
let registered = false;
let receivedSourceTrackId: number | null | undefined;
let receivedReferenceTrackId: number | null | undefined;
const composed = composeIpcRuntimeHandlers({
mpvCommandMainDeps: {
@@ -25,7 +25,7 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
},
handleMpvCommandFromIpcRuntime: () => {},
runSubsyncManualFromIpc: async (request) => {
receivedSourceTrackId = request.sourceTrackId;
receivedReferenceTrackId = request.referenceTrackId;
return {
ok: true,
message: 'ok',
@@ -124,10 +124,10 @@ test('composeIpcRuntimeHandlers returns callable IPC handlers and registration b
const result = await composed.runSubsyncManualFromIpc({
engine: 'alass',
sourceTrackId: 7,
referenceTrackId: 7,
});
assert.deepEqual(result, { ok: true, message: 'ok' });
assert.equal(receivedSourceTrackId, 7);
assert.equal(receivedReferenceTrackId, 7);
composed.registerIpcRuntimeHandlers();
assert.equal(registered, true);
@@ -11,7 +11,7 @@ import { createOverlayNotificationDelivery } from './overlay-notification-delive
test('notifyConfiguredStatus routes both to overlay and system without osd', () => {
const calls: string[] = [];
notifyConfiguredStatus('Subsync: choose engine and source', {
notifyConfiguredStatus('Subsync: choose engine and subtitles', {
getNotificationType: () => 'both',
showOsd: (message) => {
calls.push(`osd:${message}`);
@@ -25,8 +25,8 @@ test('notifyConfiguredStatus routes both to overlay and system without osd', ()
});
assert.deepEqual(calls, [
'overlay::SubMiner:Subsync: choose engine and source:info:auto',
'desktop:SubMiner:Subsync: choose engine and source',
'overlay::SubMiner:Subsync: choose engine and subtitles:info:auto',
'desktop:SubMiner:Subsync: choose engine and subtitles',
]);
});
+7 -1
View File
@@ -5,7 +5,13 @@ import type { SubsyncManualPayload } from '../../types';
const payload: SubsyncManualPayload = {
ffsubsyncAvailable: true,
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
videoReferenceAvailable: true,
subtitleTracks: [
{ id: 1, label: 'Internal #1 - jpn (active)' },
{ id: 2, label: 'External #2 - eng' },
],
defaultReferenceTrackId: 2,
defaultTargetTrackId: 1,
};
test('subsync manual open prefers dedicated modal window on first attempt', async () => {
+7 -3
View File
@@ -359,9 +359,13 @@
ffsubsync
</label>
</div>
<label id="subsyncSourceLabel" class="subsync-field">
<span>Source Subtitle (for alass)</span>
<select id="subsyncSourceSelect"></select>
<label id="subsyncReferenceLabel" class="subsync-field">
<span>Reference (correct timing, for alass)</span>
<select id="subsyncReferenceSelect"></select>
</label>
<label id="subsyncTargetLabel" class="subsync-field">
<span>Out-of-sync Subtitle (gets retimed)</span>
<select id="subsyncTargetSelect"></select>
</label>
</div>
<div id="subsyncStatus" class="runtime-options-status"></div>
+184 -33
View File
@@ -2,6 +2,7 @@ import test from 'node:test';
import assert from 'node:assert/strict';
import { createSubsyncModal } from './subsync.js';
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
type Listener = () => void;
@@ -52,18 +53,54 @@ function createDeferred<T>() {
return { promise, resolve };
}
function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; message: string }>) {
function createSelectStub() {
const options: Array<{ value: string; textContent: string }> = [];
const events = createEventTarget();
let innerHTML = '';
let value = '';
return {
options,
disabled: false,
addEventListener: events.addEventListener,
dispatch: events.dispatch,
get innerHTML(): string {
return innerHTML;
},
set innerHTML(next: string) {
innerHTML = next;
if (next === '') {
options.length = 0;
value = '';
}
},
get value(): string {
return value;
},
set value(next: string) {
value = next;
},
appendChild(option: { value: string; textContent: string }) {
options.push(option);
if (!value) value = option.value;
return option;
},
};
}
function createTestHarness(
runSubsyncManual: (request: SubsyncManualRunRequest) => Promise<{ ok: boolean; message: string }>,
) {
const overlayClassList = createClassList();
const modalClassList = createClassList();
const statusClassList = createClassList();
const sourceLabelClassList = createClassList();
const referenceLabelClassList = createClassList();
const targetLabelClassList = createClassList();
const runButtonEvents = createEventTarget();
const closeButtonEvents = createEventTarget();
const engineAlassEvents = createEventTarget();
const engineFfsubsyncEvents = createEventTarget();
const sourceOptions: Array<{ value: string; textContent: string }> = [];
const runButton = {
disabled: false,
addEventListener: runButtonEvents.addEventListener,
@@ -77,6 +114,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
const subsyncEngineAlass = {
checked: false,
disabled: false,
addEventListener: engineAlassEvents.addEventListener,
dispatch: engineAlassEvents.dispatch,
};
@@ -88,18 +126,8 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
dispatch: engineFfsubsyncEvents.dispatch,
};
const sourceSelect = {
innerHTML: '',
value: '',
disabled: false,
appendChild: (option: { value: string; textContent: string }) => {
sourceOptions.push(option);
if (!sourceSelect.value) {
sourceSelect.value = option.value;
}
return option;
},
};
const referenceSelect = createSelectStub();
const targetSelect = createSelectStub();
let notifyClosedCalls = 0;
let notifyOpenedCalls = 0;
@@ -139,8 +167,10 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
subsyncCloseButton: closeButton,
subsyncEngineAlass,
subsyncEngineFfsubsync,
subsyncSourceLabel: { classList: sourceLabelClassList },
subsyncSourceSelect: sourceSelect,
subsyncReferenceLabel: { classList: referenceLabelClassList },
subsyncReferenceSelect: referenceSelect,
subsyncTargetLabel: { classList: targetLabelClassList },
subsyncTargetSelect: targetSelect,
subsyncRunButton: runButton,
subsyncStatus: {
textContent: '',
@@ -149,7 +179,7 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
},
state: {
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubtitleTracks: [],
subsyncSubmitting: false,
isOverSubtitle: false,
},
@@ -166,6 +196,9 @@ function createTestHarness(runSubsyncManual: () => Promise<{ ok: boolean; messag
ctx,
modal,
runButton,
referenceSelect,
targetSelect,
referenceLabelClassList,
statusClassList,
getNotifyClosedCalls: () => notifyClosedCalls,
getNotifyOpenedCalls: () => notifyOpenedCalls,
@@ -187,16 +220,28 @@ async function flushMicrotasks(): Promise<void> {
await Promise.resolve();
}
const BASE_PAYLOAD: SubsyncManualPayload = {
subtitleTracks: [
{ id: 1, label: 'External #1 - jpn (active)' },
{ id: 2, label: 'External #2 - eng' },
],
defaultReferenceTrackId: 2,
defaultTargetTrackId: 1,
videoReferenceAvailable: true,
ffsubsyncAvailable: true,
};
function payloadWith(overrides: Partial<SubsyncManualPayload>): SubsyncManualPayload {
return { ...BASE_PAYLOAD, ...overrides };
}
test('manual subsync failure closes during run, then reopens modal with error', async () => {
const deferred = createDeferred<{ ok: boolean; message: string }>();
const harness = createTestHarness(async () => deferred.promise);
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: true,
});
harness.modal.openSubsyncModal(BASE_PAYLOAD);
harness.runButton.dispatch('click');
await Promise.resolve();
@@ -219,7 +264,8 @@ test('manual subsync failure closes during run, then reopens modal with error',
assert.equal(harness.statusClassList.contains('error'), true);
assert.equal(harness.ctx.dom.subsyncRunButton.disabled, false);
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncSourceSelect.value, '2');
assert.equal(harness.referenceSelect.value, '2');
assert.equal(harness.targetSelect.value, '1');
assert.equal(harness.getNotifyClosedCalls(), 1);
assert.equal(harness.getNotifyOpenedCalls(), 1);
} finally {
@@ -231,15 +277,17 @@ test('subsync modal disables ffsubsync when payload marks it unavailable', () =>
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.openSubsyncModal({
sourceTracks: [{ id: 2, label: 'External #2 - eng' }],
ffsubsyncAvailable: false,
});
harness.modal.openSubsyncModal(
payloadWith({ ffsubsyncAvailable: false, videoReferenceAvailable: false }),
);
assert.equal(harness.ctx.dom.subsyncEngineAlass.checked, true);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.checked, false);
assert.equal(harness.ctx.dom.subsyncEngineFfsubsync.disabled, true);
assert.equal(harness.ctx.dom.subsyncStatus.textContent, 'Choose alass source, then run.');
assert.equal(
harness.ctx.dom.subsyncStatus.textContent,
'Choose the alass reference and out-of-sync subtitle, then run.',
);
} finally {
harness.restoreGlobals();
}
@@ -253,10 +301,14 @@ test('subsync modal ignores enter submission when no sync engine is available',
});
try {
harness.modal.openSubsyncModal({
sourceTracks: [],
ffsubsyncAvailable: false,
});
harness.modal.openSubsyncModal(
payloadWith({
subtitleTracks: [{ id: 1, label: 'External #1 - jpn (active)' }],
defaultReferenceTrackId: null,
videoReferenceAvailable: false,
ffsubsyncAvailable: false,
}),
);
harness.modal.handleSubsyncKeydown({
key: 'Enter',
@@ -270,3 +322,102 @@ test('subsync modal ignores enter submission when no sync engine is available',
harness.restoreGlobals();
}
});
test('subsync modal defaults reference to the secondary track and target to the primary', async () => {
let request: SubsyncManualRunRequest | null = null;
const harness = createTestHarness(async (nextRequest) => {
request = nextRequest;
return { ok: true, message: 'ok' };
});
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
assert.equal(harness.referenceSelect.value, '2');
assert.equal(harness.targetSelect.value, '1');
harness.runButton.dispatch('click');
await flushMicrotasks();
assert.deepEqual(request, {
engine: 'alass',
targetTrackId: 1,
referenceMode: 'track',
referenceTrackId: 2,
});
} finally {
harness.restoreGlobals();
}
});
test('subsync modal offers the video file as an alass reference and excludes the target track', () => {
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
assert.deepEqual(
harness.referenceSelect.options.map((option) => option.value),
['2', 'video'],
);
harness.targetSelect.value = '2';
harness.targetSelect.dispatch('change');
assert.deepEqual(
harness.referenceSelect.options.map((option) => option.value),
['1', 'video'],
);
assert.equal(harness.referenceSelect.value, '1');
} finally {
harness.restoreGlobals();
}
});
test('subsync modal sends the video reference mode when the video file is selected', async () => {
let request: SubsyncManualRunRequest | null = null;
const harness = createTestHarness(async (nextRequest) => {
request = nextRequest;
return { ok: true, message: 'ok' };
});
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
harness.referenceSelect.value = 'video';
harness.runButton.dispatch('click');
await flushMicrotasks();
assert.deepEqual(request, {
engine: 'alass',
targetTrackId: 1,
referenceMode: 'video',
referenceTrackId: null,
});
} finally {
harness.restoreGlobals();
}
});
test('subsync modal hides the reference picker for ffsubsync but keeps the target picker', () => {
const harness = createTestHarness(async () => ({ ok: true, message: 'ok' }));
try {
harness.modal.wireDomEvents();
harness.modal.openSubsyncModal(BASE_PAYLOAD);
assert.equal(harness.referenceLabelClassList.contains('hidden'), false);
harness.ctx.dom.subsyncEngineAlass.checked = false;
harness.ctx.dom.subsyncEngineFfsubsync.checked = true;
harness.ctx.dom.subsyncEngineFfsubsync.dispatch('change');
assert.equal(harness.referenceLabelClassList.contains('hidden'), true);
assert.equal(harness.targetSelect.value, '1');
} finally {
harness.restoreGlobals();
}
});
+151 -67
View File
@@ -1,6 +1,14 @@
import type { SubsyncManualPayload } from '../../types';
import type { SubsyncManualPayload, SubsyncManualRunRequest } from '../../types';
import type { ModalStateReader, RendererContext } from '../context';
const VIDEO_REFERENCE_VALUE = 'video';
interface SubsyncSelection {
engine: 'alass' | 'ffsubsync';
referenceValue: string;
targetTrackId: number | null;
}
export function createSubsyncModal(
ctx: RendererContext,
options: {
@@ -8,27 +16,103 @@ export function createSubsyncModal(
syncSettingsModalSubtitleSuppression: () => void;
},
) {
let ffsubsyncAvailable = true;
let currentPayload: SubsyncManualPayload | null = null;
function setSubsyncStatus(message: string, isError = false): void {
ctx.dom.subsyncStatus.textContent = message;
ctx.dom.subsyncStatus.classList.toggle('error', isError);
}
function updateSubsyncSourceVisibility(): void {
const useAlass = ctx.dom.subsyncEngineAlass.checked;
ctx.dom.subsyncSourceLabel.classList.toggle('hidden', !useAlass);
function hasAlassReference(): boolean {
if (!currentPayload) return false;
return currentPayload.videoReferenceAvailable || currentPayload.subtitleTracks.length > 1;
}
function renderSubsyncSourceTracks(): void {
ctx.dom.subsyncSourceSelect.innerHTML = '';
for (const track of ctx.state.subsyncSourceTracks) {
const option = document.createElement('option');
option.value = String(track.id);
option.textContent = track.label;
ctx.dom.subsyncSourceSelect.appendChild(option);
function updateSubsyncFieldVisibility(): void {
const useAlass = ctx.dom.subsyncEngineAlass.checked;
ctx.dom.subsyncReferenceLabel.classList.toggle('hidden', !useAlass);
ctx.dom.subsyncTargetLabel.classList.toggle(
'hidden',
ctx.state.subsyncSubtitleTracks.length === 0,
);
}
function appendOption(select: HTMLSelectElement, value: string, label: string): void {
const option = document.createElement('option');
option.value = value;
option.textContent = label;
select.appendChild(option);
}
function getSelectedTargetTrackId(): number | null {
const raw = Number.parseInt(ctx.dom.subsyncTargetSelect.value, 10);
return Number.isFinite(raw) ? raw : null;
}
function renderTargetTracks(preferredTrackId: number | null): void {
const select = ctx.dom.subsyncTargetSelect;
select.innerHTML = '';
select.value = '';
for (const track of ctx.state.subsyncSubtitleTracks) {
appendOption(select, String(track.id), track.label);
}
ctx.dom.subsyncSourceSelect.disabled = ctx.state.subsyncSourceTracks.length === 0;
select.disabled = ctx.state.subsyncSubtitleTracks.length === 0;
const preferred = ctx.state.subsyncSubtitleTracks.find(
(track) => track.id === preferredTrackId,
);
const fallback = ctx.state.subsyncSubtitleTracks[0];
const selected = preferred ?? fallback;
if (selected) {
select.value = String(selected.id);
}
}
function renderReferenceTracks(preferredValue: string | null): void {
const select = ctx.dom.subsyncReferenceSelect;
const targetTrackId = getSelectedTargetTrackId();
const values: string[] = [];
select.innerHTML = '';
select.value = '';
for (const track of ctx.state.subsyncSubtitleTracks) {
if (track.id === targetTrackId) continue;
appendOption(select, String(track.id), track.label);
values.push(String(track.id));
}
if (currentPayload?.videoReferenceAvailable) {
appendOption(select, VIDEO_REFERENCE_VALUE, 'Video file (audio reference)');
values.push(VIDEO_REFERENCE_VALUE);
}
select.disabled = values.length === 0;
const preferred = preferredValue && values.includes(preferredValue) ? preferredValue : null;
const defaultTrackValue =
currentPayload?.defaultReferenceTrackId !== null &&
currentPayload?.defaultReferenceTrackId !== undefined
? String(currentPayload.defaultReferenceTrackId)
: null;
const fallback =
defaultTrackValue && values.includes(defaultTrackValue) ? defaultTrackValue : values[0];
const selected = preferred ?? fallback;
if (selected) {
select.value = selected;
}
}
function describeSubsyncState(): string {
if (!currentPayload) return '';
const alassReady = hasAlassReference();
if (alassReady && currentPayload.ffsubsyncAvailable) {
return 'Choose engine, reference and out-of-sync subtitle, then run.';
}
if (alassReady) {
return 'Choose the alass reference and out-of-sync subtitle, then run.';
}
if (currentPayload.ffsubsyncAvailable) {
return 'No reference available for alass. Use ffsubsync.';
}
return 'No sync engine available for current media.';
}
function closeSubsyncModal(): void {
@@ -46,30 +130,27 @@ export function createSubsyncModal(
}
}
function openSubsyncModal(payload: SubsyncManualPayload): void {
function openSubsyncModal(payload: SubsyncManualPayload, selection?: SubsyncSelection): void {
ctx.state.subsyncSubmitting = false;
ctx.state.subsyncSourceTracks = payload.sourceTracks;
ffsubsyncAvailable = payload.ffsubsyncAvailable;
ctx.state.subsyncSubtitleTracks = payload.subtitleTracks;
currentPayload = payload;
const hasSources = ctx.state.subsyncSourceTracks.length > 0;
ctx.dom.subsyncEngineAlass.checked = hasSources;
ctx.dom.subsyncEngineFfsubsync.checked = !hasSources && ffsubsyncAvailable;
ctx.dom.subsyncEngineFfsubsync.disabled = !ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !hasSources && !ffsubsyncAvailable;
const alassReady = hasAlassReference();
const useAlass = selection ? selection.engine === 'alass' && alassReady : alassReady;
ctx.dom.subsyncEngineAlass.checked = useAlass;
ctx.dom.subsyncEngineFfsubsync.checked = !useAlass && payload.ffsubsyncAvailable;
ctx.dom.subsyncEngineAlass.disabled = !alassReady;
ctx.dom.subsyncEngineFfsubsync.disabled = !payload.ffsubsyncAvailable;
ctx.dom.subsyncRunButton.disabled = !alassReady && !payload.ffsubsyncAvailable;
renderSubsyncSourceTracks();
updateSubsyncSourceVisibility();
setSubsyncStatus(
!ffsubsyncAvailable && hasSources
? 'Choose alass source, then run.'
: !ffsubsyncAvailable
? 'No source subtitles available for alass.'
: hasSources
? 'Choose engine and source, then run.'
: 'No source subtitles available for alass. Use ffsubsync.',
false,
renderTargetTracks(
selection
? (selection.targetTrackId ?? payload.defaultTargetTrackId)
: payload.defaultTargetTrackId,
);
renderReferenceTracks(selection?.referenceValue ?? null);
updateSubsyncFieldVisibility();
setSubsyncStatus(describeSubsyncState(), false);
ctx.state.subsyncModalOpen = true;
options.syncSettingsModalSubtitleSuppression();
@@ -80,25 +161,11 @@ export function createSubsyncModal(
}
function reopenSubsyncModalWithError(
sourceTracks: SubsyncManualPayload['sourceTracks'],
engine: 'alass' | 'ffsubsync',
sourceTrackId: number | null,
payload: SubsyncManualPayload,
selection: SubsyncSelection,
message: string,
): void {
openSubsyncModal({ sourceTracks, ffsubsyncAvailable });
if (engine === 'alass' && sourceTracks.length > 0) {
ctx.dom.subsyncEngineAlass.checked = true;
ctx.dom.subsyncEngineFfsubsync.checked = false;
if (Number.isFinite(sourceTrackId)) {
ctx.dom.subsyncSourceSelect.value = String(sourceTrackId);
}
} else if (ffsubsyncAvailable) {
ctx.dom.subsyncEngineAlass.checked = false;
ctx.dom.subsyncEngineFfsubsync.checked = true;
}
updateSubsyncSourceVisibility();
openSubsyncModal(payload, selection);
setSubsyncStatus(message, true);
window.electronAPI.notifyOverlayModalOpened('subsync');
}
@@ -106,6 +173,7 @@ export function createSubsyncModal(
async function runSubsyncManualFromModal(): Promise<void> {
if (ctx.state.subsyncSubmitting) return;
if (ctx.dom.subsyncRunButton.disabled) return;
if (!currentPayload) return;
const useAlass = ctx.dom.subsyncEngineAlass.checked;
const useFfsubsync = ctx.dom.subsyncEngineFfsubsync.checked;
@@ -115,33 +183,46 @@ export function createSubsyncModal(
}
const engine = useAlass ? 'alass' : 'ffsubsync';
const sourceTrackId =
engine === 'alass' && ctx.dom.subsyncSourceSelect.value
? Number.parseInt(ctx.dom.subsyncSourceSelect.value, 10)
: null;
const referenceValue = ctx.dom.subsyncReferenceSelect.value;
const targetTrackId = getSelectedTargetTrackId();
if (engine === 'alass' && !Number.isFinite(sourceTrackId)) {
setSubsyncStatus('Select a source subtitle track for alass.', true);
if (targetTrackId === null) {
setSubsyncStatus('Select the out-of-sync subtitle track to retime.', true);
return;
}
if (engine === 'alass' && !referenceValue) {
setSubsyncStatus('Select a reference for alass.', true);
return;
}
const sourceTracksSnapshot = ctx.state.subsyncSourceTracks.map((track) => ({ ...track }));
const useVideoReference = referenceValue === VIDEO_REFERENCE_VALUE;
const request: SubsyncManualRunRequest = {
engine,
targetTrackId,
};
if (engine === 'alass') {
request.referenceMode = useVideoReference ? 'video' : 'track';
request.referenceTrackId = useVideoReference ? null : Number.parseInt(referenceValue, 10);
}
const payloadSnapshot: SubsyncManualPayload = {
...currentPayload,
subtitleTracks: currentPayload.subtitleTracks.map((track) => ({ ...track })),
};
const selection: SubsyncSelection = { engine, referenceValue, targetTrackId };
ctx.state.subsyncSubmitting = true;
ctx.dom.subsyncRunButton.disabled = true;
closeSubsyncModal();
try {
const result = await window.electronAPI.runSubsyncManual({
engine,
sourceTrackId,
});
const result = await window.electronAPI.runSubsyncManual(request);
if (result.ok) return;
reopenSubsyncModalWithError(sourceTracksSnapshot, engine, sourceTrackId, result.message);
reopenSubsyncModalWithError(payloadSnapshot, selection, result.message);
} catch (error) {
reopenSubsyncModalWithError(
sourceTracksSnapshot,
engine,
sourceTrackId,
payloadSnapshot,
selection,
`Subsync failed: ${(error as Error).message}`,
);
} finally {
@@ -171,10 +252,13 @@ export function createSubsyncModal(
closeSubsyncModal();
});
ctx.dom.subsyncEngineAlass.addEventListener('change', () => {
updateSubsyncSourceVisibility();
updateSubsyncFieldVisibility();
});
ctx.dom.subsyncEngineFfsubsync.addEventListener('change', () => {
updateSubsyncSourceVisibility();
updateSubsyncFieldVisibility();
});
ctx.dom.subsyncTargetSelect.addEventListener('change', () => {
renderReferenceTracks(ctx.dom.subsyncReferenceSelect.value || null);
});
ctx.dom.subsyncRunButton.addEventListener('click', () => {
void runSubsyncManualFromModal();
+3 -3
View File
@@ -18,7 +18,7 @@ import type {
SubtitlePosition,
SubtitleSidebarSnapshotConfig,
SubtitleCue,
SubsyncSourceTrack,
SubsyncSubtitleTrack,
YoutubePickerOpenPayload,
} from '../types';
@@ -85,7 +85,7 @@ export type RendererState = {
characterDictionaryStatus: string;
subsyncModalOpen: boolean;
subsyncSourceTracks: SubsyncSourceTrack[];
subsyncSubtitleTracks: SubsyncSubtitleTrack[];
subsyncSubmitting: boolean;
controllerSelectModalOpen: boolean;
@@ -213,7 +213,7 @@ export function createRendererState(): RendererState {
characterDictionaryStatus: '',
subsyncModalOpen: false,
subsyncSourceTracks: [],
subsyncSubtitleTracks: [],
subsyncSubmitting: false,
controllerSelectModalOpen: false,
+8 -4
View File
@@ -90,8 +90,10 @@ export type RendererDom = {
subsyncCloseButton: HTMLButtonElement;
subsyncEngineAlass: HTMLInputElement;
subsyncEngineFfsubsync: HTMLInputElement;
subsyncSourceLabel: HTMLLabelElement;
subsyncSourceSelect: HTMLSelectElement;
subsyncReferenceLabel: HTMLLabelElement;
subsyncReferenceSelect: HTMLSelectElement;
subsyncTargetLabel: HTMLLabelElement;
subsyncTargetSelect: HTMLSelectElement;
subsyncRunButton: HTMLButtonElement;
subsyncStatus: HTMLDivElement;
@@ -255,8 +257,10 @@ export function resolveRendererDom(): RendererDom {
subsyncCloseButton: getRequiredElement<HTMLButtonElement>('subsyncClose'),
subsyncEngineAlass: getRequiredElement<HTMLInputElement>('subsyncEngineAlass'),
subsyncEngineFfsubsync: getRequiredElement<HTMLInputElement>('subsyncEngineFfsubsync'),
subsyncSourceLabel: getRequiredElement<HTMLLabelElement>('subsyncSourceLabel'),
subsyncSourceSelect: getRequiredElement<HTMLSelectElement>('subsyncSourceSelect'),
subsyncReferenceLabel: getRequiredElement<HTMLLabelElement>('subsyncReferenceLabel'),
subsyncReferenceSelect: getRequiredElement<HTMLSelectElement>('subsyncReferenceSelect'),
subsyncTargetLabel: getRequiredElement<HTMLLabelElement>('subsyncTargetLabel'),
subsyncTargetSelect: getRequiredElement<HTMLSelectElement>('subsyncTargetSelect'),
subsyncRunButton: getRequiredElement<HTMLButtonElement>('subsyncRun'),
subsyncStatus: getRequiredElement<HTMLDivElement>('subsyncStatus'),
+16 -3
View File
@@ -295,14 +295,27 @@ export function parseControllerConfigUpdate(value: unknown): ControllerConfigUpd
export function parseSubsyncManualRunRequest(value: unknown): SubsyncManualRunRequest | null {
if (!isObject(value)) return null;
const { engine, sourceTrackId } = value;
const { engine, referenceMode, referenceTrackId, targetTrackId } = value;
if (engine !== 'alass' && engine !== 'ffsubsync') return null;
if (sourceTrackId !== undefined && sourceTrackId !== null && !isInteger(sourceTrackId)) {
if (referenceMode !== undefined && referenceMode !== 'track' && referenceMode !== 'video') {
return null;
}
const parseOptionalTrackId = (raw: unknown): number | null | undefined | false => {
if (raw === undefined) return undefined;
if (raw === null) return null;
return isInteger(raw) ? raw : false;
};
const parsedReferenceTrackId = parseOptionalTrackId(referenceTrackId);
const parsedTargetTrackId = parseOptionalTrackId(targetTrackId);
if (parsedReferenceTrackId === false || parsedTargetTrackId === false) return null;
return {
engine,
sourceTrackId: sourceTrackId === undefined ? undefined : (sourceTrackId as number | null),
referenceMode,
referenceTrackId: parsedReferenceTrackId,
targetTrackId: parsedTargetTrackId,
};
}
+2 -1
View File
@@ -33,7 +33,8 @@ export interface SubsyncContext {
videoPath: string;
primaryTrack: MpvTrack;
secondaryTrack: MpvTrack | null;
sourceTracks: MpvTrack[];
/** Every usable subtitle track, including the primary one. */
subtitleTracks: MpvTrack[];
audioStreamIndex: number | null;
}
+12 -3
View File
@@ -78,19 +78,28 @@ export interface MpvClient {
send(command: { command: unknown[]; request_id?: number }): boolean;
}
export interface SubsyncSourceTrack {
export interface SubsyncSubtitleTrack {
id: number;
label: string;
}
export interface SubsyncManualPayload {
sourceTracks: SubsyncSourceTrack[];
subtitleTracks: SubsyncSubtitleTrack[];
defaultReferenceTrackId: number | null;
defaultTargetTrackId: number | null;
videoReferenceAvailable: boolean;
ffsubsyncAvailable: boolean;
}
export type SubsyncReferenceMode = 'track' | 'video';
export interface SubsyncManualRunRequest {
engine: 'alass' | 'ffsubsync';
sourceTrackId?: number | null;
/** alass reference source: another subtitle track, or the loaded media file itself. */
referenceMode?: SubsyncReferenceMode;
referenceTrackId?: number | null;
/** Subtitle track to retime. Defaults to the active primary track. */
targetTrackId?: number | null;
}
export interface SubsyncResult {