Files
tdarr-plugs/Local/Tdarr_Plugin_av1_svt_converter.js
Tdarr Plugin Developer fe125da51a Fix ab-av1 ffprobe issue by passing tdarr-ffmpeg path explicitly (v2.32)
- ab-av1 now uses --ffmpeg and --ffprobe flags to locate tdarr-ffmpeg
- No symlinks needed - uses existing Tdarr binaries
- tdarr-ffmpeg acts as both ffmpeg and ffprobe (multi-call binary)
2025-12-15 22:33:30 -08:00

1085 lines
40 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const details = () => ({
id: 'Tdarr_Plugin_av1_svt_converter',
Stage: 'Pre-processing',
Name: 'Convert to AV1 SVT-AV1',
Type: 'Video',
Operation: 'Transcode',
Description: `
AV1 conversion plugin with advanced quality control for SVT-AV1 v3.0+ (2025).
**Rate Control Modes**: VBR (predictable file sizes), CRF (quality-based), or VMAF (quality-targeted with ab-av1 crf-search).
Features resolution-aware CRF, source-relative bitrate strategies, ab-av1 auto-CRF, and performance optimizations.
**Balanced defaults**: Preset 6, CRF 26, tune 0 (VQ), 10-bit, SCD 1, AQ 2, lookahead -1, TF on, keyint -2, fast-decode 0.
`,
Version: '2.32',
Tags: 'video,av1,svt,quality,performance,speed-optimized,vbr,crf,vmaf',
Inputs: [
{
name: 'crf',
type: 'string',
defaultValue: '26*',
inputUI: {
type: 'dropdown',
options: [
'22',
'24',
'26*',
'28',
'30',
'32',
'34',
'36',
'38',
'40',
'42'
],
},
tooltip: 'Quality setting (CRF). Higher = faster encoding, lower quality. (default: 26 for 1080p) 2428 = high quality, 30+ = faster/transcoding. 1020 = archival. For 4K, add +2; for 720p, subtract 2. [SVT-AV1 v3.0+]',
},
{
name: 'custom_maxrate',
type: 'string',
defaultValue: '0',
inputUI: {
type: 'text',
},
tooltip: 'Maximum bitrate in kbps (0 or empty = unlimited). Used when target_bitrate_strategy is \'static\'. Capped CRF saves bandwidth on easy scenes while preserving quality on complex ones.',
},
{
name: 'target_bitrate_strategy',
type: 'string',
defaultValue: 'static*',
inputUI: {
type: 'dropdown',
options: [
'static*',
'match_source',
'75%_source',
'50%_source',
'33%_source',
'25%_source'
],
},
tooltip: 'Target bitrate strategy. \'static\' uses custom_maxrate. Other options set target/maxrate relative to detected source bitrate.',
},
{
name: 'rate_control_mode',
type: 'string',
defaultValue: 'crf*',
inputUI: {
type: 'dropdown',
options: [
'crf*',
'vbr',
'vmaf'
],
},
tooltip: 'Rate control mode. \'crf\' = Quality-based (CRF + maxrate cap), \'vbr\' = Bitrate-based (target average + maxrate peaks), \'vmaf\' = Quality-targeted (ab-av1 auto CRF selection, requires ab-av1 binary).',
},
{
name: 'vmaf_target',
type: 'string',
defaultValue: '95*',
inputUI: {
type: 'dropdown',
options: [
'85',
'90',
'95*',
'97',
'99'
],
},
tooltip: 'Target VMAF quality score (vmaf mode only). Higher = better quality but larger files. 95 = visually transparent (recommended), 90 = good quality, 85 = acceptable quality.',
},
{
name: 'vmaf_samples',
type: 'string',
defaultValue: '4*',
inputUI: {
type: 'dropdown',
options: [
'2',
'4*',
'6',
'8'
],
},
tooltip: 'Number of sample segments for ab-av1 quality analysis (vmaf mode only). More samples = more accurate CRF selection but slower analysis. 4 samples is a good balance.',
},
{
name: 'max_resolution',
type: 'string',
defaultValue: 'none*',
inputUI: {
type: 'dropdown',
options: [
'none*',
'480p',
'720p',
'1080p',
'1440p',
'2160p'
],
},
tooltip: 'Maximum output resolution. Videos exceeding this will be downscaled while maintaining aspect ratio. CRF adjustment (if enabled) applies to output resolution.',
},
{
name: 'resolution_crf_adjust',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Auto-adjust CRF based on resolution: 4K gets +2 CRF, 1080p baseline, 720p gets -2 CRF. Improves efficiency with minimal quality impact.',
},
{
name: 'preset',
type: 'string',
defaultValue: '6*',
inputUI: {
type: 'dropdown',
options: [
'-1',
'0',
'1',
'2',
'3',
'4',
'5',
'6*',
'7',
'8',
'9',
'10',
'11',
'12'
],
},
tooltip: 'SVT-AV1 preset. (default: 6) 6 = balanced speed/quality, 10 = fastest (real-time), 89 = very fast, 34 = best quality but slow. Higher = faster, lower = better quality. [v3.0+]',
},
{
name: 'tune',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'2'
],
},
tooltip: 'Tuning mode. (default: 0 VQ) 0 = VQ (best visual quality), 1 = PSNR (faster), 2 = SSIM (slowest). [v3.0+]',
},
{
name: 'scd',
type: 'string',
defaultValue: '1*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1*'
],
},
tooltip: 'Scene Change Detection. (default: 1) 0 = Off (fastest), 1 = On (better keyframe placement, ~510% slower).',
},
{
name: 'aq_mode',
type: 'string',
defaultValue: '2*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1',
'2*'
],
},
tooltip: 'Adaptive Quantization. (default: 2) 0 = Off (fastest), 1 = Variance AQ (better quality, minor speed loss), 2 = DeltaQ AQ (best quality, 1020% slower).',
},
{
name: 'lookahead',
type: 'string',
defaultValue: '-1*',
inputUI: {
type: 'dropdown',
options: [
'-1*',
'0',
'60',
'90',
'120'
],
},
tooltip: 'Lookahead frames. (default: -1) 0 = Off (fastest), -1 = Auto (good compromise), higher = better quality, slower encoding.',
},
{
name: 'enable_tf',
type: 'string',
defaultValue: '1*',
inputUI: {
type: 'dropdown',
options: [
'0',
'1*'
],
},
tooltip: 'Temporal Filtering. (default: 1) 0 = Off (fastest), 1 = On (better noise reduction/quality, ~1525% slower).',
},
{
name: 'threads',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'2',
'3',
'4',
'5',
'6',
'7',
'8',
'12',
'16',
'24',
'32'
],
},
tooltip: 'Number of encoding threads. 0 = Auto (use all cores, recommended). SVT-AV1 scales well with more threads.',
},
{
name: 'keyint',
type: 'string',
defaultValue: '-2*',
inputUI: {
type: 'dropdown',
options: [
'-2*',
'-1',
'120',
'240',
'360',
'480',
'600',
'720',
'900',
'1200'
],
},
tooltip: 'Keyframe interval. (default: -2 ≈5s) -2=~5 seconds, -1=infinite (CRF only), higher = smaller files but worse seeking; lower = better quality/seeking, larger files.',
},
{
name: 'hierarchical_levels',
type: 'string',
defaultValue: '4*',
inputUI: {
type: 'dropdown',
options: [
'2',
'3',
'4*',
'5'
],
},
tooltip: 'Hierarchical levels: 2=3 temporal layers, 3=4 temporal layers, 4=5 temporal layers (recommended), 5=6 temporal layers. Controls GOP structure complexity.',
},
{
name: 'film_grain',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1',
'5',
'10',
'15',
'20',
'25',
'30',
'35',
'40',
'45',
'50'
],
},
tooltip: 'Film grain synthesis: 0 = Off (fastest), 150 = denoising level (slower, more natural grain).',
},
{
name: 'input_depth',
type: 'string',
defaultValue: '10*',
inputUI: {
type: 'dropdown',
options: [
'8',
'10*'
],
},
tooltip: 'Output bit depth: 8 = faster encoding, 10 = better quality (prevents banding), ~10-20% slower. Recommended: 10-bit for high-quality sources.',
},
{
name: 'fast_decode',
type: 'string',
defaultValue: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'1'
],
},
tooltip: 'Fast decode optimization. (default: 0) 1 = moderate decode speed improvement, 0 = off (best compression). [v3.0+]',
},
{
name: 'container',
type: 'string',
defaultValue: 'mp4*',
inputUI: {
type: 'dropdown',
options: [
'mp4*',
'mkv',
'webm',
'original'
],
},
tooltip: 'Output container format. "mp4" = best compatibility. "original" keeps input container.',
},
{
name: 'skip_hevc',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Skip HEVC/H.265 files without converting. Useful if you want to handle HEVC files separately or they are already efficient.',
},
{
name: 'force_transcode',
type: 'string',
defaultValue: 'disabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled*',
'enabled'
],
},
tooltip: 'Force transcoding even if the file is already AV1. Useful for changing quality or preset.',
}
],
});
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
// Initialize response first for error handling
const response = {
processFile: false,
preset: '',
container: '',
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
// eslint-disable-next-line @typescript-eslint/no-unused-vars,no-param-reassign
inputs = lib.loadDefaultValues(inputs, details);
// Strip UI star markers from input values (Tdarr uses '*' to indicate default values in UI)
const stripStar = (value) => {
if (typeof value === 'string') {
return value.replace(/\*/g, '');
}
return value;
};
// Sanitize UI-starred defaults
const sanitized = {
crf: stripStar(inputs.crf),
preset: stripStar(inputs.preset),
tune: stripStar(inputs.tune),
scd: stripStar(inputs.scd),
aq_mode: stripStar(inputs.aq_mode),
threads: stripStar(inputs.threads),
keyint: stripStar(inputs.keyint),
hierarchical_levels: stripStar(inputs.hierarchical_levels),
film_grain: stripStar(inputs.film_grain),
input_depth: stripStar(inputs.input_depth),
fast_decode: stripStar(inputs.fast_decode),
lookahead: stripStar(inputs.lookahead),
enable_tf: stripStar(inputs.enable_tf),
container: stripStar(inputs.container),
max_resolution: stripStar(inputs.max_resolution),
resolution_crf_adjust: stripStar(inputs.resolution_crf_adjust),
custom_maxrate: stripStar(inputs.custom_maxrate),
target_bitrate_strategy: stripStar(inputs.target_bitrate_strategy),
rate_control_mode: stripStar(inputs.rate_control_mode),
skip_hevc: stripStar(inputs.skip_hevc),
force_transcode: stripStar(inputs.force_transcode),
vmaf_target: stripStar(inputs.vmaf_target),
vmaf_samples: stripStar(inputs.vmaf_samples),
};
// Detect ab-av1 binary path with multi-level fallback
const getAbAv1Path = () => {
const fs = require('fs');
const { execSync } = require('child_process');
// Try environment variable first
const envPath = (process.env.ABAV1_PATH || '').trim();
if (envPath) {
try {
if (fs.existsSync(envPath)) {
// Try to check executable, but don't fail if check errors
try {
fs.accessSync(envPath, fs.constants.X_OK);
} catch (accessErr) {
// File exists but X_OK check failed - try anyway (Docker mount issue)
response.infoLog += `Note: ab-av1 at ${envPath} exists but X_OK check failed, trying anyway.\n`;
}
return envPath;
}
} catch (e) {
// Continue to next detection method
}
}
// Try common installation paths
const commonPaths = [
'/usr/local/bin/ab-av1',
'/usr/bin/ab-av1',
];
for (const checkPath of commonPaths) {
try {
if (fs.existsSync(checkPath)) {
// Try to check executable, but don't fail if check errors
try {
fs.accessSync(checkPath, fs.constants.X_OK);
} catch (accessErr) {
// File exists but X_OK check failed - try anyway (Docker mount issue)
response.infoLog += `Note: ab-av1 at ${checkPath} exists but X_OK check failed, trying anyway.\n`;
}
return checkPath;
}
} catch (e) {
// Continue to next path
}
}
// Fallback: Try 'which' command to find ab-av1 in PATH
try {
const whichResult = execSync('which ab-av1', { encoding: 'utf8', timeout: 5000 }).trim();
if (whichResult && fs.existsSync(whichResult)) {
response.infoLog += `Found ab-av1 via 'which': ${whichResult}\n`;
return whichResult;
}
} catch (e) {
// which failed or ab-av1 not in PATH
}
// Not found in any known location
return null;
};
// Execute ab-av1 crf-search synchronously to find optimal CRF for target VMAF
// Returns { success: boolean, crf: number|null, vmaf: number|null, error: string|null }
const executeAbAv1CrfSearch = (abav1Path, inputFile, vmafTarget, sampleCount, preset) => {
const { execSync } = require('child_process');
try {
// Build ab-av1 command
// --min-vmaf is the target VMAF score to achieve
// --samples controls how many sample segments to test
// --encoder specifies the encoder (libsvtav1 for FFmpeg)
const args = [
'crf-search',
'-i', `"${inputFile}"`,
'--min-vmaf', vmafTarget.toString(),
'--samples', sampleCount.toString(),
'--encoder', 'libsvtav1',
'--preset', preset.toString(),
'--ffmpeg', 'tdarr-ffmpeg', // Use Tdarr's ffmpeg binary
'--ffprobe', 'tdarr-ffmpeg', // tdarr-ffmpeg acts as both ffmpeg and ffprobe
];
const command = `${abav1Path} ${args.join(' ')}`;
// Execute with timeout (5 minutes should be enough for sample encodes)
const output = execSync(command, {
encoding: 'utf8',
timeout: 300000, // 5 minute timeout
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
stdio: ['pipe', 'pipe', 'pipe']
});
// Parse ab-av1 output for CRF value
// Expected format: "crf 28, VMAF 95.2" or similar
// Also matches: "Best crf: 28" or "crf: 28 vmaf: 95.2"
const crfMatch = output.match(/(?:crf|CRF)[:\s]+(\d+)/i);
const vmafMatch = output.match(/(?:vmaf|VMAF)[:\s]+([\d.]+)/i);
if (crfMatch) {
return {
success: true,
crf: parseInt(crfMatch[1]),
vmaf: vmafMatch ? parseFloat(vmafMatch[1]) : null,
error: null,
output: output.substring(0, 500) // Truncate for logging
};
} else {
return {
success: false,
crf: null,
vmaf: null,
error: 'Could not parse CRF from ab-av1 output',
output: output.substring(0, 500)
};
}
} catch (error) {
// Handle execution errors
let errorMsg = error.message;
if (error.killed) {
errorMsg = 'ab-av1 timed out after 5 minutes';
} else if (error.status) {
errorMsg = `ab-av1 exited with code ${error.status}`;
}
return {
success: false,
crf: null,
vmaf: null,
error: errorMsg,
output: error.stderr ? error.stderr.substring(0, 500) : ''
};
}
};
// Detect actual input container format via ffprobe
const actualFormatName = file.ffProbeData?.format?.format_name || '';
const looksLikeAppleMp4Family = actualFormatName.includes('mov') || actualFormatName.includes('mp4');
// Detect Apple/broadcast streams that are problematic in MKV or missing codec name
const unsupportedSubtitleIdx = [];
const unsupportedDataIdx = [];
try {
file.ffProbeData.streams.forEach((s, idx) => {
if (s.codec_type === 'subtitle') {
const name = (s.codec_name || '').toLowerCase();
const tag = (s.codec_tag_string || '').toLowerCase();
if (!name) {
// skip subs missing codec_name (e.g., WEBVTT detection failures)
unsupportedSubtitleIdx.push(idx);
} else if (name === 'eia_608' || name === 'cc_dec') {
unsupportedSubtitleIdx.push(idx);
} else if (name === 'tx3g' || tag === 'tx3g') {
// tx3g sometimes shows as timed text in MP4; in mkv it may appear as bin_data
unsupportedSubtitleIdx.push(idx);
}
} else if (s.codec_type === 'data') {
const name = (s.codec_name || '').toLowerCase();
const tag = (s.codec_tag_string || '').toLowerCase();
if (name === 'bin_data' || tag === 'tx3g') {
unsupportedDataIdx.push(idx);
}
}
});
} catch (e) {
// ignore detection errors, continue safely
}
// Check if file is already AV1 and skip if not forcing transcode
const isAV1 = file.ffProbeData.streams.some(stream =>
stream.codec_type === 'video' &&
(stream.codec_name === 'av01' || stream.codec_name === 'av1' || stream.codec_name === 'libsvtav1')
);
if (isAV1 && sanitized.force_transcode !== 'enabled') {
response.processFile = false;
response.infoLog += 'File is already AV1 encoded and force_transcode is disabled. Skipping.\n';
return response;
}
// Check if file is HEVC and skip if skip_hevc is enabled
const isHEVC = file.ffProbeData.streams.some(stream =>
stream.codec_type === 'video' &&
(stream.codec_name === 'hevc' || stream.codec_name === 'h265' || stream.codec_name === 'libx265')
);
if (isHEVC && sanitized.skip_hevc === 'enabled') {
response.processFile = false;
response.infoLog += 'File is HEVC/H.265 encoded and skip_hevc is enabled. Skipping for separate processing.\n';
return response;
}
// Validate video stream exists
const videoStream = file.ffProbeData.streams.find(s => s.codec_type === 'video');
if (!videoStream) {
response.processFile = false;
response.infoLog += 'Error: No video stream found in file. Skipping.\n';
return response;
}
// Use specified preset
const finalPreset = sanitized.preset;
response.infoLog += `Using preset ${finalPreset} (speed-optimized default).\n`;
// Use specified thread count
const threadCount = sanitized.threads;
response.infoLog += `Using ${threadCount} encoding threads.\n`;
// Resolution mapping and downscaling logic
const resolutionMap = {
'480p': 480,
'720p': 720,
'1080p': 1080,
'1440p': 1440,
'2160p': 2160
};
// videoStream was validated and assigned earlier (after HEVC skip check)
let scaleFilter = '';
let outputHeight = null;
// Detect HDR metadata for color preservation
let hdrArgs = '';
const colorTransfer = videoStream.color_transfer || '';
const colorPrimaries = videoStream.color_primaries || '';
const colorSpace = videoStream.color_space || '';
// Check for HDR10, HLG, or PQ transfer characteristics
const isHDR10 = colorTransfer === 'smpte2084'; // PQ
const isHLG = colorTransfer === 'arib-std-b67'; // HLG
const isHDR = (isHDR10 || isHLG) && (
colorPrimaries === 'bt2020' ||
colorSpace === 'bt2020nc' ||
colorSpace === 'bt2020c'
);
if (isHDR) {
// Preserve HDR color metadata
hdrArgs = ` -colorspace ${colorSpace || 'bt2020nc'} -color_trc ${colorTransfer || 'smpte2084'} -color_primaries ${colorPrimaries || 'bt2020'}`;
response.infoLog += `HDR content detected (${colorTransfer}/${colorPrimaries}), preserving color metadata.\n`;
}
if (videoStream && videoStream.height && sanitized.max_resolution !== 'none') {
const inputHeight = videoStream.height;
const maxHeight = resolutionMap[sanitized.max_resolution];
if (maxHeight && inputHeight > maxHeight) {
// Downscale needed - use scale filter with -2 to maintain aspect ratio and ensure even dimensions
outputHeight = maxHeight;
scaleFilter = `-vf "scale=-2:${maxHeight}"`;
response.infoLog += `Downscaling from ${inputHeight}p to ${maxHeight}p while maintaining aspect ratio.\n`;
} else if (maxHeight) {
// Input is already at or below max resolution
outputHeight = inputHeight;
response.infoLog += `Input resolution ${inputHeight}p is within max limit of ${maxHeight}p, no downscaling needed.\n`;
} else {
// No max resolution set
outputHeight = inputHeight;
}
} else if (videoStream && videoStream.height) {
// No max resolution constraint
outputHeight = videoStream.height;
}
// Resolution-based CRF adjustment (applies to OUTPUT resolution after any downscaling)
let finalCrf = sanitized.crf;
if (sanitized.resolution_crf_adjust === 'enabled' && outputHeight) {
const baseCrf = parseInt(sanitized.crf);
// Validate CRF is a valid number
if (isNaN(baseCrf) || baseCrf < 0 || baseCrf > 63) {
response.infoLog += `Warning: Invalid CRF value "${sanitized.crf}", using default.\n`;
finalCrf = '26';
} else {
if (outputHeight >= 2160) { // 4K
finalCrf = Math.min(63, baseCrf + 2).toString();
response.infoLog += `4K output resolution detected, CRF adjusted from ${sanitized.crf} to ${finalCrf}.\n`;
} else if (outputHeight <= 720) { // 720p or lower
finalCrf = Math.max(1, baseCrf - 2).toString();
response.infoLog += `720p or lower output resolution detected, CRF adjusted from ${sanitized.crf} to ${finalCrf}.\n`;
} else {
response.infoLog += `1080p output resolution detected, using base CRF ${finalCrf}.\n`;
}
}
} else if (sanitized.resolution_crf_adjust === 'enabled') {
response.infoLog += `Resolution-based CRF adjustment enabled but video height not detected, using base CRF ${finalCrf}.\n`;
}
// Build SVT-AV1 parameters string
const svtParams = [
`preset=${finalPreset}`,
`tune=${sanitized.tune}`,
`scd=${sanitized.scd}`,
`aq-mode=${sanitized.aq_mode}`,
`lp=${threadCount}`,
`keyint=${sanitized.keyint}`,
`hierarchical-levels=${sanitized.hierarchical_levels}`,
`film-grain=${sanitized.film_grain}`,
`input-depth=${sanitized.input_depth}`,
`fast-decode=${sanitized.fast_decode}`,
`lookahead=${sanitized.lookahead}`,
`enable-tf=${sanitized.enable_tf}`
].join(':');
// Set up FFmpeg arguments for CRF quality control with fixed qmin/qmax
let qualityArgs = `-crf ${finalCrf} -qmin 10 -qmax 50`;
let bitrateControlInfo = `Using CRF mode with value ${finalCrf}`;
// Explicitly set pixel format for 10-bit to ensure correct encoding
if (sanitized.input_depth === '10') {
qualityArgs += ' -pix_fmt yuv420p10le';
response.infoLog += `10-bit encoding enabled with yuv420p10le pixel format.\n`;
}
// Source bitrate detection for target_bitrate_strategy
let sourceBitrateKbps = null;
if (videoStream) {
// Try to get bitrate from video stream first
if (videoStream.bit_rate) {
sourceBitrateKbps = Math.round(parseInt(videoStream.bit_rate) / 1000);
response.infoLog += `Detected video stream bitrate: ${sourceBitrateKbps}k.\n`;
} else if (file.ffProbeData?.format?.bit_rate) {
// Fall back to overall file bitrate
sourceBitrateKbps = Math.round(parseInt(file.ffProbeData.format.bit_rate) / 1000);
response.infoLog += `Detected file bitrate (video stream bitrate unavailable): ${sourceBitrateKbps}k.\n`;
}
}
// Estimate expected average bitrate for a given CRF and resolution
// Based on SVT-AV1 CRF 30, preset ~6, average movie content (VMAF ~95)
// Lower CRF = higher bitrate (roughly 10-15% increase per CRF step down)
const estimateCrfBitrate = (crf, height) => {
// Baseline bitrates for CRF 30
let baselineCrf30 = 3000; // Default to 1080p
if (height >= 2160) {
baselineCrf30 = 12000; // 4K average
} else if (height >= 1440) {
baselineCrf30 = 6000; // 1440p estimate (between 1080p and 4K)
} else if (height >= 1080) {
baselineCrf30 = 3000; // 1080p average
} else if (height >= 720) {
baselineCrf30 = 2000; // 720p average
} else {
baselineCrf30 = 1200; // 480p average
}
// Adjust for CRF difference from baseline (CRF 30)
// Each CRF step down increases bitrate by ~12%
const crfDiff = 30 - parseInt(crf);
const bitrateFactor = Math.pow(1.12, crfDiff);
return Math.round(baselineCrf30 * bitrateFactor);
};
// Calculate target bitrate and maxrate based on rate control mode
let calculatedTargetBitrate = null; // For VBR mode
let calculatedMaxrate = null; // For both modes
let bitrateSource = '';
// Step 1: Calculate base bitrate from strategy
if (sanitized.target_bitrate_strategy !== 'static') {
if (sourceBitrateKbps) {
let multiplier = 1.0;
switch (sanitized.target_bitrate_strategy) {
case 'match_source':
multiplier = 1.0;
break;
case '75%_source':
multiplier = 0.75;
break;
case '50%_source':
multiplier = 0.50;
break;
case '33%_source':
multiplier = 0.33;
break;
case '25%_source':
multiplier = 0.25;
break;
}
const baseBitrate = Math.round(sourceBitrateKbps * multiplier);
// Step 2: Apply mode-specific logic
if (sanitized.rate_control_mode === 'vbr') {
// VBR Mode: Target average = base, Maxrate = base * 2.0 (headroom for peaks)
calculatedTargetBitrate = baseBitrate;
calculatedMaxrate = Math.round(baseBitrate * 2.0);
bitrateSource = `VBR mode with target_bitrate_strategy '${sanitized.target_bitrate_strategy}': Source ${sourceBitrateKbps}k * ${multiplier} = Target ${calculatedTargetBitrate}k, Maxrate ${calculatedMaxrate}k (2.0x headroom)`;
response.infoLog += `Using ${bitrateSource}.\n`;
} else {
// CRF Mode: Ensure maxrate is higher than what CRF would naturally produce
// Estimate what the CRF will average based on resolution
const estimatedCrfAvg = estimateCrfBitrate(finalCrf, outputHeight || 1080);
// Set maxrate to the higher of: user's calculated value OR 1.8x estimated CRF average
// The 1.8x ensures headroom for peaks above the CRF average
const minSafeMaxrate = Math.round(estimatedCrfAvg * 1.8);
if (baseBitrate < minSafeMaxrate) {
calculatedMaxrate = minSafeMaxrate;
bitrateSource = `CRF mode: Calculated ${baseBitrate}k from strategy, but CRF ${finalCrf} @ ${outputHeight || 1080}p averages ~${estimatedCrfAvg}k. Using Maxrate ${calculatedMaxrate}k (1.8x avg) for headroom`;
response.infoLog += `${bitrateSource}.\n`;
} else {
calculatedMaxrate = baseBitrate;
bitrateSource = `CRF mode with target_bitrate_strategy '${sanitized.target_bitrate_strategy}': Source ${sourceBitrateKbps}k * ${multiplier} = Maxrate ${calculatedMaxrate}k (above CRF estimate)`;
response.infoLog += `Using ${bitrateSource}.\n`;
}
}
} else {
response.infoLog += `Warning: target_bitrate_strategy '${sanitized.target_bitrate_strategy}' selected but source bitrate unavailable. Falling back to static mode.\n`;
}
}
// Priority 2: custom_maxrate (if strategy is static or failed)
if (!calculatedMaxrate && sanitized.custom_maxrate && sanitized.custom_maxrate !== '' && sanitized.custom_maxrate !== '0') {
const customValue = parseInt(sanitized.custom_maxrate);
if (!isNaN(customValue) && customValue > 0) {
if (sanitized.rate_control_mode === 'vbr') {
// VBR mode: Custom value is the target, maxrate = target * 2.0
calculatedTargetBitrate = customValue;
calculatedMaxrate = Math.round(customValue * 2.0);
bitrateSource = `VBR mode with custom_maxrate: Target ${calculatedTargetBitrate}k, Maxrate ${calculatedMaxrate}k (2.0x headroom)`;
response.infoLog += `Using ${bitrateSource}.\n`;
} else {
// CRF mode: Ensure custom maxrate is reasonable for the CRF
const estimatedCrfAvg = estimateCrfBitrate(finalCrf, outputHeight || 1080);
const minSafeMaxrate = Math.round(estimatedCrfAvg * 1.8);
if (customValue < minSafeMaxrate) {
calculatedMaxrate = minSafeMaxrate;
bitrateSource = `CRF mode: Custom ${customValue}k is below safe minimum for CRF ${finalCrf} @ ${outputHeight || 1080}p (est. ~${estimatedCrfAvg}k avg). Using ${calculatedMaxrate}k (1.8x) for headroom`;
response.infoLog += `${bitrateSource}.\n`;
} else {
calculatedMaxrate = customValue;
bitrateSource = `CRF mode with custom_maxrate: Maxrate ${calculatedMaxrate}k`;
response.infoLog += `Using ${bitrateSource}.\n`;
}
}
} else {
response.infoLog += `Warning: Invalid custom_maxrate value '${sanitized.custom_maxrate}'. Using uncapped CRF.\n`;
}
}
// Apply calculated maxrate if any method succeeded
// Enforce minimum bitrate threshold to prevent unusable output (resolution-aware)
const getMinBitrate = (height) => {
if (height >= 2160) return 2000; // 4K
if (height >= 1440) return 1500; // 1440p
if (height >= 1080) return 800; // 1080p
if (height >= 720) return 500; // 720p
return 250; // 480p and below
};
const minBitrate = getMinBitrate(outputHeight || 1080);
// Adjust target and maxrate if below minimum
if (calculatedTargetBitrate && calculatedTargetBitrate < minBitrate) {
response.infoLog += `Warning: Calculated target bitrate ${calculatedTargetBitrate}k is below minimum for ${outputHeight || 1080}p. Raising to ${minBitrate}k.\n`;
calculatedTargetBitrate = minBitrate;
calculatedMaxrate = Math.round(minBitrate * 2.0); // Adjust maxrate proportionally
} else if (calculatedMaxrate && calculatedMaxrate < minBitrate) {
response.infoLog += `Warning: Calculated maxrate ${calculatedMaxrate}k is below minimum for ${outputHeight || 1080}p. Raising to ${minBitrate}k.\n`;
calculatedMaxrate = minBitrate;
}
// Step 3: Build quality/bitrate arguments based on mode
if (sanitized.rate_control_mode === 'vbr' && calculatedTargetBitrate) {
// VBR Mode: Use target bitrate + maxrate
const bufsize = calculatedMaxrate; // Buffer size = maxrate for VBR
qualityArgs += ` -b:v ${calculatedTargetBitrate}k -maxrate ${calculatedMaxrate}k -bufsize ${bufsize}k`;
bitrateControlInfo += ` with VBR target ${calculatedTargetBitrate}k, maxrate ${calculatedMaxrate}k (bufsize: ${bufsize}k)`;
response.infoLog += `VBR encoding: Target average ${calculatedTargetBitrate}k, peak ${calculatedMaxrate}k, buffer ${bufsize}k.\n`;
} else if (sanitized.rate_control_mode === 'vmaf') {
// VMAF Mode: Use ab-av1 for automatic CRF calculation
const abav1Path = getAbAv1Path();
if (!abav1Path) {
response.infoLog += 'VMAF mode selected but ab-av1 binary not found. Falling back to CRF mode.\n';
response.infoLog += 'To use VMAF mode, ensure ab-av1 is installed and accessible (check ABAV1_PATH env var or /usr/local/bin/ab-av1).\n';
// Fall through to standard CRF encoding - use user's configured CRF
bitrateControlInfo = `CRF ${finalCrf} (VMAF fallback - ab-av1 not found)`;
} else {
response.infoLog += `Using ab-av1 for quality-targeted encoding (target VMAF ${sanitized.vmaf_target}).\n`;
response.infoLog += `ab-av1 binary: ${abav1Path}\n`;
const vmafTarget = parseInt(sanitized.vmaf_target);
const sampleCount = parseInt(sanitized.vmaf_samples);
response.infoLog += `Running ab-av1 crf-search to find optimal CRF for VMAF ${vmafTarget}...\n`;
response.infoLog += `Using ${sampleCount} sample segments for quality analysis.\n`;
// Execute ab-av1 crf-search synchronously
const crfResult = executeAbAv1CrfSearch(
abav1Path,
file.file,
vmafTarget,
sampleCount,
finalPreset
);
if (crfResult.success && crfResult.crf !== null) {
// Success! Use the found CRF
const foundCrf = crfResult.crf;
response.infoLog += `✅ ab-av1 found optimal CRF: ${foundCrf}`;
if (crfResult.vmaf) {
response.infoLog += ` (predicted VMAF: ${crfResult.vmaf})`;
}
response.infoLog += '\n';
// Update qualityArgs with the ab-av1 determined CRF
// Replace the CRF in qualityArgs (which was set earlier with user's default)
qualityArgs = qualityArgs.replace(/-crf \d+/, `-crf ${foundCrf}`);
bitrateControlInfo = `VMAF-targeted CRF ${foundCrf} (target VMAF: ${vmafTarget}, achieved: ${crfResult.vmaf || 'unknown'})`;
// Store metadata for logging/debugging
response.abav1CrfResult = foundCrf;
response.abav1VmafResult = crfResult.vmaf;
} else {
// ab-av1 failed - fall back to user's configured CRF
response.infoLog += `⚠️ ab-av1 crf-search failed: ${crfResult.error}\n`;
if (crfResult.output) {
response.infoLog += `ab-av1 output: ${crfResult.output}\n`;
}
response.infoLog += `Falling back to configured CRF ${finalCrf}.\n`;
bitrateControlInfo = `CRF ${finalCrf} (VMAF fallback - ab-av1 failed)`;
}
}
} else if (calculatedMaxrate) {
// CRF Mode with maxrate cap
const bufsize = Math.round(calculatedMaxrate * 2.0); // Buffer size = 2.0x maxrate for stability
qualityArgs += ` -maxrate ${calculatedMaxrate}k -bufsize ${bufsize}k`;
bitrateControlInfo += ` with capped bitrate at ${calculatedMaxrate}k (bufsize: ${bufsize}k)`;
response.infoLog += `Capped CRF enabled: Max bitrate ${calculatedMaxrate}k, buffer size ${bufsize}k for optimal bandwidth management.\n`;
} else {
// Uncapped CRF
response.infoLog += `Using uncapped CRF for maximum quality efficiency.\n`;
}
// Add tile options for 4K content (improves parallel encoding/decoding)
let tileArgs = '';
if (outputHeight && outputHeight >= 2160) {
// 4K: 2x1 tiles = 3 tiles total (better parallelism for 4K encoding)
tileArgs = ':tile-columns=2:tile-rows=1';
response.infoLog += '4K content: Adding tile-columns=2, tile-rows=1 for improved parallelism.\n';
} else if (outputHeight && outputHeight >= 1440) {
// 1440p: 1x0 tiles = 2 tiles total (balanced for 1440p)
tileArgs = ':tile-columns=1:tile-rows=0';
response.infoLog += '1440p content: Adding tile-columns=1 for balanced parallelism.\n';
}
// 1080p and below: No tiles (overhead not worth it)
// Build mapping with per-stream exclusions if needed
let mapArgs = '-map 0';
const hasUnsupportedStreams = unsupportedSubtitleIdx.length > 0 || unsupportedDataIdx.length > 0;
if (hasUnsupportedStreams) {
[...unsupportedSubtitleIdx, ...unsupportedDataIdx].forEach((idx) => {
mapArgs += ` -map -0:${idx}`;
});
response.infoLog += `Excluding unsupported streams from mapping: subtitles[${unsupportedSubtitleIdx.join(', ')}] data[${unsupportedDataIdx.join(', ')}].\n`;
}
// Set up FFmpeg arguments for AV1 SVT conversion
// Use explicit stream mapping instead of -dn to handle data streams precisely
const svtParamsWithTiles = svtParams + tileArgs;
response.preset = `<io>${scaleFilter ? ' ' + scaleFilter : ''} -c:v libsvtav1 ${qualityArgs}${hdrArgs} -svtav1-params "${svtParamsWithTiles}" -c:a copy -c:s copy ${mapArgs}`;
// Set container with Apple-specific handling
// If user asked for MKV but input is MP4/MOV family and has unsupported streams, prefer MP4 to avoid mux errors
if (sanitized.container === 'original') {
response.container = `.${file.container}`;
if (looksLikeAppleMp4Family && response.container === '.mkv' && hasUnsupportedStreams) {
response.infoLog += 'Detected MP4/MOV input with Apple/broadcast streams; overriding output container to .mp4 to preserve compatibility.\n';
response.container = '.mp4';
}
} else {
response.container = `.${sanitized.container}`;
// WebM container validation - warn about potential compatibility
if (sanitized.container === 'webm') {
response.infoLog += 'Note: WebM container selected. Ensure audio is Opus/Vorbis for full compatibility.\n';
if (hasUnsupportedStreams) {
response.infoLog += 'Warning: WebM does not support all subtitle formats. Subtitles may be dropped.\n';
}
}
// MKV container handling with user warning
if (sanitized.container === 'mkv' && (looksLikeAppleMp4Family || hasUnsupportedStreams)) {
response.infoLog += 'Warning: MKV requested but file has Apple/broadcast streams that may cause issues. Consider using MP4 container.\n';
// Don't force override - let user decide, just warn
}
}
response.ffmpegMode = true;
response.handbrakeMode = false;
response.reQueueAfter = true;
response.processFile = true;
if (isAV1) {
response.infoLog += `File is AV1 but force transcoding is enabled. ${bitrateControlInfo}.\n`;
} else if (isHEVC) {
response.infoLog += `Converting HEVC to AV1. ${bitrateControlInfo}.\n`;
} else {
response.infoLog += `Converting ${file.ffProbeData.streams.find(s => s.codec_type === 'video')?.codec_name || 'unknown'} to AV1. ${bitrateControlInfo}.\n`;
}
response.infoLog += `Using SVT-AV1 preset: ${finalPreset}, tune: ${sanitized.tune} (VQ-optimized when 0), threads: ${threadCount}\n`;
response.infoLog += `Encoding params - SCD: ${sanitized.scd}, AQ: ${sanitized.aq_mode}, Lookahead: ${sanitized.lookahead}, TF: ${sanitized.enable_tf}\n`;
response.infoLog += `Quality control - CRF: ${finalCrf}, Fixed QMin: 10, Fixed QMax: 50, Film grain: ${sanitized.film_grain}\n`;
response.infoLog += `Output container: ${response.container}\n`;
return response;
} catch (error) {
// Comprehensive error handling
response.processFile = false;
response.preset = '';
response.container = `.${file.container || 'mkv'}`;
response.reQueueAfter = false;
// Provide detailed error information
response.infoLog = `💥 Plugin error: ${error.message}\n`;
// Add stack trace for debugging (first 5 lines)
if (error.stack) {
const stackLines = error.stack.split('\n').slice(0, 5).join('\n');
response.infoLog += `Stack trace:\n${stackLines}\n`;
}
// Log additional context
response.infoLog += `File: ${file.file}\n`;
response.infoLog += `Container: ${file.container}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;