feat(subsync): add replace option and deterministic retimed naming

This commit is contained in:
2026-03-03 00:26:31 -08:00
parent 6c80bd5843
commit 10ef535f9a
11 changed files with 171 additions and 7 deletions

View File

@@ -88,6 +88,7 @@
"alass_path": "", // Alass path setting. "alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting. "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting. "ffmpeg_path": "", // Ffmpeg path setting.
"replace": true, // Replace active subtitle file when synchronization succeeds.
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================

View File

@@ -771,7 +771,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
"defaultMode": "auto", "defaultMode": "auto",
"alass_path": "", "alass_path": "",
"ffsubsync_path": "", "ffsubsync_path": "",
"ffmpeg_path": "" "ffmpeg_path": "",
"replace": true
} }
} }
``` ```
@@ -782,6 +783,7 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
| `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. | | `alass_path` | string path | Path to `alass` executable. Empty or `null` falls back to `/usr/bin/alass`. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. | | `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` falls back to `/usr/bin/ffsubsync`. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. | | `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
Customize it there, or set it to `null` to disable. Customize it there, or set it to `null` to disable.

View File

@@ -88,6 +88,7 @@
"alass_path": "", // Alass path setting. "alass_path": "", // Alass path setting.
"ffsubsync_path": "", // Ffsubsync path setting. "ffsubsync_path": "", // Ffsubsync path setting.
"ffmpeg_path": "", // Ffmpeg path setting. "ffmpeg_path": "", // Ffmpeg path setting.
"replace": true, // Replace active subtitle file when synchronization succeeds.
}, // Subsync engine and executable paths. }, // Subsync engine and executable paths.
// ========================================== // ==========================================

View File

@@ -50,6 +50,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
alass_path: '', alass_path: '',
ffsubsync_path: '', ffsubsync_path: '',
ffmpeg_path: '', ffmpeg_path: '',
replace: true,
}, },
startupWarmups: { startupWarmups: {
lowPowerMode: false, lowPowerMode: false,

View File

@@ -32,6 +32,12 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.subsync.defaultMode, defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.', description: 'Subsync default mode.',
}, },
{
path: 'subsync.replace',
kind: 'boolean',
defaultValue: defaultConfig.subsync.replace,
description: 'Replace the active subtitle file when sync completes.',
},
{ {
path: 'startupWarmups.lowPowerMode', path: 'startupWarmups.lowPowerMode',
kind: 'boolean', kind: 'boolean',

View File

@@ -173,6 +173,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync; if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
const ffmpeg = asString(src.subsync.ffmpeg_path); const ffmpeg = asString(src.subsync.ffmpeg_path);
if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg; if (ffmpeg !== undefined) resolved.subsync.ffmpeg_path = ffmpeg;
const replace = asBoolean(src.subsync.replace);
if (replace !== undefined) {
resolved.subsync.replace = replace;
} else if (src.subsync.replace !== undefined) {
warn('subsync.replace', src.subsync.replace, resolved.subsync.replace, 'Expected boolean.');
}
} }
if (isObject(src.subtitlePosition)) { if (isObject(src.subtitlePosition)) {

View File

@@ -209,10 +209,73 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
assert.ok(ffArgs.includes(primaryPath)); assert.ok(ffArgs.includes(primaryPath));
assert.ok(ffArgs.includes('--reference-stream')); assert.ok(ffArgs.includes('--reference-stream'));
assert.ok(ffArgs.includes('0:2')); assert.ok(ffArgs.includes('0:2'));
const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true);
assert.equal(ffArgs[ffOutputFlagIndex + 1], primaryPath);
assert.equal(sentCommands[0]?.[0], 'sub_add'); assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]); assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
}); });
test('runSubsyncManual writes deterministic _retimed filename when replace is false', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-ffsubsync-no-replace-'));
const ffsubsyncLogPath = path.join(tmpDir, 'ffsubsync-args.log');
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
const ffmpegPath = path.join(tmpDir, 'ffmpeg.sh');
const alassPath = path.join(tmpDir, 'alass.sh');
const videoPath = path.join(tmpDir, 'video.mkv');
const primaryPath = path.join(tmpDir, 'episode.ja.srt');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'sub');
writeExecutableScript(ffmpegPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(alassPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffsubsyncPath,
`#!/bin/sh\n: > "${ffsubsyncLogPath}"\nfor arg in "$@"; do printf '%s\\n' "$arg" >> "${ffsubsyncLogPath}"; done\nout=\"\"\nprev=\"\"\nfor arg in \"$@\"; do\n if [ \"$prev\" = \"-o\" ]; then out=\"$arg\"; fi\n prev=\"$arg\"\ndone\nif [ -n \"$out\" ]; then : > \"$out\"; fi\nexit 0\n`,
);
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: () => {},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
replace: false,
}),
});
const result = await runSubsyncManual({ engine: 'ffsubsync', sourceTrackId: null }, deps);
assert.equal(result.ok, true);
const ffArgs = fs.readFileSync(ffsubsyncLogPath, 'utf8').trim().split('\n');
const ffOutputFlagIndex = ffArgs.indexOf('-o');
assert.equal(ffOutputFlagIndex >= 0, true);
const outputPath = ffArgs[ffOutputFlagIndex + 1];
assert.equal(outputPath, path.join(tmpDir, 'episode.ja_retimed.srt'));
});
test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => { test('runSubsyncManual constructs alass command and returns failure on non-zero exit', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
const alassLogPath = path.join(tmpDir, 'alass-args.log'); const alassLogPath = path.join(tmpDir, 'alass-args.log');
@@ -281,6 +344,76 @@ test('runSubsyncManual constructs alass command and returns failure on non-zero
assert.equal(alassArgs[1], primaryPath); assert.equal(alassArgs[1], primaryPath);
}); });
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');
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');
fs.writeFileSync(videoPath, 'video');
fs.writeFileSync(primaryPath, 'sub');
writeExecutableScript(ffsubsyncPath, '#!/bin/sh\nexit 0\n');
writeExecutableScript(
ffmpegPath,
'#!/bin/sh\nout=""\nfor arg in "$@"; do out="$arg"; done\nif [ -n "$out" ]; then : > "$out"; fi\nexit 0\n',
);
writeExecutableScript(
alassPath,
'#!/bin/sh\nsleep 0.2\nif [ ! -f "$1" ]; then echo "missing reference subtitle" >&2; exit 1; fi\nif [ ! -f "$2" ]; then echo "missing input subtitle" >&2; exit 1; fi\n: > "$3"\nexit 0\n',
);
const sentCommands: Array<Array<string | number>> = [];
const deps = makeDeps({
getMpvClient: () => ({
connected: true,
currentAudioStreamIndex: null,
send: (payload) => {
sentCommands.push(payload.command);
},
requestProperty: async (name: string) => {
if (name === 'path') return videoPath;
if (name === 'sid') return 1;
if (name === 'secondary-sid') return null;
if (name === 'track-list') {
return [
{
id: 1,
type: 'sub',
selected: true,
external: true,
'external-filename': primaryPath,
},
{
id: 2,
type: 'sub',
selected: false,
external: false,
'ff-index': 2,
codec: 'ass',
},
];
}
return null;
},
}),
getResolvedConfig: () => ({
defaultMode: 'manual',
alassPath,
ffsubsyncPath,
ffmpegPath,
}),
});
const result = await runSubsyncManual({ engine: 'alass', sourceTrackId: 2 }, deps);
assert.equal(result.ok, true);
assert.equal(result.message, 'Subtitle synchronized with alass');
assert.equal(sentCommands[0]?.[0], 'sub_add');
assert.deepEqual(sentCommands[1], ['set_property', 'sub-delay', 0]);
});
test('runSubsyncManual resolves string sid values from mpv stream properties', async () => { test('runSubsyncManual resolves string sid values from mpv stream properties', async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh'); const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');

View File

@@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
} catch {} } catch {}
} }
function buildRetimedPath(subPath: string): string { function buildRetimedPath(subPath: string, replace: boolean): string {
if (replace) return subPath;
const parsed = path.parse(subPath); const parsed = path.parse(subPath);
const suffix = `_retimed_${Date.now()}`; return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`);
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
} }
async function runAlassSync( async function runAlassSync(
@@ -265,7 +265,8 @@ async function subsyncToReference(
context.videoPath, context.videoPath,
context.primaryTrack, context.primaryTrack,
); );
const outputPath = buildRetimedPath(primaryExtraction.path); const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
try { try {
let result: CommandResult; let result: CommandResult;
@@ -389,7 +390,7 @@ export async function runSubsyncManual(
let sourceExtraction: FileExtractionResult | null = null; let sourceExtraction: FileExtractionResult | null = null;
try { try {
sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack); sourceExtraction = await extractSubtitleTrackToFile(ffmpegPath, context.videoPath, sourceTrack);
return subsyncToReference('alass', sourceExtraction.path, context, resolved, client); return await subsyncToReference('alass', sourceExtraction.path, context, resolved, client);
} finally { } finally {
if (sourceExtraction) { if (sourceExtraction) {
cleanupTemporaryFile(sourceExtraction); cleanupTemporaryFile(sourceExtraction);

View File

@@ -1,6 +1,6 @@
import test from 'node:test'; import test from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { codecToExtension } from './utils'; import { codecToExtension, getSubsyncConfig } from './utils';
test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => { test('codecToExtension maps stream/web formats to ffmpeg extractable extensions', () => {
assert.equal(codecToExtension('subrip'), 'srt'); assert.equal(codecToExtension('subrip'), 'srt');
@@ -12,3 +12,13 @@ test('codecToExtension maps stream/web formats to ffmpeg extractable extensions'
test('codecToExtension returns null for unsupported codecs', () => { test('codecToExtension returns null for unsupported codecs', () => {
assert.equal(codecToExtension('unsupported-codec'), null); assert.equal(codecToExtension('unsupported-codec'), null);
}); });
test('getSubsyncConfig defaults replace to true', () => {
assert.equal(getSubsyncConfig(undefined).replace, true);
assert.equal(getSubsyncConfig({}).replace, true);
});
test('getSubsyncConfig respects explicit replace value', () => {
assert.equal(getSubsyncConfig({ replace: false }).replace, false);
assert.equal(getSubsyncConfig({ replace: true }).replace, true);
});

View File

@@ -20,6 +20,7 @@ export interface SubsyncResolvedConfig {
alassPath: string; alassPath: string;
ffsubsyncPath: string; ffsubsyncPath: string;
ffmpegPath: string; ffmpegPath: string;
replace?: boolean;
} }
const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = { const DEFAULT_SUBSYNC_EXECUTABLE_PATHS = {
@@ -55,6 +56,7 @@ export function getSubsyncConfig(config: SubsyncConfig | undefined): SubsyncReso
alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass), alassPath: resolvePath(config?.alass_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.alass),
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync), ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg), ffmpegPath: resolvePath(config?.ffmpeg_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffmpeg),
replace: config?.replace ?? DEFAULT_CONFIG.subsync.replace,
}; };
} }

View File

@@ -97,6 +97,7 @@ export interface SubsyncConfig {
alass_path?: string; alass_path?: string;
ffsubsync_path?: string; ffsubsync_path?: string;
ffmpeg_path?: string; ffmpeg_path?: string;
replace?: boolean;
} }
export interface StartupWarmupsConfig { export interface StartupWarmupsConfig {