feat(proxy): VBR 500k-1M encoding for proxy generation

executor.js:
- transcodeVideo() now accepts videoMinRate, videoMaxRate, videoBufSize
- When set, passes -minrate/-maxrate/-bufsize to FFmpeg for ABR/VBR mode
- libx264 operates with per-scene quality variation within the envelope

proxy.js:
- Target average: 750k (gpu_bitrate_mbps=0.75)
- Min: 375k (50% of target), Max: 998k (~133%), Buffer: 2× max
- Gives effective range of ~500k-1M depending on scene complexity
- Log now shows VBR min-max-avg
- GPU fallback also passes VBR params
- Default videoBitrate changed from 10M to 750k in executor.js
This commit is contained in:
Zac Gaetano 2026-05-26 17:44:18 +00:00
parent 03aa7a0673
commit e4d4c00f52
2 changed files with 35 additions and 18 deletions

View file

@ -94,20 +94,19 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
const {
videoCodec = 'libx264',
videoPreset = 'fast',
videoBitrate = '10M',
rateControl = null, // 'cbr' | 'vbr' | 'cqp' — optional
videoBitrate = '750k', // average/target for VBR
videoMinRate = null, // VBR minimum e.g. '500k'
videoMaxRate = null, // VBR maximum e.g. '1000k'
videoBufSize = null, // VBR buffer e.g. '2000k' (2× maxrate recommended)
rateControl = null, // 'cbr' | 'vbr' | 'cqp' — optional override for HW codecs
audioCodec = 'aac',
audioBitrate = '192k',
audioBitrate = '128k',
hasAudio = true,
} = options;
// libx264 / yuv420p require even dimensions. Captured frames from SDI
// or upstream uploads sometimes arrive odd-sized (e.g. 1243x1125).
// libx264 / yuv420p require even dimensions.
const vf = "scale='trunc(iw/2)*2:trunc(ih/2)*2',format=yuv420p";
// analyzeduration/probesize must be set BEFORE -i. Some ProRes captures
// write unusual timebases (60k tbn) that ffmpeg cannot resolve with the
// default 5MB probe — bump to 100MB so we always read enough of the file.
const args = [
'-analyzeduration', '100M',
'-probesize', '100M',
@ -118,8 +117,14 @@ export const transcodeVideo = async (inputPath, outputPath, options = {}) => {
'-b:v', videoBitrate,
];
// NVENC takes rate control via -rc / -cq. VAAPI uses -rc_mode. libx264
// ignores both (rate is implied by -b:v + -maxrate).
// VBR min/max/bufsize for libx264 ABR mode.
// When minrate+maxrate are set, libx264 operates in ABR with hard limits
// rather than strict CBR — quality varies per-scene within the envelope.
if (videoMinRate) args.push('-minrate', videoMinRate);
if (videoMaxRate) args.push('-maxrate', videoMaxRate);
if (videoBufSize) args.push('-bufsize', videoBufSize);
// NVENC/VAAPI hardware rate control flags
if (rateControl) {
if (NVENC_CODECS.has(videoCodec)) {
args.push('-rc', rateControl);

View file

@ -21,15 +21,26 @@ async function loadProxyEncodingSettings() {
const gpuEnabled = map.gpu_transcode_enabled === 'true';
const codec = map.gpu_codec || (gpuEnabled ? 'h264_nvenc' : 'libx264');
const preset = map.gpu_preset || (gpuEnabled ? 'p4' : 'fast');
const bitrateM = parseFloat(map.gpu_bitrate_mbps || '1.5');
const rcMode = map.gpu_rc_mode || null;
const audioCodec = map.gpu_audio_codec || 'aac';
const audioKbps = parseInt(map.gpu_audio_bitrate_kbps || '128', 10);
// VBR 500k1M: target average 750k, hard cap 1M, buffer 2M.
// libx264 ABR mode — quality varies per-scene within the envelope.
// These are stored in the DB as the bitrate field; min/max derived from it.
const bitrateM = parseFloat(map.gpu_bitrate_mbps || '0.75');
const targetBps = Math.round(bitrateM * 1000); // kbps
const minKbps = Math.round(targetBps * 0.5); // 50% of target
const maxKbps = Math.round(targetBps * 1.33); // 133% of target, capped at ~1M for 750k target
const bufKbps = maxKbps * 2; // 2× maxrate recommended
return {
videoCodec: codec,
videoPreset: preset,
videoBitrate: `${bitrateM}M`,
videoBitrate: `${targetBps}k`,
videoMinRate: `${minKbps}k`,
videoMaxRate: `${maxKbps}k`,
videoBufSize: `${bufKbps}k`,
rateControl: rcMode,
audioCodec,
audioBitrate: `${audioKbps}k`,
@ -161,20 +172,21 @@ export const proxyWorker = async (job) => {
const encSettings = await loadProxyEncodingSettings();
console.log(
`[proxy] Transcoding asset ${assetId} via ${encSettings._gpu ? 'GPU' : 'CPU'} ` +
`(${encSettings.videoCodec} ${encSettings.videoPreset} ${encSettings.videoBitrate})`
`(${encSettings.videoCodec} ${encSettings.videoPreset} VBR ${encSettings.videoMinRate}-${encSettings.videoMaxRate} avg=${encSettings.videoBitrate})`
);
try {
await transcodeVideo(inputPath, outputPath, { ...encSettings, hasAudio });
} catch (err) {
if (encSettings._gpu) {
// Hardware encoder failed — typically "no NVIDIA driver" or "VAAPI
// device not found". Fall back to libx264 so the job doesn't fail
// when the worker host has no GPU.
console.warn(`[proxy] GPU encode failed (${err.message}); falling back to libx264`);
await transcodeVideo(inputPath, outputPath, {
videoCodec: 'libx264', videoPreset: 'fast',
videoBitrate: encSettings.videoBitrate,
audioCodec: encSettings.audioCodec, audioBitrate: encSettings.audioBitrate,
videoMinRate: encSettings.videoMinRate,
videoMaxRate: encSettings.videoMaxRate,
videoBufSize: encSettings.videoBufSize,
audioCodec: encSettings.audioCodec,
audioBitrate: encSettings.audioBitrate,
hasAudio,
});
} else { throw err; }