diff --git a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js index 9c7202f..0bdf7e3 100644 --- a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js +++ b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js @@ -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 ════════════════════════════════════════════════════════════════ diff --git a/README.md b/README.md index 1c438db..ad99883 100644 --- a/README.md +++ b/README.md @@ -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`. -- **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) - **Subtitles**: stripped (`-sn`) - **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. | | `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 diff --git a/tests/quick-logic-test.js b/tests/quick-logic-test.js index 4f438a9..8acfd2c 100644 --- a/tests/quick-logic-test.js +++ b/tests/quick-logic-test.js @@ -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('[...mapArgs, ...codecArgs]'), 'buildAudioArgs: maps first, then codec settings'); 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 ─────────────────────────────────────────────────────────────── console.log(`\n${'='.repeat(55)}`);