'use strict'; const path = require('path'); const fs = require('fs'); /* eslint-disable no-param-reassign */ /** * transcode2PassX265 * Performs 2-pass x265 encoding, unless the input is already a compatible * low-bitrate HEVC stream, 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, ' + 'or copies compatible HEVC video below the configured bitrate limit. ' + 'Applies bound-1080p Lanczos scaling when encoding, 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).', }, { label: 'Copy HEVC Below (kbps)', name: 'copyHevcBelowKbps', type: 'string', defaultValue: '3000', inputUI: { type: 'text' }, tooltip: 'Copy video instead of re-encoding when it is HEVC, is already 1080p or smaller, ' + 'and its reported bitrate is strictly below this value. Set 0 to disable copying.', }, ], 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]; }; const getStreamBitrateKbps = (stream) => { const streamBitrate = parseInt(stream.bit_rate, 10); if (streamBitrate > 0) return streamBitrate / 1000; // Matroska files commonly expose the per-stream bitrate as BPS metadata // rather than ffprobe's bit_rate field (for example: tags.BPS = "1800356"). const tags = stream.tags || {}; const bpsEntry = Object.entries(tags).find(([key]) => { const normalizedKey = String(key).toLowerCase(); return normalizedKey === 'bps' || normalizedKey === 'bps-eng'; }); const metadataBitrate = bpsEntry ? parseInt(bpsEntry[1], 10) : 0; return metadataBitrate > 0 ? metadataBitrate / 1000 : 0; }; const getVideoBitrateKbps = (videoStream, streams, format) => { const directBitrate = getStreamBitrateKbps(videoStream); if (directBitrate > 0) return directBitrate; // Last resort: derive video bitrate from the container bitrate after // subtracting any known audio/data stream bitrates. This is deliberately // conservative if a companion stream does not report a bitrate. let containerBitrate = parseInt(format && format.bit_rate, 10) / 1000; if (!(containerBitrate > 0)) { const size = parseInt(format && format.size, 10); const duration = parseFloat(format && format.duration); containerBitrate = size > 0 && duration > 0 ? (size * 8 / duration) / 1000 : 0; } if (!(containerBitrate > 0)) return 0; const otherStreamBitrate = (streams || []) .filter((stream) => stream !== videoStream) .reduce((total, stream) => total + getStreamBitrateKbps(stream), 0); return Math.max(0, containerBitrate - otherStreamBitrate); }; const shouldCopyVideo = (videoStream, copyHevcBelowKbps, streams, format) => { if (!videoStream || String(videoStream.codec_name || '').toLowerCase() !== 'hevc') return false; const width = parseInt(videoStream.width, 10) || 0; const height = parseInt(videoStream.height, 10) || 0; const bitrateKbps = getVideoBitrateKbps(videoStream, streams, format); return copyHevcBelowKbps > 0 && width > 0 && width <= 1920 && height > 0 && height <= 1080 && bitrateKbps > 0 && bitrateKbps < copyHevcBelowKbps; }; // ─── 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'; const copyHevcBelowKbps = Math.max(0, parseInt(args.inputs.copyHevcBelowKbps || '3000', 10) || 0); const streams = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.streams) || []; const format = (inputFileObj.ffProbeData && inputFileObj.ffProbeData.format) || {}; const videoStream = streams.find((stream) => stream.codec_type === 'video'); if (!videoStream) throw new Error('[transcode2PassX265] No video stream found.'); const copyVideo = shouldCopyVideo(videoStream, copyHevcBelowKbps, streams, format); // 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`, ); const sourceBitrateKbps = getVideoBitrateKbps(videoStream, streams, format).toFixed(0); jobLog( `[transcode2PassX265] Video: codec=${videoStream.codec_name || 'unknown'} ` + `${videoStream.width || '?'}x${videoStream.height || '?'} bitrate=${sourceBitrateKbps}k ` + `mode=${copyVideo ? 'copy' : '2-pass-x265'} (copy limit <${copyHevcBelowKbps}k)\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'); // The CLI runs with workDir as its current directory. Keeping this relative // avoids the Windows drive colon being parsed as an x265-params separator. const statsForX265 = path.basename(statsFile); const nullDev = process.platform === 'win32' ? 'NUL' : '/dev/null'; jobLog(`[transcode2PassX265] Output: ${outFile}\n`); // ── Video filter / args ─────────────────────────────────────────────────── 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}`, `scenecut=0`, `open-gop=0`, `vbv-maxrate=${maxRateNum}`, `vbv-bufsize=${bufSizeNum}`, `stats=${statsForX265}`, ].join(':'); // ── Common video args ───────────────────────────────────────────────────── const tuneArgs = x265Tune ? ['-tune', x265Tune] : []; const encodeVideoArgs = [ '-vf', vf, '-c:v', 'libx265', '-b:v', vBitrate, '-preset', x265Preset, ...tuneArgs, ]; const videoArgs = copyVideo ? ['-c:v', 'copy'] : encodeVideoArgs; // A stream copy has no encoder pass. The normal pass-2 command below still // remuxes the copied video while re-encoding the selected audio tracks. if (copyVideo) { const audioArgs = buildAudioArgs(variables); if (audioArgs.length === 0) { jobLog('[transcode2PassX265] WARNING: No audio streams mapped (no ITA/ENG found).\n'); } jobLog('[transcode2PassX265] Copying compatible video and processing audio...\n'); const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils'); const copyCli = new CLI({ cli: args.ffmpegPath, spawnArgs: [ '-y', '-i', inputFile, '-map', '0:v:0', ...audioArgs, '-map_metadata', '-1', '-map_chapters', '-1', '-disposition:v:0', 'default', ...videoArgs, '-sn', outFile, ], WorkDir: workDir, jobLog, inputFileObj, updateWorker, args, }); const { cliExitCode } = await copyCli.runCli(); if (cliExitCode !== 0) { throw new Error(`[transcode2PassX265] Video-copy/audio-transcode failed with exit code ${cliExitCode}`); } jobLog(`[transcode2PassX265] Done. Output: ${outFile}\n`); return { outputNumber: 1, outputFileObj: { ...inputFileObj, _id: outFile }, variables, }; } 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, '-map_metadata', '-1', '-map_chapters', '-1', ...videoArgs, '-x265-params', `no-slow-firstpass=1:pass=1:${x265ParamsBase}`, '-an', '-sn', '-f', 'null', nullDev, ], WorkDir: workDir, jobLog, inputFileObj, updateWorker, args, }); 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_metadata', '-1', '-map_chapters', '-1', '-disposition:v:0', 'default', ...videoArgs, '-x265-params', `pass=2:${x265ParamsBase}`, '-sn', outFile, ], WorkDir: workDir, jobLog, inputFileObj, updateWorker, args, }); 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;