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,206 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* audioSelectorClamping
|
||||
* Selects the best ITA and ENG audio tracks using codec+bitrate scoring,
|
||||
* then calculates clamped Opus target bitrates.
|
||||
*
|
||||
* Scoring: score = (codecWeight * 10000) + bitrateKbps
|
||||
* Weights: TrueHD=100, DTS-HD MA=95, FLAC=90, EAC3=75, DTS=60,
|
||||
* AC3=45, AAC=35, Opus=30
|
||||
*
|
||||
* Clamping:
|
||||
* Multichannel (ch > 2):
|
||||
* Film: min(filmMultiChannelBitrate, sourceBitrateKbps)
|
||||
* Series: sourceBitrateKbps >= threshold ? 384 : 256
|
||||
* Stereo (ch <= 2):
|
||||
* min(stereoBitrateMax, sourceBitrateKbps) [no upcoding]
|
||||
*/
|
||||
|
||||
const CODEC_WEIGHTS = {
|
||||
truehd: 100,
|
||||
flac: 90,
|
||||
eac3: 75,
|
||||
dts: 60,
|
||||
ac3: 45,
|
||||
aac: 35,
|
||||
opus: 30,
|
||||
mp3: 20,
|
||||
vorbis: 20,
|
||||
};
|
||||
|
||||
const getCodecWeight = (stream) => {
|
||||
const codec = (stream.codec_name || '').toLowerCase();
|
||||
const profile = (stream.profile || '').toLowerCase();
|
||||
// DTS-HD MA detection via profile field
|
||||
if (codec === 'dts' && (profile.includes('ma') || profile.includes('dts-hd'))) return 95;
|
||||
return CODEC_WEIGHTS[codec] || 5;
|
||||
};
|
||||
|
||||
const getSourceBitrateKbps = (stream) => {
|
||||
const br = parseInt(stream.bit_rate || '0', 10);
|
||||
return br > 0 ? Math.round(br / 1000) : 0;
|
||||
};
|
||||
|
||||
const calcOpusBitrate = (stream, isMovie, inputs) => {
|
||||
const srcKbps = getSourceBitrateKbps(stream);
|
||||
const ch = parseInt(stream.channels || '2', 10);
|
||||
const stereoMax = parseInt(inputs.stereoBitrateMax || '128', 10);
|
||||
const filmTarget = parseInt(inputs.filmMultiChannelBitrate || '640', 10);
|
||||
const serThreshold = parseInt(inputs.seriesMultiChannelThreshold || '384', 10);
|
||||
|
||||
if (ch <= 2) {
|
||||
// Stereo: min(max, source) — no upcoding
|
||||
return srcKbps > 0 ? Math.min(stereoMax, srcKbps) : stereoMax;
|
||||
}
|
||||
|
||||
// Multichannel
|
||||
if (isMovie) {
|
||||
return srcKbps > 0 ? Math.min(filmTarget, srcKbps) : filmTarget;
|
||||
}
|
||||
// Series
|
||||
// If bitrate is unknown (0), assume high-quality source -> use 384k
|
||||
const effectiveKbps = srcKbps > 0 ? srcKbps : serThreshold;
|
||||
return effectiveKbps >= serThreshold ? 384 : 256;
|
||||
};
|
||||
|
||||
const details = () => ({
|
||||
name: 'Audio Selector & Opus Bitrate Clamping',
|
||||
description:
|
||||
'Selects the best ITA and ENG audio tracks using weighted codec+bitrate scoring. '
|
||||
+ 'Calculates clamped Opus target bitrates. '
|
||||
+ 'Output 1: tracks found. Output 2: no ITA or ENG tracks found.',
|
||||
style: { borderColor: '#4CAF50' },
|
||||
tags: 'audio',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: 2,
|
||||
icon: 'faVolumeUp',
|
||||
inputs: [
|
||||
{
|
||||
label: 'Film Multichannel Bitrate (kbps)',
|
||||
name: 'filmMultiChannelBitrate',
|
||||
type: 'string',
|
||||
defaultValue: '640',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Max Opus bitrate in kbps for multichannel (>2ch) Film audio. Source bitrate is used if lower.',
|
||||
},
|
||||
{
|
||||
label: 'Series Multichannel Threshold (kbps)',
|
||||
name: 'seriesMultiChannelThreshold',
|
||||
type: 'string',
|
||||
defaultValue: '384',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'For series: if source bitrate >= this value, Opus target is 384k, otherwise 256k.',
|
||||
},
|
||||
{
|
||||
label: 'Stereo Max Bitrate (kbps)',
|
||||
name: 'stereoBitrateMax',
|
||||
type: 'string',
|
||||
defaultValue: '128',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Max Opus bitrate for stereo tracks. Source bitrate is used if lower (no upcoding).',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'ITA or ENG track found — continue pipeline' },
|
||||
{ number: 2, tooltip: 'No ITA or ENG tracks found — handle separately' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables } = args;
|
||||
|
||||
const filePath = inputFileObj._id;
|
||||
jobLog(`[audioSelectorClamping] Processing: ${filePath}\n`);
|
||||
|
||||
const isMovie = variables.isMovie === 'true';
|
||||
jobLog(`[audioSelectorClamping] isMovie=${isMovie}\n`);
|
||||
|
||||
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
||||
const audioStreams = streams.filter((s) => s.codec_type === 'audio');
|
||||
|
||||
jobLog(`[audioSelectorClamping] Found ${audioStreams.length} audio stream(s).\n`);
|
||||
|
||||
let bestIta = null;
|
||||
let bestEng = null;
|
||||
let bestItaScore = -1;
|
||||
let bestEngScore = -1;
|
||||
|
||||
audioStreams.forEach((stream) => {
|
||||
const lang = ((stream.tags && stream.tags.language) || '').toLowerCase();
|
||||
if (lang !== 'ita' && lang !== 'eng' && lang !== 'it' && lang !== 'en') return;
|
||||
|
||||
const normLang = (lang === 'it') ? 'ita' : (lang === 'en') ? 'eng' : lang;
|
||||
const weight = getCodecWeight(stream);
|
||||
const srcKbps = getSourceBitrateKbps(stream);
|
||||
const score = weight * 10000 + srcKbps;
|
||||
|
||||
jobLog(
|
||||
`[audioSelectorClamping] Stream #${stream.index} lang=${normLang} `
|
||||
+ `codec=${stream.codec_name} profile="${stream.profile || ''}" `
|
||||
+ `src=${srcKbps}kbps ch=${stream.channels} score=${score}\n`,
|
||||
);
|
||||
|
||||
if (normLang === 'ita' && score > bestItaScore) {
|
||||
bestItaScore = score;
|
||||
bestIta = { stream, normLang };
|
||||
}
|
||||
if (normLang === 'eng' && score > bestEngScore) {
|
||||
bestEngScore = score;
|
||||
bestEng = { stream, normLang };
|
||||
}
|
||||
});
|
||||
|
||||
if (!bestIta && !bestEng) {
|
||||
jobLog('[audioSelectorClamping] WARNING: No ITA or ENG tracks found. Routing to output 2.\n');
|
||||
variables.audioHasIta = 'false';
|
||||
variables.audioHasEng = 'false';
|
||||
return { outputNumber: 2, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
|
||||
// Export ITA
|
||||
if (bestIta) {
|
||||
const { stream } = bestIta;
|
||||
const opusBitrate = calcOpusBitrate(stream, isMovie, args.inputs);
|
||||
variables.audioHasIta = 'true';
|
||||
variables.audioItaStreamIndex = String(stream.index);
|
||||
variables.audioItaOpusBitrate = String(opusBitrate);
|
||||
variables.audioItaChannels = String(stream.channels || '2');
|
||||
variables.audioItaCodecName = (stream.codec_name || '').toLowerCase();
|
||||
jobLog(
|
||||
`[audioSelectorClamping] ITA selected: #${stream.index} ${stream.codec_name} `
|
||||
+ `${getSourceBitrateKbps(stream)}kbps -> Opus ${opusBitrate}kbps\n`,
|
||||
);
|
||||
} else {
|
||||
variables.audioHasIta = 'false';
|
||||
jobLog('[audioSelectorClamping] No ITA track found.\n');
|
||||
}
|
||||
|
||||
// Export ENG
|
||||
if (bestEng) {
|
||||
const { stream } = bestEng;
|
||||
const opusBitrate = calcOpusBitrate(stream, isMovie, args.inputs);
|
||||
variables.audioHasEng = 'true';
|
||||
variables.audioEngStreamIndex = String(stream.index);
|
||||
variables.audioEngOpusBitrate = String(opusBitrate);
|
||||
variables.audioEngChannels = String(stream.channels || '2');
|
||||
variables.audioEngCodecName = (stream.codec_name || '').toLowerCase();
|
||||
jobLog(
|
||||
`[audioSelectorClamping] ENG selected: #${stream.index} ${stream.codec_name} `
|
||||
+ `${getSourceBitrateKbps(stream)}kbps -> Opus ${opusBitrate}kbps\n`,
|
||||
);
|
||||
} else {
|
||||
variables.audioHasEng = 'false';
|
||||
jobLog('[audioSelectorClamping] No ENG track found.\n');
|
||||
}
|
||||
|
||||
jobLog('[audioSelectorClamping] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -0,0 +1,94 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* backupSourceAudio
|
||||
* Extracts the original ITA audio track as a lossless bitstream copy,
|
||||
* ONLY when the codec is DTS or TrueHD.
|
||||
* Output is saved next to the source file (same directory).
|
||||
*/
|
||||
|
||||
const BACKUP_CODECS = ['dts', 'truehd'];
|
||||
const details = () => ({
|
||||
name: 'Backup Source Audio (DTS / TrueHD ITA)',
|
||||
description:
|
||||
'Extracts the ITA audio track as a lossless bitstream copy (-c:a copy) '
|
||||
+ 'when the source codec is DTS or TrueHD. '
|
||||
+ 'Output is saved next to the source file. '
|
||||
+ 'Silently skips if the ITA track does not require backup.',
|
||||
style: { borderColor: '#F44336' },
|
||||
tags: 'audio,backup',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: -1,
|
||||
icon: 'faSave',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue to next plugin' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables, workDir } = args;
|
||||
|
||||
const inputFile = inputFileObj._id;
|
||||
jobLog(`[backupSourceAudio] Processing: ${inputFile}\n`);
|
||||
|
||||
const hasIta = variables.audioHasIta === 'true';
|
||||
const itaCodec = (variables.audioItaCodecName || '').toLowerCase();
|
||||
const itaIdx = variables.audioItaStreamIndex;
|
||||
|
||||
if (!hasIta) {
|
||||
jobLog('[backupSourceAudio] No ITA track found — skipping.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
|
||||
const needsBackup = BACKUP_CODECS.some((c) => itaCodec.includes(c));
|
||||
if (!needsBackup) {
|
||||
jobLog(`[backupSourceAudio] ITA codec "${itaCodec}" does not require backup — skipping.\n`);
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
}
|
||||
|
||||
// Determine output extension
|
||||
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}`);
|
||||
|
||||
jobLog(`[backupSourceAudio] Backing up ITA ${itaCodec} -> ${outFile}\n`);
|
||||
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
const cli = new CLI({
|
||||
cli: args.ffmpegPath,
|
||||
spawnArgs: [
|
||||
'-y',
|
||||
'-i', inputFile,
|
||||
'-map', `0:${itaIdx}`,
|
||||
'-c:a', 'copy',
|
||||
outFile,
|
||||
],
|
||||
WorkDir: workDir,
|
||||
jobLog,
|
||||
});
|
||||
|
||||
const { cliExitCode } = await cli.runCli();
|
||||
|
||||
if (cliExitCode !== 0) {
|
||||
jobLog(`[backupSourceAudio] WARNING: FFmpeg exited with code ${cliExitCode}. Backup may be incomplete.\n`);
|
||||
} else {
|
||||
jobLog(`[backupSourceAudio] Backup complete: ${outFile}\n`);
|
||||
variables.audioBackupPath = outFile;
|
||||
}
|
||||
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,72 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const details = () => ({
|
||||
name: 'Extract Chapters to XML',
|
||||
description:
|
||||
'Extracts MKV chapter markers to an XML file in workDir using mkvextract. '
|
||||
+ 'Stores the output path in args.variables.chaptersXmlPath. '
|
||||
+ 'Safe to run even if the source has no chapters.',
|
||||
style: { borderColor: '#9C27B0' },
|
||||
tags: 'utility,chapters,mkv',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: -1,
|
||||
icon: 'faList',
|
||||
inputs: [],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue to next plugin' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables, workDir } = args;
|
||||
|
||||
const inputFile = inputFileObj._id;
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||
const xmlPath = path.join(workDir, `${baseName}_chapters.xml`);
|
||||
|
||||
jobLog(`[extractChaptersXml] Source: ${inputFile}\n`);
|
||||
jobLog(`[extractChaptersXml] Output: ${xmlPath}\n`);
|
||||
|
||||
// Derive mkvextract from mkvpropeditPath (same MKVToolNix bin dir)
|
||||
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)) mkvExtract = candidate;
|
||||
}
|
||||
jobLog(`[extractChaptersXml] Using: ${mkvExtract}\n`);
|
||||
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
const cli = new CLI({
|
||||
cli: mkvExtract,
|
||||
spawnArgs: [inputFile, 'chapters', xmlPath],
|
||||
WorkDir: workDir,
|
||||
jobLog,
|
||||
});
|
||||
|
||||
const { cliExitCode } = await cli.runCli();
|
||||
|
||||
// mkvextract: exit 0 = OK, exit 1 = no chapters (still OK), exit 2 = error
|
||||
if (cliExitCode > 1) {
|
||||
jobLog(`[extractChaptersXml] WARNING: mkvextract exit ${cliExitCode} — chapters may be missing.\n`);
|
||||
variables.chaptersXmlPath = '';
|
||||
} else if (fs.existsSync(xmlPath) && fs.statSync(xmlPath).size > 50) {
|
||||
variables.chaptersXmlPath = xmlPath;
|
||||
jobLog(`[extractChaptersXml] Chapters saved: ${xmlPath}\n`);
|
||||
} else {
|
||||
jobLog('[extractChaptersXml] No chapters found.\n');
|
||||
variables.chaptersXmlPath = '';
|
||||
}
|
||||
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -0,0 +1,167 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* mediaAnalyzerGOP
|
||||
* Analyzes the video stream and populates args.variables with encoding
|
||||
* parameters consumed by downstream plugins:
|
||||
* gopKeyint / gopMinKeyint -> 2 x FPS (rounded)
|
||||
* isAnimation -> detected from animeSeries path list
|
||||
* isMovie -> detected from moviePathPatterns
|
||||
* vBitrate / vMaxRate / vBufSize
|
||||
* x265Tune -> "animation" or ""
|
||||
* videoHeight / videoFps
|
||||
*/
|
||||
|
||||
const details = () => ({
|
||||
name: 'Media Analyzer & GOP Calculator',
|
||||
description:
|
||||
'Analyzes video metadata, calculates GOP keyint (2xFPS), target bitrate '
|
||||
+ 'and VBV parameters based on content type (film/series/anime). '
|
||||
+ 'Exports all values to args.variables for downstream plugins.',
|
||||
style: { borderColor: '#2196F3' },
|
||||
tags: 'video,utility',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: 1,
|
||||
icon: 'faFilm',
|
||||
inputs: [
|
||||
{
|
||||
label: 'Movie Library Path Patterns',
|
||||
name: 'moviePathPatterns',
|
||||
type: 'string',
|
||||
defaultValue: '/Radarr',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip:
|
||||
'Pipe-separated path fragments that identify a Film library. '
|
||||
+ 'Example: /Radarr|/films|/movies',
|
||||
},
|
||||
{
|
||||
label: 'Anime Series Names',
|
||||
name: 'animeSeries',
|
||||
type: 'string',
|
||||
defaultValue: '',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip:
|
||||
'Pipe-separated list of anime series names. The plugin checks if any '
|
||||
+ 'token appears (case-insensitive) in the file path. '
|
||||
+ 'Example: Naruto|One Piece|Attack on Titan',
|
||||
},
|
||||
{
|
||||
label: 'Film Bitrate (kbps)',
|
||||
name: 'filmBitrate',
|
||||
type: 'string',
|
||||
defaultValue: '3000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for live-action films.',
|
||||
},
|
||||
{
|
||||
label: 'Series Bitrate (kbps)',
|
||||
name: 'seriesBitrate',
|
||||
type: 'string',
|
||||
defaultValue: '3000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for TV series.',
|
||||
},
|
||||
{
|
||||
label: 'Anime HD Bitrate (kbps, >=720p)',
|
||||
name: 'animeBitrateHd',
|
||||
type: 'string',
|
||||
defaultValue: '2000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for anime at 720p or higher.',
|
||||
},
|
||||
{
|
||||
label: 'Anime SD Bitrate (kbps, <720p)',
|
||||
name: 'animeBitrateSd',
|
||||
type: 'string',
|
||||
defaultValue: '1000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target video bitrate in kbps for anime below 720p.',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue to next plugin' },
|
||||
],
|
||||
});
|
||||
|
||||
const parseRate = (rateStr) => {
|
||||
if (!rateStr) return 0;
|
||||
if (rateStr.includes('/')) {
|
||||
const [n, d] = rateStr.split('/').map(Number);
|
||||
return d ? n / d : 0;
|
||||
}
|
||||
return parseFloat(rateStr) || 0;
|
||||
};
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables } = args;
|
||||
|
||||
const filePath = inputFileObj._id;
|
||||
jobLog(`[mediaAnalyzerGOP] Processing: ${filePath}\n`);
|
||||
|
||||
const moviePathPatterns = (args.inputs.moviePathPatterns || '/Radarr')
|
||||
.split('|').filter(Boolean);
|
||||
const animeList = (args.inputs.animeSeries || '').split('|').filter(Boolean);
|
||||
const filmBitrate = Math.max(1, parseInt(args.inputs.filmBitrate || '3000', 10));
|
||||
const seriesBitrate = Math.max(1, parseInt(args.inputs.seriesBitrate || '3000', 10));
|
||||
const animeBitrateHd = Math.max(1, parseInt(args.inputs.animeBitrateHd || '2000', 10));
|
||||
const animeBitrateSd = Math.max(1, parseInt(args.inputs.animeBitrateSd || '1000', 10));
|
||||
|
||||
const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || [];
|
||||
const videoStream = streams.find((s) => s.codec_type === 'video');
|
||||
if (!videoStream) throw new Error('[mediaAnalyzerGOP] No video stream found.');
|
||||
|
||||
let fps = parseRate(videoStream.r_frame_rate);
|
||||
if (!fps || fps > 120) fps = parseRate(videoStream.avg_frame_rate);
|
||||
if (!fps || fps > 120) fps = parseFloat(videoStream.FrameRate || '0');
|
||||
if (!fps || fps <= 0) {
|
||||
jobLog('[mediaAnalyzerGOP] WARNING: Cannot determine FPS, defaulting to 24.\n');
|
||||
fps = 24;
|
||||
}
|
||||
jobLog(`[mediaAnalyzerGOP] FPS: ${fps.toFixed(3)}\n`);
|
||||
|
||||
const gopKeyint = String(Math.round(fps * 2));
|
||||
jobLog(`[mediaAnalyzerGOP] keyint=${gopKeyint}\n`);
|
||||
|
||||
// Normalize to forward slashes for cross-platform path matching
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const isMovie = moviePathPatterns.some((p) => normalizedPath.includes(p));
|
||||
const matchedPat = moviePathPatterns.find((p) => normalizedPath.includes(p));
|
||||
jobLog(`[mediaAnalyzerGOP] isMovie=${isMovie}${matchedPat ? ` ("${matchedPat}")` : ''}\n`);
|
||||
|
||||
const lowerPath = normalizedPath.toLowerCase();
|
||||
const matchedAnime = animeList.find((name) => lowerPath.includes(name.toLowerCase()));
|
||||
const isAnimation = !!matchedAnime;
|
||||
jobLog(`[mediaAnalyzerGOP] isAnimation=${isAnimation}${matchedAnime ? ` ("${matchedAnime}")` : ''}\n`);
|
||||
|
||||
const videoHeight = parseInt(videoStream.height || '0', 10);
|
||||
let targetKbps;
|
||||
if (isAnimation) targetKbps = videoHeight >= 720 ? animeBitrateHd : animeBitrateSd;
|
||||
else if (isMovie) targetKbps = filmBitrate;
|
||||
else targetKbps = seriesBitrate;
|
||||
|
||||
const vBitrate = `${targetKbps}k`;
|
||||
const vMaxRate = `${targetKbps * 2}k`;
|
||||
const vBufSize = `${targetKbps * 4}k`;
|
||||
jobLog(`[mediaAnalyzerGOP] vBitrate=${vBitrate} vMaxRate=${vMaxRate} vBufSize=${vBufSize}\n`);
|
||||
|
||||
variables.gopKeyint = gopKeyint;
|
||||
variables.gopMinKeyint = gopKeyint;
|
||||
variables.isAnimation = String(isAnimation);
|
||||
variables.isMovie = String(isMovie);
|
||||
variables.vBitrate = vBitrate;
|
||||
variables.vMaxRate = vMaxRate;
|
||||
variables.vBufSize = vBufSize;
|
||||
variables.x265Tune = isAnimation ? 'animation' : '';
|
||||
variables.videoHeight = String(videoHeight);
|
||||
variables.videoFps = fps.toFixed(3);
|
||||
|
||||
jobLog('[mediaAnalyzerGOP] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -0,0 +1,73 @@
|
||||
'use strict';
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
const details = () => ({
|
||||
name: 'Set Animation Flag',
|
||||
description:
|
||||
'Forces isAnimation=true and x265Tune=animation in flow variables. '
|
||||
+ 'Use when a file is animation but was not detected automatically '
|
||||
+ 'by the animeSeries list in mediaAnalyzerGOP. '
|
||||
+ 'Optionally recalculates video bitrate targets.',
|
||||
style: { borderColor: '#FF9800' },
|
||||
tags: 'utility,animation',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: -1,
|
||||
icon: 'faMagic',
|
||||
inputs: [
|
||||
{
|
||||
label: 'Anime HD Bitrate (kbps, >=720p)',
|
||||
name: 'animeBitrateHd',
|
||||
type: 'string',
|
||||
defaultValue: '2000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target bitrate for anime >=720p. Leave blank to skip recalculation.',
|
||||
},
|
||||
{
|
||||
label: 'Anime SD Bitrate (kbps, <720p)',
|
||||
name: 'animeBitrateSd',
|
||||
type: 'string',
|
||||
defaultValue: '1000',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Target bitrate for anime <720p.',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Continue with animation flag set' },
|
||||
],
|
||||
});
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables } = args;
|
||||
jobLog('[setAnimationFlag] Forcing isAnimation=true\n');
|
||||
|
||||
variables.isAnimation = 'true';
|
||||
variables.x265Tune = 'animation';
|
||||
|
||||
const hdInput = args.inputs.animeBitrateHd;
|
||||
const sdInput = args.inputs.animeBitrateSd;
|
||||
|
||||
if (hdInput && sdInput) {
|
||||
const animeBitrateHd = Math.max(1, parseInt(hdInput, 10));
|
||||
const animeBitrateSd = Math.max(1, parseInt(sdInput, 10));
|
||||
const videoHeight = parseInt(variables.videoHeight || '0', 10);
|
||||
|
||||
if (videoHeight > 0) {
|
||||
const targetKbps = videoHeight >= 720 ? animeBitrateHd : animeBitrateSd;
|
||||
variables.vBitrate = `${targetKbps}k`;
|
||||
variables.vMaxRate = `${targetKbps * 2}k`;
|
||||
variables.vBufSize = `${targetKbps * 4}k`;
|
||||
jobLog(`[setAnimationFlag] Bitrate recalculated: ${variables.vBitrate} (height=${videoHeight}px)\n`);
|
||||
} else {
|
||||
jobLog('[setAnimationFlag] videoHeight not set — skipping bitrate recalculation.\n');
|
||||
}
|
||||
}
|
||||
|
||||
jobLog('[setAnimationFlag] Done.\n');
|
||||
return { outputNumber: 1, outputFileObj: inputFileObj, variables };
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
@@ -0,0 +1,234 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
/* eslint-disable no-param-reassign */
|
||||
|
||||
/**
|
||||
* transcode2PassX265
|
||||
* Performs 2-pass x265 encoding with:
|
||||
* - Bound-1080p Lanczos scaling (no letterbox, even dimensions)
|
||||
* - VBV rate control from args.variables
|
||||
* - Dynamic Opus audio mapping (ITA default, ENG secondary)
|
||||
* - No subtitle streams (-sn)
|
||||
* - Cross-platform null device (NUL / /dev/null)
|
||||
* - Cleanup of x265 2-pass log files post-encode
|
||||
*/
|
||||
|
||||
const details = () => ({
|
||||
name: '2-Pass x265 Transcode',
|
||||
description:
|
||||
'Performs 2-pass libx265 encoding using parameters from args.variables. '
|
||||
+ 'Applies bound-1080p Lanczos scaling, Opus audio re-encoding, '
|
||||
+ 'and chapter preservation. Cross-platform (Linux server / Windows worker).',
|
||||
style: { borderColor: '#E91E63' },
|
||||
tags: 'video,ffmpeg,x265',
|
||||
isStartPlugin: false,
|
||||
pType: '',
|
||||
requiresVersion: '2.11.01',
|
||||
sidebarPosition: 4,
|
||||
icon: 'faVideo',
|
||||
inputs: [
|
||||
{
|
||||
label: 'x265 Preset',
|
||||
name: 'x265Preset',
|
||||
type: 'string',
|
||||
defaultValue: 'slow',
|
||||
inputUI: {
|
||||
type: 'dropdown',
|
||||
options: ['ultrafast', 'superfast', 'veryfast', 'faster', 'fast', 'medium', 'slow', 'slower', 'veryslow'],
|
||||
},
|
||||
tooltip: 'x265 encoding preset. Slower = better quality/compression ratio.',
|
||||
},
|
||||
{
|
||||
label: 'Output Container',
|
||||
name: 'outputContainer',
|
||||
type: 'string',
|
||||
defaultValue: 'mkv',
|
||||
inputUI: { type: 'text' },
|
||||
tooltip: 'Output file container extension (mkv recommended).',
|
||||
},
|
||||
],
|
||||
outputs: [
|
||||
{ number: 1, tooltip: 'Transcode complete — output file ready' },
|
||||
],
|
||||
});
|
||||
|
||||
// ─── Build audio mapping args ────────────────────────────────────────────────
|
||||
|
||||
const buildAudioArgs = (variables) => {
|
||||
// Maps and codec/bitrate args are separated intentionally.
|
||||
// -c:a and -b:a without a stream index apply globally (last wins),
|
||||
// so we use indexed specifiers -c:a:N / -b:a:N to set per-track options.
|
||||
const mapArgs = [];
|
||||
const codecArgs = [];
|
||||
let audioTrackOut = 0;
|
||||
|
||||
if (variables.audioHasIta === 'true') {
|
||||
const idx = variables.audioItaStreamIndex;
|
||||
const bitrate = variables.audioItaOpusBitrate;
|
||||
mapArgs.push('-map', `0:${idx}`);
|
||||
codecArgs.push(
|
||||
`-c:a:${audioTrackOut}`, 'libopus',
|
||||
`-b:a:${audioTrackOut}`, `${bitrate}k`,
|
||||
`-metadata:s:a:${audioTrackOut}`, 'language=ita',
|
||||
`-disposition:a:${audioTrackOut}`, 'default',
|
||||
);
|
||||
audioTrackOut += 1;
|
||||
}
|
||||
|
||||
if (variables.audioHasEng === 'true') {
|
||||
const idx = variables.audioEngStreamIndex;
|
||||
const bitrate = variables.audioEngOpusBitrate;
|
||||
mapArgs.push('-map', `0:${idx}`);
|
||||
codecArgs.push(
|
||||
`-c:a:${audioTrackOut}`, 'libopus',
|
||||
`-b:a:${audioTrackOut}`, `${bitrate}k`,
|
||||
`-metadata:s:a:${audioTrackOut}`, 'language=eng',
|
||||
`-disposition:a:${audioTrackOut}`, '0',
|
||||
);
|
||||
audioTrackOut += 1;
|
||||
}
|
||||
|
||||
// Emit: all -map first, then all codec settings
|
||||
return [...mapArgs, ...codecArgs];
|
||||
};
|
||||
|
||||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const plugin = async (args) => {
|
||||
const { jobLog, inputFileObj, variables, workDir } = args;
|
||||
|
||||
const inputFile = inputFileObj._id;
|
||||
jobLog(`[transcode2PassX265] Source: ${inputFile}\n`);
|
||||
|
||||
// ── Read variables ────────────────────────────────────────────────────────
|
||||
const {
|
||||
gopKeyint = '48',
|
||||
gopMinKeyint = '48',
|
||||
vBitrate = '3000k',
|
||||
vMaxRate = '6000k',
|
||||
vBufSize = '12000k',
|
||||
x265Tune = '',
|
||||
} = variables;
|
||||
|
||||
const x265Preset = args.inputs.x265Preset || 'slow';
|
||||
const container = args.inputs.outputContainer || 'mkv';
|
||||
|
||||
// Parse numeric VBV values (parseInt handles trailing 'k')
|
||||
const maxRateNum = parseInt(vMaxRate, 10);
|
||||
const bufSizeNum = parseInt(vBufSize, 10);
|
||||
|
||||
jobLog(
|
||||
`[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k `
|
||||
+ `bufsize=${bufSizeNum}k keyint=${gopKeyint} tune=${x265Tune || 'none'} preset=${x265Preset}\n`,
|
||||
);
|
||||
|
||||
// ── Paths ─────────────────────────────────────────────────────────────────
|
||||
const baseName = path.basename(inputFile, path.extname(inputFile));
|
||||
const outFile = path.join(workDir, `${baseName}_x265.${container}`);
|
||||
const statsFile = path.join(workDir, 'x265_2pass.log');
|
||||
// x265 expects forward slashes in x265-params on all platforms
|
||||
const statsForX265 = statsFile.replace(/\\/g, '/');
|
||||
const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null';
|
||||
|
||||
jobLog(`[transcode2PassX265] Output: ${outFile}\n`);
|
||||
|
||||
// ── Video filter ──────────────────────────────────────────────────────────
|
||||
const vf = [
|
||||
"scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease",
|
||||
'scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos',
|
||||
].join(',');
|
||||
|
||||
// ── x265-params base ──────────────────────────────────────────────────────
|
||||
const x265ParamsBase = [
|
||||
`keyint=${gopKeyint}`,
|
||||
`min-keyint=${gopMinKeyint}`,
|
||||
`vbv-maxrate=${maxRateNum}`,
|
||||
`vbv-bufsize=${bufSizeNum}`,
|
||||
`stats=${statsForX265}`,
|
||||
].join(':');
|
||||
|
||||
// ── Common video args ─────────────────────────────────────────────────────
|
||||
const tuneArgs = x265Tune ? ['-tune', x265Tune] : [];
|
||||
const videoArgs = [
|
||||
'-vf', vf,
|
||||
'-c:v', 'libx265',
|
||||
'-b:v', vBitrate,
|
||||
'-preset', x265Preset,
|
||||
...tuneArgs,
|
||||
];
|
||||
|
||||
const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
|
||||
|
||||
// ══ PASS 1 ════════════════════════════════════════════════════════════════
|
||||
jobLog('[transcode2PassX265] Starting Pass 1...\n');
|
||||
const pass1Cli = new CLI({
|
||||
cli: args.ffmpegPath,
|
||||
spawnArgs: [
|
||||
'-y',
|
||||
'-i', inputFile,
|
||||
...videoArgs,
|
||||
'-x265-params', `${x265ParamsBase}:pass=1`,
|
||||
'-an', '-sn',
|
||||
'-f', 'null',
|
||||
nullDev,
|
||||
],
|
||||
WorkDir: workDir,
|
||||
jobLog,
|
||||
});
|
||||
|
||||
const { cliExitCode: pass1Exit } = await pass1Cli.runCli();
|
||||
if (pass1Exit !== 0) {
|
||||
throw new Error(`[transcode2PassX265] Pass 1 failed with exit code ${pass1Exit}`);
|
||||
}
|
||||
jobLog('[transcode2PassX265] Pass 1 complete.\n');
|
||||
|
||||
// ══ PASS 2 ════════════════════════════════════════════════════════════════
|
||||
const audioArgs = buildAudioArgs(variables);
|
||||
if (audioArgs.length === 0) {
|
||||
jobLog('[transcode2PassX265] WARNING: No audio streams mapped (no ITA/ENG found).\n');
|
||||
}
|
||||
|
||||
jobLog('[transcode2PassX265] Starting Pass 2...\n');
|
||||
const pass2Cli = new CLI({
|
||||
cli: args.ffmpegPath,
|
||||
spawnArgs: [
|
||||
'-y',
|
||||
'-i', inputFile,
|
||||
'-map', '0:v:0',
|
||||
...audioArgs,
|
||||
'-map_chapters', '0',
|
||||
...videoArgs,
|
||||
'-x265-params', `${x265ParamsBase}:pass=2`,
|
||||
'-sn',
|
||||
outFile,
|
||||
],
|
||||
WorkDir: workDir,
|
||||
jobLog,
|
||||
});
|
||||
|
||||
const { cliExitCode: pass2Exit } = await pass2Cli.runCli();
|
||||
if (pass2Exit !== 0) {
|
||||
throw new Error(`[transcode2PassX265] Pass 2 failed with exit code ${pass2Exit}`);
|
||||
}
|
||||
jobLog('[transcode2PassX265] Pass 2 complete.\n');
|
||||
|
||||
// ── Cleanup 2-pass log files ──────────────────────────────────────────────
|
||||
[statsFile, `${statsFile}.mbtree`, `${statsFile}.cutree`].forEach((f) => {
|
||||
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch (_) { /* ignore */ }
|
||||
});
|
||||
jobLog('[transcode2PassX265] Temp log files cleaned up.\n');
|
||||
|
||||
// ── Return transcoded file for Tdarr to manage ────────────────────────────
|
||||
jobLog(`[transcode2PassX265] Done. Output: ${outFile}\n`);
|
||||
return {
|
||||
outputNumber: 1,
|
||||
outputFileObj: { ...inputFileObj, _id: outFile },
|
||||
variables,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports.details = details;
|
||||
module.exports.plugin = plugin;
|
||||
Reference in new issue
Block a user