feat: Tdarr Local Flow Plugin Suite v1.0
7 atomic plugins for automated x265 transcoding pipeline: - utility/mediaAnalyzerGOP : GOP calc, FPS detection, film/series/anime routing - utility/setAnimationFlag : manual animation flag override - utility/extractChaptersXml : mkvextract chapters to XML - audio/audioSelectorClamping : codec scoring, clamped Opus bitrates, 2 outputs - audio/backupSourceAudio : lossless DTS/TrueHD ITA backup (-c:a copy) - subtitles/extractSubtitlesAndOcr : SRT extract + SubtitleEdit OCR for PGS - video/transcode2PassX265 : 2-pass x265, Lanczos 1080p bound, Opus mux Test suite: 34 unit tests + 3 integration tests (FFmpeg real files) FlowHelpers mock for local testing included Docs: integration-guide.md with canvas wiring and deploy instructions
This commit is contained in:
commit
c2074be826
19 files changed
+2134
No files matched your search
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* Output files go next to the source file.
|
||||
* Naming: <baseName>.<lang>[.forced][.default][.sdh].srt
|
||||
*/
|
||||
|
||||
const TEXT_CODECS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'mov_text']);
|
||||
const BITMAP_CODECS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'pgssub', 'dvbsub']);
|
||||
|
||||
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. '
|
||||
+ 'Bitmap subtitles (PGS/VOBSUB) are OCR-d via SubtitleEdit CLI. '
|
||||
+ 'OCR failures are logged as warnings without stopping the pipeline.',
|
||||
style: { borderColor: '#00BCD4' },
|
||||
tags: 'subtitles',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: 3,
|
||||
icon: 'faClosedCaptioning',
|
||||
inputs: [
|
||||
{
|
||||
label: 'SubtitleEdit Executable Path',
|
||||
name: 'subtitleEditPath',
|
||||
type: 'string',
|
||||
defaultValue: 'SubtitleEdit',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip:
|
||||
'Name or full path of the SubtitleEdit CLI executable. '
|
||||
+ 'Must be in system PATH or provide absolute path. '
|
||||
+ 'Used for OCR of PGS/VOBSUB streams on Windows workers.',
|
||||
},
|
||||
{
|
||||
label: 'Languages to Extract (CSV)',
|
||||
name: 'languages',
|
||||
type: 'string',
|
||||
defaultValue: 'ita,eng',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Comma-separated list of ISO 639-2 language codes. Example: ita,eng',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue to next plugin (warnings in log if OCR failed)' },
|
||||
],
|
||||
});
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const normLang = (tag) => {
|
||||
const l = (tag || '').toLowerCase().trim();
|
||||
if (l === 'it') return 'ita';
|
||||
if (l === 'en') return 'eng';
|
||||
return l;
|
||||
};
|
||||
|
||||
const buildSuffix = (lang, stream) => {
|
||||
const parts = [lang];
|
||||
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('.');
|
||||
};
|
||||
|
||||
const runFFmpeg = async (spawnArgs, workDir, ffmpegPath, jobLog) => {
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
const cli = new CLI({ cli: ffmpegPath, spawnArgs, WorkDir: workDir, jobLog });
|
||||
return cli.runCli();
|
||||
};
|
||||
|
||||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
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));
|
||||
|
||||
jobLog(`[extractSubtitlesAndOcr] Source: ${inputFile}\n`);
|
||||
|
||||
const subtitleEditPath = args.inputs.subtitleEditPath || 'SubtitleEdit';
|
||||
const targetLangs = (args.inputs.languages || 'ita,eng')
|
||||
.split(',').map((l) => l.trim().toLowerCase()).filter(Boolean);
|
||||
|
||||
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
||||
const subtitleStreams = streams.filter((s) => s.codec_type === 'subtitle');
|
||||
|
||||
if (subtitleStreams.length === 0) {
|
||||
jobLog('[extractSubtitlesAndOcr] No subtitle streams found.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
|
||||
jobLog(`[extractSubtitlesAndOcr] Found ${subtitleStreams.length} subtitle stream(s).\n`);
|
||||
|
||||
for (const stream of subtitleStreams) {
|
||||
const lang = normLang((stream.tags && stream.tags.language) || '');
|
||||
if (!targetLangs.includes(lang)) {
|
||||
jobLog(`[extractSubtitlesAndOcr] Stream #${stream.index} lang="${lang}" — not in target list, skipping.\n`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const codec = (stream.codec_name || '').toLowerCase();
|
||||
const suffix = buildSuffix(lang, stream);
|
||||
const outSrt = path.join(sourceDir, `${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],
|
||||
workDir, args.ffmpegPath, jobLog,
|
||||
);
|
||||
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],
|
||||
workDir, args.ffmpegPath, jobLog,
|
||||
);
|
||||
|
||||
if (extractExit !== 0) {
|
||||
jobLog(`[extractSubtitlesAndOcr] WARNING: .sup extraction failed for stream #${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,
|
||||
});
|
||||
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`);
|
||||
}
|
||||
|
||||
jobLog('[extractSubtitlesAndOcr] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
Reference in new issue
Block a user