fix: enhance 2-pass x265 encoding to copy compatible HEVC video below specified bitrate limit
Build / Lint & Test (push) Successful in 15s
Build / Lint & Test (push) Successful in 15s
This commit is contained in:
1 parent
2a39da6d08
commit
ee5fc99820
3 files changed
+92
-6
No files matched your search
@@ -7,7 +7,8 @@ const fs = require('fs');
|
||||
|
||||
/**
|
||||
* transcode2PassX265
|
||||
* Performs 2-pass x265 encoding with:
|
||||
* 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)
|
||||
@@ -19,8 +20,9 @@ const fs = require('fs');
|
||||
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, '
|
||||
'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',
|
||||
@@ -49,6 +51,16 @@ const details = () => ({
|
||||
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' },
|
||||
@@ -95,6 +107,19 @@ const buildAudioArgs = (variables) => {
|
||||
return [...mapArgs, ...codecArgs];
|
||||
};
|
||||
|
||||
const shouldCopyVideo = (videoStream, copyHevcBelowKbps) => {
|
||||
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 = (parseInt(videoStream.bit_rate, 10) || 0) / 1000;
|
||||
|
||||
return copyHevcBelowKbps > 0
|
||||
&& width > 0 && width <= 1920
|
||||
&& height > 0 && height <= 1080
|
||||
&& bitrateKbps > 0 && bitrateKbps < copyHevcBelowKbps;
|
||||
};
|
||||
|
||||
// ─── Plugin ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const plugin = async (args) => {
|
||||
@@ -115,6 +140,11 @@ const plugin = async (args) => {
|
||||
|
||||
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 videoStream = streams.find((stream) => stream.codec_type === 'video');
|
||||
if (!videoStream) throw new Error('[transcode2PassX265] No video stream found.');
|
||||
const copyVideo = shouldCopyVideo(videoStream, copyHevcBelowKbps);
|
||||
|
||||
// Parse numeric VBV values (parseInt handles trailing 'k')
|
||||
const maxRateNum = parseInt(vMaxRate, 10);
|
||||
@@ -124,6 +154,12 @@ const plugin = async (args) => {
|
||||
`[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k `
|
||||
+ `bufsize=${bufSizeNum}k keyint=${gopKeyint} tune=${x265Tune || 'none'} preset=${x265Preset}\n`,
|
||||
);
|
||||
const sourceBitrateKbps = ((parseInt(videoStream.bit_rate, 10) || 0) / 1000).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));
|
||||
@@ -135,7 +171,7 @@ const plugin = async (args) => {
|
||||
|
||||
jobLog(`[transcode2PassX265] Output: ${outFile}\n`);
|
||||
|
||||
// ── Video filter ──────────────────────────────────────────────────────────
|
||||
// ── 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',
|
||||
@@ -154,7 +190,7 @@ const plugin = async (args) => {
|
||||
|
||||
// ── Common video args ─────────────────────────────────────────────────────
|
||||
const tuneArgs = x265Tune ? ['-tune', x265Tune] : [];
|
||||
const videoArgs = [
|
||||
const encodeVideoArgs = [
|
||||
'-vf', vf,
|
||||
'-c:v', 'libx265',
|
||||
'-b:v', vBitrate,
|
||||
@@ -162,6 +198,52 @@ const plugin = async (args) => {
|
||||
...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 ════════════════════════════════════════════════════════════════
|
||||
|
||||
Reference in new issue
Block a user