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.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -771,7 +771,8 @@ Sync the active subtitle track using `alass` (preferred) or `ffsubsync`:
|
||||
"defaultMode": "auto",
|
||||
"alass_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`. |
|
||||
| `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`. |
|
||||
| `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`.
|
||||
Customize it there, or set it to `null` to disable.
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||
"replace": true, // Replace active subtitle file when synchronization succeeds.
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -50,6 +50,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
alass_path: '',
|
||||
ffsubsync_path: '',
|
||||
ffmpeg_path: '',
|
||||
replace: true,
|
||||
},
|
||||
startupWarmups: {
|
||||
lowPowerMode: false,
|
||||
|
||||
@@ -32,6 +32,12 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subsync.defaultMode,
|
||||
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',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -173,6 +173,12 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
if (ffsubsync !== undefined) resolved.subsync.ffsubsync_path = ffsubsync;
|
||||
const ffmpeg = asString(src.subsync.ffmpeg_path);
|
||||
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)) {
|
||||
|
||||
@@ -209,10 +209,73 @@ test('runSubsyncManual constructs ffsubsync command and returns success', async
|
||||
assert.ok(ffArgs.includes(primaryPath));
|
||||
assert.ok(ffArgs.includes('--reference-stream'));
|
||||
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.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 () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-alass-'));
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subsync-stream-sid-'));
|
||||
const ffsubsyncPath = path.join(tmpDir, 'ffsubsync.sh');
|
||||
|
||||
@@ -215,10 +215,10 @@ function cleanupTemporaryFile(extraction: FileExtractionResult): void {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function buildRetimedPath(subPath: string): string {
|
||||
function buildRetimedPath(subPath: string, replace: boolean): string {
|
||||
if (replace) return subPath;
|
||||
const parsed = path.parse(subPath);
|
||||
const suffix = `_retimed_${Date.now()}`;
|
||||
return path.join(parsed.dir, `${parsed.name}${suffix}${parsed.ext || '.srt'}`);
|
||||
return path.join(parsed.dir, `${parsed.name}_retimed${parsed.ext || '.srt'}`);
|
||||
}
|
||||
|
||||
async function runAlassSync(
|
||||
@@ -265,7 +265,8 @@ async function subsyncToReference(
|
||||
context.videoPath,
|
||||
context.primaryTrack,
|
||||
);
|
||||
const outputPath = buildRetimedPath(primaryExtraction.path);
|
||||
const replacePrimary = resolved.replace !== false && !primaryExtraction.temporary;
|
||||
const outputPath = buildRetimedPath(primaryExtraction.path, replacePrimary);
|
||||
|
||||
try {
|
||||
let result: CommandResult;
|
||||
@@ -389,7 +390,7 @@ export async function runSubsyncManual(
|
||||
let sourceExtraction: FileExtractionResult | null = null;
|
||||
try {
|
||||
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 {
|
||||
if (sourceExtraction) {
|
||||
cleanupTemporaryFile(sourceExtraction);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from 'node:test';
|
||||
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', () => {
|
||||
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', () => {
|
||||
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;
|
||||
ffsubsyncPath: string;
|
||||
ffmpegPath: string;
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
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),
|
||||
ffsubsyncPath: resolvePath(config?.ffsubsync_path, DEFAULT_SUBSYNC_EXECUTABLE_PATHS.ffsubsync),
|
||||
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;
|
||||
ffsubsync_path?: string;
|
||||
ffmpeg_path?: string;
|
||||
replace?: boolean;
|
||||
}
|
||||
|
||||
export interface StartupWarmupsConfig {
|
||||
|
||||
Reference in New Issue
Block a user