'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, updateWorker } = 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, inputFileObj, updateWorker, }); 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, inputFileObj, updateWorker, }); 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;