fix: enhance copyToWorkDir to log progress and improve video bitrate estimation in transcoding
Build / Lint & Test (push) Successful in 16s
Build / Lint & Test (push) Successful in 16s
This commit is contained in:
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`);
|
||||
|
||||
@@ -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)}`);
|
||||
|
||||
Reference in new issue
Block a user