From 36184fbf3243868fae00a4b32ccc124f861291c0 Mon Sep 17 00:00:00 2001 From: PickleRick Date: Sat, 19 Sep 2026 23:10:20 +0200 Subject: [PATCH] fix: enhance copyToWorkDir to log progress and improve video bitrate estimation in transcoding --- .../utility/copyToWorkDir/1.0.0/index.js | 19 ++++++- .../video/transcode2PassX265/1.0.0/index.js | 50 ++++++++++++++++--- tests/quick-logic-test.js | 6 +++ 3 files changed, 68 insertions(+), 7 deletions(-) diff --git a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js index b4d53e3..41eaf7b 100644 --- a/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js +++ b/LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js @@ -82,9 +82,13 @@ const plugin = async (args) => { } // ── Copy using streams (no RAM spike for large files) ──────────────────── - jobLog(`[copyToWorkDir] Copying to: ${localCopy}\n`); + const sourceSize = fs.statSync(inputFile).size; + const sourceSizeMb = (sourceSize / 1024 / 1024).toFixed(1); + jobLog(`[copyToWorkDir] Copying ${sourceSizeMb} MB to: ${localCopy}\n`); const startMs = Date.now(); + let copiedBytes = 0; + let nextProgressPercent = 10; await new Promise((resolve, reject) => { const rd = fs.createReadStream(inputFile); @@ -93,6 +97,19 @@ const plugin = async (args) => { rd.on('error', reject); wr.on('error', reject); wr.on('finish', resolve); + rd.on('data', (chunk) => { + copiedBytes += chunk.length; + if (sourceSize <= 0) return; + + const percent = Math.floor((copiedBytes / sourceSize) * 100); + if (percent < nextProgressPercent) return; + + const elapsedSeconds = Math.max((Date.now() - startMs) / 1000, 0.1); + const copiedMb = (copiedBytes / 1024 / 1024).toFixed(1); + const speedMbPerSecond = (copiedBytes / 1024 / 1024 / elapsedSeconds).toFixed(1); + jobLog(`[copyToWorkDir] Progress: ${Math.min(percent, 100)}% (${copiedMb}/${sourceSizeMb} MB, ${speedMbPerSecond} MB/s)\n`); + nextProgressPercent = (Math.floor(percent / 10) + 1) * 10; + }); rd.pipe(wr); }); diff --git a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js index 38d8a2b..d736014 100644 --- a/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js +++ b/LocalFlowPlugins/video/transcode2PassX265/1.0.0/index.js @@ -107,12 +107,48 @@ const buildAudioArgs = (variables) => { return [...mapArgs, ...codecArgs]; }; -const shouldCopyVideo = (videoStream, copyHevcBelowKbps) => { +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 = (parseInt(videoStream.bit_rate, 10) || 0) / 1000; + const bitrateKbps = getVideoBitrateKbps(videoStream, streams, format); return copyHevcBelowKbps > 0 && width > 0 && width <= 1920 @@ -142,9 +178,10 @@ const plugin = async (args) => { 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); + const copyVideo = shouldCopyVideo(videoStream, copyHevcBelowKbps, streams, format); // Parse numeric VBV values (parseInt handles trailing 'k') const maxRateNum = parseInt(vMaxRate, 10); @@ -154,7 +191,7 @@ 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); + const sourceBitrateKbps = getVideoBitrateKbps(videoStream, streams, format).toFixed(0); jobLog( `[transcode2PassX265] Video: codec=${videoStream.codec_name || 'unknown'} ` + `${videoStream.width || '?'}x${videoStream.height || '?'} bitrate=${sourceBitrateKbps}k ` @@ -165,8 +202,9 @@ const plugin = async (args) => { const baseName = path.basename(inputFile, path.extname(inputFile)); const outFile = path.join(workDir, `${baseName}_x265.${container}`); const statsFile = path.join(workDir, 'x265_2pass.log'); - // x265 expects forward slashes in x265-params on all platforms - const statsForX265 = statsFile.replace(/\\/g, '/'); + // 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`); diff --git a/tests/quick-logic-test.js b/tests/quick-logic-test.js index 183bcfa..671dd06 100644 --- a/tests/quick-logic-test.js +++ b/tests/quick-logic-test.js @@ -200,8 +200,14 @@ const AUDIO_INPUTS = { 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'); + ok(transcodeCode.includes("normalizedKey === 'bps' || normalizedKey === 'bps-eng'"), 'HEVC copy check falls back to Matroska BPS metadata'); + ok(transcodeCode.includes('containerBitrate - otherStreamBitrate'), 'HEVC copy check estimates video bitrate from container and companion streams'); ok(!transcodeCode.includes("'-map_metadata -1'"), 'pass 1 separates -map_metadata from its value'); ok(!transcodeCode.includes("'-map_chapters -1'"), 'pass 1 separates -map_chapters from its value'); + ok(transcodeCode.includes('const statsForX265 = path.basename(statsFile)'), 'x265 stats file is relative to workDir on Windows'); + + const copyPluginCode = require('fs').readFileSync(path.join(ROOT, 'LocalFlowPlugins/utility/copyToWorkDir/1.0.0/index.js'), 'utf8'); + ok(copyPluginCode.includes('[copyToWorkDir] Progress:'), 'copyToWorkDir reports copy progress in the job log'); // ── Summary ─────────────────────────────────────────────────────────────── console.log(`\n${'='.repeat(55)}`);