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
|
* 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 ════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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)}`);
|
||||||
|
|||||||
Reference in new issue
Block a user