fix: enhance 2-pass x265 encoding to copy compatible HEVC video below specified bitrate limit
Build / Lint & Test (push) Successful in 15s

This commit is contained in:
PickleRick committed 2026-09-19 22:29:00 +02:00
1 parent 2a39da6d08
commit ee5fc99820
3 files changed
+92 -6

No files matched your search

@@ -7,7 +7,8 @@ const fs = require('fs');
/** /**
* transcode2PassX265 * 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) * - Bound-1080p Lanczos scaling (no letterbox, even dimensions)
* - VBV rate control from args.variables * - VBV rate control from args.variables
* - Dynamic Opus audio mapping (ITA default, ENG secondary) * - Dynamic Opus audio mapping (ITA default, ENG secondary)
@@ -19,8 +20,9 @@ const fs = require('fs');
const details = () => ({ const details = () => ({
name: '2-Pass x265 Transcode', name: '2-Pass x265 Transcode',
description: description:
'Performs 2-pass libx265 encoding using parameters from args.variables. ' 'Performs 2-pass libx265 encoding using parameters from args.variables, '
+ 'Applies bound-1080p Lanczos scaling, Opus audio re-encoding, ' + '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).', + 'and chapter preservation. Cross-platform (Linux server / Windows worker).',
style: { borderColor: '#E91E63' }, style: { borderColor: '#E91E63' },
tags: 'video,ffmpeg,x265', tags: 'video,ffmpeg,x265',
@@ -49,6 +51,16 @@ const details = () => ({
inputUI: { type: 'text' }, inputUI: { type: 'text' },
tooltip: 'Output file container extension (mkv recommended).', 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: [ outputs: [
{ number: 1, tooltip: 'Transcode complete — output file ready' }, { number: 1, tooltip: 'Transcode complete — output file ready' },
@@ -95,6 +107,19 @@ const buildAudioArgs = (variables) => {
return [...mapArgs, ...codecArgs]; 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 ─────────────────────────────────────────────────────────────────── // ─── Plugin ───────────────────────────────────────────────────────────────────
const plugin = async (args) => { const plugin = async (args) => {
@@ -115,6 +140,11 @@ const plugin = async (args) => {
const x265Preset = args.inputs.x265Preset || 'slow'; const x265Preset = args.inputs.x265Preset || 'slow';
const container = args.inputs.outputContainer || 'mkv'; 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') // Parse numeric VBV values (parseInt handles trailing 'k')
const maxRateNum = parseInt(vMaxRate, 10); const maxRateNum = parseInt(vMaxRate, 10);
@@ -124,6 +154,12 @@ const plugin = async (args) => {
`[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k ` `[transcode2PassX265] Params: bitrate=${vBitrate} maxrate=${maxRateNum}k `
+ `bufsize=${bufSizeNum}k keyint=${gopKeyint} tune=${x265Tune || 'none'} preset=${x265Preset}\n`, + `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 ───────────────────────────────────────────────────────────────── // ── Paths ─────────────────────────────────────────────────────────────────
const baseName = path.basename(inputFile, path.extname(inputFile)); const baseName = path.basename(inputFile, path.extname(inputFile));
@@ -135,7 +171,7 @@ const plugin = async (args) => {
jobLog(`[transcode2PassX265] Output: ${outFile}\n`); jobLog(`[transcode2PassX265] Output: ${outFile}\n`);
// ── Video filter ────────────────────────────────────────────────────────── // ── Video filter / args ───────────────────────────────────────────────────
const vf = [ const vf = [
"scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease", "scale='min(1920,iw)':'min(1080,ih)':force_original_aspect_ratio=decrease",
'scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos', 'scale=trunc(iw/2)*2:trunc(ih/2)*2:flags=lanczos',
@@ -154,7 +190,7 @@ const plugin = async (args) => {
// ── Common video args ───────────────────────────────────────────────────── // ── Common video args ─────────────────────────────────────────────────────
const tuneArgs = x265Tune ? ['-tune', x265Tune] : []; const tuneArgs = x265Tune ? ['-tune', x265Tune] : [];
const videoArgs = [ const encodeVideoArgs = [
'-vf', vf, '-vf', vf,
'-c:v', 'libx265', '-c:v', 'libx265',
'-b:v', vBitrate, '-b:v', vBitrate,
@@ -162,6 +198,52 @@ const plugin = async (args) => {
...tuneArgs, ...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'); const { CLI } = require('../../../../FlowHelpers/1.0.0/cliUtils');
// ══ PASS 1 ════════════════════════════════════════════════════════════════ // ══ PASS 1 ════════════════════════════════════════════════════════════════
+2 -1
View File
@@ -223,7 +223,7 @@ OCR failures are logged as **warnings** without stopping the pipeline.
Performs 2-pass libx265 encoding using VBV parameters from `args.variables`. Performs 2-pass libx265 encoding using VBV parameters from `args.variables`.
- **Video**: bound-1080p Lanczos scaling (no letterbox, even dimensions) - **Video**: HEVC already within 1080p and strictly below 3000 kbps is copied; all other video is bound-1080p Lanczos scaled and x265-encoded (no letterbox, even dimensions)
- **Audio**: Opus re-encode with per-track indexed bitrate (ITA default, ENG secondary) - **Audio**: Opus re-encode with per-track indexed bitrate (ITA default, ENG secondary)
- **Subtitles**: stripped (`-sn`) - **Subtitles**: stripped (`-sn`)
- **Chapters**: preserved (`-map_chapters 0`) - **Chapters**: preserved (`-map_chapters 0`)
@@ -233,6 +233,7 @@ Performs 2-pass libx265 encoding using VBV parameters from `args.variables`.
|---|---|---| |---|---|---|
| `x265Preset` | `slow` | x265 preset (`ultrafast` … `veryslow`). Slower = better quality. | | `x265Preset` | `slow` | x265 preset (`ultrafast` … `veryslow`). Slower = better quality. |
| `outputContainer` | `mkv` | Output file extension | | `outputContainer` | `mkv` | Output file extension |
| `copyHevcBelowKbps` | `3000` | Copy compatible HEVC video below this bitrate instead of re-encoding it. `0` disables copying. |
**Outputs**: `1` → transcode complete **Outputs**: `1` → transcode complete
+3
View File
@@ -197,6 +197,9 @@ const AUDIO_INPUTS = {
ok(transcodeCode.includes('`-b:a:${audioTrackOut}`'), 'buildAudioArgs uses indexed -b:a:N (not global -b:a)'); ok(transcodeCode.includes('`-b:a:${audioTrackOut}`'), 'buildAudioArgs uses indexed -b:a:N (not global -b:a)');
ok(transcodeCode.includes('[...mapArgs, ...codecArgs]'), 'buildAudioArgs: maps first, then codec settings'); ok(transcodeCode.includes('[...mapArgs, ...codecArgs]'), 'buildAudioArgs: maps first, then codec settings');
ok(!transcodeCode.includes("'-c:a', 'libopus'"), 'no unindexed -c:a in buildAudioArgs'); ok(!transcodeCode.includes("'-c:a', 'libopus'"), 'no unindexed -c:a in buildAudioArgs');
ok(transcodeCode.includes('bitrateKbps < copyHevcBelowKbps'), 'HEVC copy requires bitrate strictly below the limit');
ok(transcodeCode.includes("copyHevcBelowKbps || '3000'"), 'HEVC copy limit defaults to 3000 kbps');
ok(transcodeCode.includes("'-c:v', 'copy'"), 'compatible HEVC video uses stream copy');
// ── Summary ─────────────────────────────────────────────────────────────── // ── Summary ───────────────────────────────────────────────────────────────
console.log(`\n${'='.repeat(55)}`); console.log(`\n${'='.repeat(55)}`);