fix: enhance copyToWorkDir to log progress and improve video bitrate estimation in transcoding
Build / Lint & Test (push) Successful in 16s

This commit is contained in:
PickleRick committed 2026-09-19 23:10:20 +02:00
1 parent 0c8b713325
commit 36184fbf32
3 files changed
+68 -7

No files matched your search

@@ -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);
});
@@ -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`);
+6
View File
@@ -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)}`);