371 lines
14 KiB
JavaScript
371 lines
14 KiB
JavaScript
'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 = '96',
|
|
gopMinKeyint = '24',
|
|
gopSceneCut = '40',
|
|
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} min-keyint=${gopMinKeyint} scenecut=${gopSceneCut} `
|
|
+ `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 inputBaseName = path.basename(inputFile, path.extname(inputFile));
|
|
const baseName = variables.workDirSourceBaseName
|
|
|| (variables.workDirCopyApplied === 'true' ? inputBaseName.replace(/_old$/, '') : inputBaseName);
|
|
const outFile = path.join(workDir, `${baseName}.${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=${gopSceneCut}`,
|
|
`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;
|