mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-03 06:22:41 -08:00
feat(subsync): add replace option and deterministic retimed naming
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user