fix: optimize subtitle extraction with mkvextract batch mode, fix copyToWorkDir skip logic, target original library dir
Build / Lint & Test (push) Successful in 13s
Build / Lint & Test (push) Successful in 13s
This commit is contained in:
1 parent
27fee1ee95
commit
1cdd771045
3 files changed
+133
-94
No files matched your search
@@ -58,9 +58,9 @@ const plugin = async (args) => {
|
||||
let ext = '.dts';
|
||||
if (itaCodec.includes('truehd')) ext = '.thd';
|
||||
|
||||
const sourceDir = path.dirname(inputFile);
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||
const outFile = path.join(sourceDir, `${baseName}.ita${ext}`);
|
||||
const targetDir = variables.originalLibraryDir || path.dirname(inputFile);
|
||||
const baseName = path.basename(variables.originalLibraryFile || inputFile, path.extname(variables.originalLibraryFile || inputFile));
|
||||
const outFile = path.join(targetDir, `${baseName}.ita${ext}`);
|
||||
|
||||
jobLog(`[backupSourceAudio] Backing up ITA ${itaCodec} -> ${outFile}\n`);
|
||||
|
||||
|
||||
@@ -8,11 +8,13 @@ const fs = require('fs');
|
||||
/**
|
||||
* extractSubtitlesAndOcr
|
||||
* Extracts subtitle streams for ITA and ENG languages:
|
||||
* - Text-based (subrip, srt, ass, ssa, webvtt): direct FFmpeg extraction
|
||||
* - Bitmap-based (hdmv_pgs_subtitle, dvd_subtitle): FFmpeg .sup extract
|
||||
* then SubtitleEdit CLI OCR -> .srt (with graceful fallback on failure)
|
||||
* - Text-based (subrip, srt, ass, ssa, webvtt):
|
||||
* Fast extraction via mkvextract (all tracks in one pass, seconds instead of minutes),
|
||||
* with single-pass FFmpeg (-vn -an) fallback.
|
||||
* - Bitmap-based (hdmv_pgs_subtitle, dvd_subtitle): .sup extract
|
||||
* then SubtitleEdit CLI OCR -> .srt (with graceful fallback on failure).
|
||||
*
|
||||
* Output files go next to the source file.
|
||||
* Output files go to target directory (original library directory or next to source).
|
||||
* Naming: <baseName>.<lang>[.forced][.default][.sdh].srt
|
||||
*/
|
||||
|
||||
@@ -22,8 +24,8 @@ const BITMAP_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'pgssub', 'd
|
||||
const details = () => ({
|
||||
name: 'Extract Subtitles & OCR Fallback',
|
||||
description:
|
||||
'Extracts ITA/ENG subtitle streams to SRT files next to the source. '
|
||||
+ 'Text subtitles are extracted directly via FFmpeg. '
|
||||
'Extracts ITA/ENG subtitle streams to SRT files in the library directory. '
|
||||
+ 'Fast multi-track extraction via mkvextract / FFmpeg. '
|
||||
+ 'Bitmap subtitles (PGS/VOBSUB) are OCR-d via SubtitleEdit CLI. '
|
||||
+ 'OCR failures are logged as warnings without stopping the pipeline.',
|
||||
style: { borderColor: '#00BCD4' },
|
||||
@@ -73,21 +75,25 @@ const buildSuffix = (lang, stream) => {
|
||||
if (stream.disposition && stream.disposition.forced) parts.push('forced');
|
||||
if (stream.disposition && stream.disposition.default) parts.push('default');
|
||||
if (stream.disposition && stream.disposition.hearing_impaired) parts.push('sdh');
|
||||
// Also check title tag for SDH
|
||||
const title = ((stream.tags && stream.tags.title) || '').toLowerCase();
|
||||
if (!parts.includes('sdh') && title.includes('sdh')) parts.push('sdh');
|
||||
return parts.join('.');
|
||||
};
|
||||
|
||||
/**
|
||||
* Run FFmpeg via Tdarr's CLI helper.
|
||||
* Passes the full args object so cliUtils can access any field it needs
|
||||
* (inputFileObj, updateWorker, variables, etc.) without us having to predict them.
|
||||
*/
|
||||
const runFFmpeg = async (spawnArgs, args) => {
|
||||
const getMkvExtract = (args) => {
|
||||
let mkvExtract = 'mkvextract';
|
||||
if (args.mkvpropeditPath) {
|
||||
const dir = path.dirname(args.mkvpropeditPath);
|
||||
const candidate = path.join(dir, process.platform === 'win32' ? 'mkvextract.exe' : 'mkvextract');
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
}
|
||||
return mkvExtract;
|
||||
};
|
||||
|
||||
const runCLI = async (cliBin, spawnArgs, args) => {
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
const cli = new CLI({
|
||||
cli: args.ffmpegPath,
|
||||
cli: cliBin,
|
||||
spawnArgs,
|
||||
WorkDir: args.workDir,
|
||||
jobLog: args.jobLog,
|
||||
@@ -104,10 +110,13 @@ const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables, workDir } = args;
|
||||
|
||||
const inputFile = inputFileObj._id;
|
||||
const sourceDir = path.dirname(inputFile);
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||
// Always save output SRTs in original library directory if known (e.g. from copyToWorkDir)
|
||||
const targetDir = variables.originalLibraryDir || path.dirname(inputFile);
|
||||
const origFile = variables.originalLibraryFile || inputFile;
|
||||
const baseName = path.basename(origFile, path.extname(origFile));
|
||||
|
||||
jobLog(`[extractSubtitlesAndOcr] Source: ${inputFile}\n`);
|
||||
jobLog(`[extractSubtitlesAndOcr] Input: ${inputFile}\n`);
|
||||
jobLog(`[extractSubtitlesAndOcr] Output Directory: ${targetDir}\n`);
|
||||
|
||||
const subtitleEditPath = args.inputs.subtitleEditPath || 'SubtitleEdit';
|
||||
const targetLangs = (args.inputs.languages || 'ita,eng')
|
||||
@@ -123,6 +132,9 @@ const plugin = async (args) => {
|
||||
|
||||
jobLog(`[extractSubtitlesAndOcr] Found ${subtitleStreams.length} subtitle stream(s).\n`);
|
||||
|
||||
const textTasks = [];
|
||||
const bitmapTasks = [];
|
||||
|
||||
for (const stream of subtitleStreams) {
|
||||
const lang = normLang((stream.tags && stream.tags.language) || '');
|
||||
if (!targetLangs.includes(lang)) {
|
||||
@@ -132,77 +144,106 @@ const plugin = async (args) => {
|
||||
|
||||
const codec = (stream.codec_name || '').toLowerCase();
|
||||
const suffix = buildSuffix(lang, stream);
|
||||
const outSrt = path.join(sourceDir, `${baseName}.${suffix}.srt`);
|
||||
const outSrt = path.join(targetDir, `${baseName}.${suffix}.srt`);
|
||||
|
||||
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} lang=${lang} codec=${codec} -> ${path.basename(outSrt)}\n`);
|
||||
|
||||
// ── Text-based subtitles ──────────────────────────────────────────────
|
||||
if (TEXT_CODECS.has(codec)) {
|
||||
const { cliExitCode } = await runFFmpeg(
|
||||
['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'srt', outSrt],
|
||||
textTasks.push({ stream, lang, codec, outSrt });
|
||||
} else if (BITMAP_CODECS.has(codec)) {
|
||||
bitmapTasks.push({ stream, lang, codec, outSrt });
|
||||
} else {
|
||||
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 1. Batch extract text-based subtitles (mkvextract or single-pass FFmpeg)
|
||||
if (textTasks.length > 0) {
|
||||
jobLog(`[extractSubtitlesAndOcr] Extracting ${textTasks.length} text subtitle track(s)...\n`);
|
||||
textTasks.forEach((t) => {
|
||||
jobLog(` -> Stream #${t.stream.index} (${t.lang}, ${t.codec}) to ${path.basename(t.outSrt)}\n`);
|
||||
});
|
||||
|
||||
let extracted = false;
|
||||
const isMkv = inputFile.toLowerCase().endsWith('.mkv') || (inputFileObj.container === 'mkv');
|
||||
const mkvExtract = getMkvExtract(args);
|
||||
|
||||
// Try mkvextract first (instantaneous on MKV)
|
||||
if (isMkv) {
|
||||
try {
|
||||
const mkvArgs = ['tracks', inputFile];
|
||||
textTasks.forEach((t) => mkvArgs.push(`${t.stream.index}:${t.outSrt}`));
|
||||
const { cliExitCode } = await runCLI(mkvExtract, mkvArgs, args);
|
||||
if (cliExitCode === 0) {
|
||||
extracted = true;
|
||||
jobLog('[extractSubtitlesAndOcr] mkvextract completed successfully.\n');
|
||||
} else {
|
||||
jobLog(`[extractSubtitlesAndOcr] mkvextract exited with code ${cliExitCode}, falling back to FFmpeg.\n`);
|
||||
}
|
||||
} catch (err) {
|
||||
jobLog(`[extractSubtitlesAndOcr] mkvextract error (${err.message}), falling back to FFmpeg.\n`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: single-pass FFmpeg (-vn -an for fast demuxing without video/audio decoding)
|
||||
if (!extracted) {
|
||||
const ffmpegArgs = ['-y', '-i', inputFile, '-vn', '-an'];
|
||||
textTasks.forEach((t, i) => {
|
||||
ffmpegArgs.push('-map', `0:${t.stream.index}`, `-c:s:${i}`, 'srt', t.outSrt);
|
||||
});
|
||||
const { cliExitCode } = await runCLI(args.ffmpegPath, ffmpegArgs, args);
|
||||
if (cliExitCode === 0) {
|
||||
jobLog('[extractSubtitlesAndOcr] FFmpeg text extraction completed successfully.\n');
|
||||
} else {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg extraction failed with exit code ${cliExitCode}.\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 2. Process bitmap subtitles (PGS / VOBSUB) with SubtitleEdit OCR ─────
|
||||
for (const t of bitmapTasks) {
|
||||
const tmpSup = path.join(workDir, `${baseName}_s${t.stream.index}.sup`);
|
||||
jobLog(`[extractSubtitlesAndOcr] Extracting bitmap stream #${t.stream.index} (${t.codec}) for OCR...\n`);
|
||||
|
||||
// Extract to .sup using mkvextract if mkv, else ffmpeg
|
||||
let extractedSup = false;
|
||||
const isMkv = inputFile.toLowerCase().endsWith('.mkv') || (inputFileObj.container === 'mkv');
|
||||
if (isMkv) {
|
||||
const mkvExtract = getMkvExtract(args);
|
||||
const { cliExitCode } = await runCLI(mkvExtract, ['tracks', inputFile, `${t.stream.index}:${tmpSup}`], args);
|
||||
if (cliExitCode === 0) extractedSup = true;
|
||||
}
|
||||
if (!extractedSup) {
|
||||
const { cliExitCode } = await runCLI(
|
||||
args.ffmpegPath,
|
||||
['-y', '-i', inputFile, '-vn', '-an', '-map', `0:${t.stream.index}`, '-c:s', 'copy', tmpSup],
|
||||
args,
|
||||
);
|
||||
if (cliExitCode !== 0) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: FFmpeg failed for stream #${stream.index} (exit ${cliExitCode}).\n`);
|
||||
} else {
|
||||
jobLog(`[extractSubtitlesAndOcr] Extracted: ${path.basename(outSrt)}\n`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Bitmap-based subtitles (PGS / VOBSUB) ────────────────────────────
|
||||
if (BITMAP_CODECS.has(codec)) {
|
||||
const tmpSup = path.join(workDir, `${baseName}_s${stream.index}.sup`);
|
||||
|
||||
// Step 1: Extract to .sup
|
||||
const { cliExitCode: extractExit } = await runFFmpeg(
|
||||
['-y', '-i', inputFile, '-map', `0:${stream.index}`, '-c:s', 'copy', tmpSup],
|
||||
args,
|
||||
);
|
||||
|
||||
if (extractExit !== 0) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${stream.index}.\n`);
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${t.stream.index}.\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 2: OCR with SubtitleEdit
|
||||
try {
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
const seCli = new CLI({
|
||||
cli: subtitleEditPath,
|
||||
spawnArgs: [
|
||||
'/convert', tmpSup, 'subrip',
|
||||
'/track-number:0',
|
||||
`/outputfolder:${sourceDir}`,
|
||||
],
|
||||
WorkDir: workDir,
|
||||
jobLog,
|
||||
inputFileObj,
|
||||
updateWorker: args.updateWorker,
|
||||
args,
|
||||
});
|
||||
const { cliExitCode: seExit } = await seCli.runCli();
|
||||
|
||||
if (seExit !== 0) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seExit}) for stream #${stream.index}.\n`);
|
||||
} else {
|
||||
// SubtitleEdit names the output after the .sup filename
|
||||
const seOut = path.join(sourceDir, path.basename(tmpSup, '.sup') + '.srt');
|
||||
if (fs.existsSync(seOut)) {
|
||||
fs.renameSync(seOut, outSrt);
|
||||
jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(outSrt)}\n`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available or crashed: ${err.message}\n`);
|
||||
} finally {
|
||||
// Clean up temp .sup
|
||||
try { fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ }
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} codec "${codec}" not supported — skipping.\n`);
|
||||
// Run OCR with SubtitleEdit CLI
|
||||
try {
|
||||
const seCli = await runCLI(
|
||||
subtitleEditPath,
|
||||
['/convert', tmpSup, 'subrip', '/track-number:0', `/outputfolder:${targetDir}`],
|
||||
args,
|
||||
);
|
||||
if (seCli.cliExitCode !== 0) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit OCR failed (exit ${seCli.cliExitCode}) for stream #${t.stream.index}.\n`);
|
||||
} else {
|
||||
const seOut = path.join(targetDir, path.basename(tmpSup, '.sup') + '.srt');
|
||||
if (fs.existsSync(seOut)) {
|
||||
fs.renameSync(seOut, t.outSrt);
|
||||
jobLog(`[extractSubtitlesAndOcr] OCR complete: ${path.basename(t.outSrt)}\n`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: SubtitleEdit not available: ${err.message}\n`);
|
||||
} finally {
|
||||
try { if (fs.existsSync(tmpSup)) fs.unlinkSync(tmpSup); } catch (_) { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
jobLog('[extractSubtitlesAndOcr] Done.\n');
|
||||
|
||||
@@ -68,19 +68,17 @@ const plugin = async (args) => {
|
||||
jobLog(`[copyToWorkDir] Source : ${inputFile}\n`);
|
||||
jobLog(`[copyToWorkDir] WorkDir: ${workDir}\n`);
|
||||
|
||||
// ── Optional: skip if source is already local ─────────────────────────────
|
||||
// ── Skip only if file is already located inside workDir ──────────────────
|
||||
const skipIfLocal = (args.inputs.skipIfLocal || 'true') === 'true';
|
||||
if (skipIfLocal) {
|
||||
const srcDrive = inputFile.split(/[/\\]/)[0].toUpperCase(); // e.g. "T:"
|
||||
const workDrive = workDir.split(/[/\\]/)[0].toUpperCase(); // e.g. "T:"
|
||||
const alreadyLocal = (
|
||||
inputFile.startsWith(workDir)
|
||||
|| (srcDrive && workDrive && srcDrive === workDrive)
|
||||
);
|
||||
if (alreadyLocal) {
|
||||
jobLog(`[copyToWorkDir] Source is already local (same drive: ${srcDrive}) — skipping copy.\n`);
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
if (skipIfLocal && inputFile.startsWith(workDir)) {
|
||||
jobLog('[copyToWorkDir] Source file is already inside workDir — skipping copy.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
|
||||
// ── Preserve original library paths for downstream plugins ──────────────
|
||||
if (!variables.originalLibraryFile) {
|
||||
variables.originalLibraryFile = inputFile;
|
||||
variables.originalLibraryDir = path.dirname(inputFile);
|
||||
}
|
||||
|
||||
// ── Copy using streams (no RAM spike for large files) ────────────────────
|
||||
|
||||
Reference in new issue
Block a user