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,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