Files
tdarr-plugs/Local/Tdarr_Plugin_av1_svt_converter.js

894 lines
30 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 simplified quality control for SVT-AV1 v3.0+ (2025).
**Rate Control**: CRF (quality-based, optional maxrate cap) or VBR (bitrate-based with target + maxrate).
**Quality Presets**: Use quality_preset for easy configuration, or set custom CRF/qmin/qmax values.
**Bitrate Awareness**: Optionally skip files that are already very low bitrate to prevent size bloat.
**Source Codec Awareness**: Optionally increase CRF for HEVC sources to prevent re-encoding bloat.
**Note**: Run AFTER stream_cleanup plugin to ensure problematic streams are removed.
v3.18: No code changes - version bump for compatibility with updated audio_standardizer v1.23.
`,
Version: '4.0.0',
Tags: 'video,av1,svt,quality,performance,speed-optimized,vbr,crf',
Inputs: [
{
name: 'quality_preset',
type: 'string',
defaultValue: 'balanced*',
inputUI: {
type: 'dropdown',
options: [
'archival',
'high',
'balanced*',
'efficient',
'custom'
],
},
tooltip: 'Quality presets auto-configure CRF/qmin/qmax. archival=CRF18/qmax35, high=CRF22/qmax40, balanced=CRF28/qmax45, efficient=CRF30/qmax55. Use "custom" to set values manually below.',
},
{
name: 'crf',
type: 'string',
defaultValue: '28*',
inputUI: {
type: 'dropdown',
options: [
'16',
'18',
'20',
'22',
'24',
'26',
'28*',
'30',
'32',
'34',
'36',
'38',
'40',
'42'
],
},
tooltip: 'Quality setting (CRF). Lower = better quality/larger files. 16-20=archival, 22-26=high quality, 28-32=balanced, 34+=efficient. Only used when quality_preset=custom.',
},
{
name: 'qmin',
type: 'string',
defaultValue: '10*',
inputUI: {
type: 'dropdown',
options: [
'1',
'5',
'10*',
'15',
'20'
],
},
tooltip: 'Minimum quantizer (quality ceiling). Lower = allows better quality but may not improve much. Only used when quality_preset=custom.',
},
{
name: 'qmax',
type: 'string',
defaultValue: '45*',
inputUI: {
type: 'dropdown',
options: [
'30',
'35',
'40',
'45*',
'48',
'50',
'55',
'60'
],
},
tooltip: 'Maximum quantizer (quality floor). Lower = prevents excessive compression, larger files. 35=archival, 40=high, 45=balanced, 55=efficient. Only used when quality_preset=custom.',
},
{
name: 'maxrate',
type: 'string',
defaultValue: '0',
inputUI: {
type: 'text',
},
tooltip: 'Maximum bitrate in kbps (0 = unlimited). Optional cap for both CRF and VBR modes. Prevents bitrate spikes. ~3500 kbps for 1080p.',
},
{
name: 'target_bitrate',
type: 'string',
defaultValue: '2200',
inputUI: {
type: 'text',
},
tooltip: 'Target average bitrate in kbps for VBR mode. ~2200 kbps = 1GB/hour @ 1080p. Ignored in CRF mode.',
},
{
name: 'rate_control_mode',
type: 'string',
defaultValue: 'crf*',
inputUI: {
type: 'dropdown',
options: [
'crf*',
'vbr'
],
},
tooltip: 'Rate control mode. \'crf\' = Quality-based (CRF + optional maxrate cap for bandwidth control), \'vbr\' = Bitrate-based (target average bitrate + maxrate peaks for streaming/bandwidth-limited scenarios).',
},
{
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/720p baseline, 480p and below gets +2 CRF. Helps prevent size bloat on low-bitrate SD content.',
},
{
name: 'source_codec_awareness',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Auto-adjust CRF +2 when source is HEVC/H.265 to prevent size bloat from re-encoding already-efficient codecs.',
},
{
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: '0*',
inputUI: {
type: 'dropdown',
options: [
'0*',
'60',
'90',
'120'
],
},
tooltip: 'Lookahead frames. 0 = Off/Auto (fastest, lets SVT-AV1 decide), 60-120 = higher quality but 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: 'original*',
inputUI: {
type: 'dropdown',
options: [
'original*',
'mkv',
'mp4',
'webm'
],
},
tooltip: 'Output container. "original" inherits from Plugin 01 (recommended, avoids subtitle issues). MKV supports all codecs/subs. MP4 for device compatibility (but may drop some subtitle formats).',
},
{
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.',
},
{
name: 'bitrate_awareness',
type: 'string',
defaultValue: 'enabled*',
inputUI: {
type: 'dropdown',
options: [
'disabled',
'enabled*'
],
},
tooltip: 'Skip files that are already lower than the threshold bitrate. Prevents wasting CPU on tiny files that will likely increase in size.',
},
{
name: 'min_source_bitrate',
type: 'string',
defaultValue: '400*',
inputUI: {
type: 'dropdown',
options: [
'150',
'200',
'250',
'300',
'350',
'400*',
'500',
'600',
'800'
],
},
tooltip: 'Minimum source bitrate (kbps). Only used when bitrate_awareness is enabled. 400 kbps is usually the floor for 480p quality.',
}
],
});
// Inline utilities (Tdarr plugins must be self-contained)
const stripStar = (v) => (typeof v === 'string' ? v.replace(/\*/g, '') : v);
const sanitizeInputs = (inputs) => {
Object.keys(inputs).forEach((k) => { inputs[k] = stripStar(inputs[k]); });
return inputs;
};
// Container-aware subtitle compatibility
// Subtitles incompatible with MKV container
const MKV_INCOMPATIBLE_SUBS = new Set(['mov_text', 'tx3g']);
// Subtitles incompatible with MP4 container (most text/image subs)
const MP4_INCOMPATIBLE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub', 'subrip', 'srt', 'ass', 'ssa', 'webvtt']);
// Text subtitles that can be converted to mov_text for MP4
const MP4_CONVERTIBLE_SUBS = new Set(['subrip', 'srt', 'ass', 'ssa', 'webvtt', 'text']);
// Image subtitles that must be dropped for MP4 (cannot be converted)
const IMAGE_SUBS = new Set(['hdmv_pgs_subtitle', 'dvd_subtitle', 'dvdsub']);
/**
* Build container-aware subtitle handling arguments
* @param {Array} streams - ffprobe streams array
* @param {string} targetContainer - target container (mkv, mp4, webm)
* @returns {Object} { subtitleArgs: string, subtitleLog: string }
*/
const buildSubtitleArgs = (streams, targetContainer) => {
const subtitleStreams = streams
.map((s, i) => ({ ...s, index: i }))
.filter(s => s.codec_type === 'subtitle');
if (subtitleStreams.length === 0) {
return { subtitleArgs: '', subtitleLog: '' };
}
const container = targetContainer.toLowerCase();
let args = '';
let log = '';
if (container === 'mp4' || container === 'm4v') {
// MP4: Convert compatible text subs to mov_text, drop image subs
const toConvert = [];
const toDrop = [];
subtitleStreams.forEach(s => {
const codec = (s.codec_name || '').toLowerCase();
if (IMAGE_SUBS.has(codec)) {
toDrop.push(s);
} else if (MP4_CONVERTIBLE_SUBS.has(codec)) {
toConvert.push(s);
} else if (codec === 'mov_text') {
// Already compatible, will be copied
} else {
// Unknown format - try to convert, FFmpeg will error if it can't
toConvert.push(s);
}
});
if (toDrop.length > 0) {
// Build negative mapping for dropped streams
toDrop.forEach(s => {
args += ` -map -0:${s.index}`;
});
log += `Dropping ${toDrop.length} image subtitle(s) (incompatible with MP4). `;
}
if (toConvert.length > 0) {
// Convert text subs to mov_text
args += ' -c:s mov_text';
log += `Converting ${toConvert.length} subtitle(s) to mov_text for MP4. `;
} else if (toDrop.length === 0) {
args += ' -c:s copy';
}
} else if (container === 'webm') {
// WebM: Only supports WebVTT, drop everything else
const incompatible = subtitleStreams.filter(s => {
const codec = (s.codec_name || '').toLowerCase();
return codec !== 'webvtt';
});
if (incompatible.length > 0) {
incompatible.forEach(s => {
args += ` -map -0:${s.index}`;
});
log += `Dropping ${incompatible.length} subtitle(s) (WebM only supports WebVTT). `;
}
if (incompatible.length < subtitleStreams.length) {
args += ' -c:s copy';
}
} else {
// MKV: Very permissive, just convert mov_text to srt
const movTextStreams = subtitleStreams.filter(s =>
(s.codec_name || '').toLowerCase() === 'mov_text'
);
if (movTextStreams.length > 0) {
args += ' -c:s srt';
log += `Converting ${movTextStreams.length} mov_text subtitle(s) to SRT for MKV. `;
} else {
args += ' -c:s copy';
}
}
return { subtitleArgs: args, subtitleLog: log };
};
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const plugin = (file, librarySettings, inputs, otherArguments) => {
const lib = require('../methods/lib')();
const response = {
processFile: false,
preset: '',
container: '',
handbrakeMode: false,
ffmpegMode: false,
reQueueAfter: false,
infoLog: '',
};
try {
const sanitized = sanitizeInputs(lib.loadDefaultValues(inputs, details));
// Detect actual input container format via ffprobe
const actualFormatName = file.ffProbeData?.format?.format_name || '';
const looksLikeAppleMp4Family = actualFormatName.includes('mov') || actualFormatName.includes('mp4');
// NOTE: Stream cleanup is now handled by the stream_cleanup plugin earlier in the pipeline.
// We use simple -map 0 mapping, relying on stream_cleanup to remove problematic streams.
// 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;
}
// Source Bitrate Awareness Check
const duration = parseFloat(file.ffProbeData?.format?.duration) || 0;
const sourceSize = file.statSync?.size || 0;
let sourceBitrateKbps = 0;
if (duration > 0 && sourceSize > 0) {
sourceBitrateKbps = Math.round((sourceSize * 8) / (duration * 1000));
}
if (sanitized.bitrate_awareness === 'enabled') {
const minBitrate = parseInt(sanitized.min_source_bitrate) || 400;
if (sourceBitrateKbps === 0) {
response.infoLog += `Warning: Could not calculate source bitrate (duration: ${duration}s, size: ${sourceSize}). Skipping bitrate check.\n`;
} else if (sourceBitrateKbps < minBitrate) {
response.processFile = false;
response.infoLog += `Source bitrate (${sourceBitrateKbps} kbps) is below minimum threshold (${minBitrate} kbps). Skipping to prevent size bloat.\n`;
return response;
} else {
response.infoLog += `Source bitrate: ${sourceBitrateKbps} kbps (Threshold: ${minBitrate} kbps). Proceeding.\n`;
}
} else {
response.infoLog += `Source bitrate: ${sourceBitrateKbps} kbps. Awareness disabled.\n`;
}
// 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;
}
// Apply quality preset to determine CRF, qmin, qmax values
// Presets override manual values unless quality_preset is 'custom'
let effectiveCrf = sanitized.crf;
let effectiveQmin = sanitized.qmin;
let effectiveQmax = sanitized.qmax;
const qualityPresets = {
archival: { crf: '18', qmin: '5', qmax: '35' },
high: { crf: '22', qmin: '10', qmax: '40' },
balanced: { crf: '28', qmin: '10', qmax: '45' },
efficient: { crf: '30', qmin: '10', qmax: '55' },
};
if (sanitized.quality_preset !== 'custom' && qualityPresets[sanitized.quality_preset]) {
const preset = qualityPresets[sanitized.quality_preset];
effectiveCrf = preset.crf;
effectiveQmin = preset.qmin;
effectiveQmax = preset.qmax;
response.infoLog += `Quality preset "${sanitized.quality_preset}" applied: CRF ${effectiveCrf}, qmin ${effectiveQmin}, qmax ${effectiveQmax}.\n`;
} else if (sanitized.quality_preset === 'custom') {
response.infoLog += `Custom quality settings: CRF ${effectiveCrf}, qmin ${effectiveQmin}, qmax ${effectiveQmax}.\n`;
}
// Resolution-based CRF adjustment (applies to OUTPUT resolution after any downscaling)
let finalCrf = effectiveCrf;
if (sanitized.resolution_crf_adjust === 'enabled' && outputHeight) {
const baseCrf = parseInt(effectiveCrf);
// Validate CRF is a valid number
if (isNaN(baseCrf) || baseCrf < 0 || baseCrf > 63) {
response.infoLog += `Warning: Invalid CRF value "${effectiveCrf}", 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 ${effectiveCrf} to ${finalCrf}.\n`;
} else if (outputHeight <= 480) { // 480p or lower
finalCrf = Math.min(63, baseCrf + 2).toString();
response.infoLog += `480p or lower output resolution detected, CRF adjusted from ${effectiveCrf} to ${finalCrf}.\n`;
} else if (outputHeight <= 720) { // 720p
response.infoLog += `720p output resolution detected, using base CRF ${finalCrf}.\n`;
} else {
response.infoLog += `1080p output resolution detected, using base CRF ${finalCrf}.\n`;
}
}
}
// Source codec awareness - increase CRF for already-efficient codecs
if (sanitized.source_codec_awareness === 'enabled') {
const sourceCodec = videoStream.codec_name?.toLowerCase() || '';
const efficientCodecs = ['hevc', 'h265', 'libx265'];
if (efficientCodecs.includes(sourceCodec)) {
const currentCrf = parseInt(finalCrf);
finalCrf = Math.min(63, currentCrf + 2).toString();
response.infoLog += `Source codec "${sourceCodec}" is already efficient: CRF adjusted +2 to ${finalCrf} to prevent bloat.\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
// Note: lookahead is only passed when > 0 (SVT-AV1 v3.x rejects -1 and may have issues with 0 via FFmpeg wrapper)
const svtParamsArray = [
`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}`,
`enable-tf=${sanitized.enable_tf}`
];
// Only add lookahead if explicitly set to a positive value
const lookaheadVal = parseInt(sanitized.lookahead);
if (lookaheadVal > 0) {
svtParamsArray.push(`lookahead=${lookaheadVal}`);
response.infoLog += `Lookahead set to ${lookaheadVal} frames.\\n`;
}
const svtParams = svtParamsArray.join(':');
// Set up FFmpeg arguments for CRF quality control with configurable qmin/qmax
let qualityArgs = `-crf ${finalCrf} -qmin ${effectiveQmin} -qmax ${effectiveQmax}`;
// 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`;
}
// Build quality/bitrate arguments based on rate control mode
let bitrateControlInfo = `Using CRF mode with value ${finalCrf}`;
if (sanitized.rate_control_mode === 'vbr') {
// VBR Mode: Use target bitrate. SVT-AV v3.1+ doesn't support -maxrate with VBR.
const targetBitrate = parseInt(sanitized.target_bitrate) || 2200;
qualityArgs = `-b:v ${targetBitrate}k -qmin ${effectiveQmin} -qmax ${effectiveQmax}`;
bitrateControlInfo = `VBR mode: target ${targetBitrate}k`;
response.infoLog += `VBR encoding: Target average ${targetBitrate}k.\n`;
} else {
// CRF Mode: Quality-based with optional maxrate cap
if (sanitized.maxrate && parseInt(sanitized.maxrate) > 0) {
const maxrateValue = parseInt(sanitized.maxrate);
const bufsize = Math.round(maxrateValue * 2.0); // Buffer = 2x maxrate
qualityArgs += ` -maxrate ${maxrateValue}k -bufsize ${bufsize}k`;
bitrateControlInfo += ` with maxrate cap at ${maxrateValue}k`;
response.infoLog += `Capped CRF enabled: Max bitrate ${maxrateValue}k, buffer ${bufsize}k.\n`;
} else {
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)
// Determine target container for subtitle handling
const targetContainer = sanitized.container === 'original' ? file.container : sanitized.container;
// Build container-aware subtitle arguments
const { subtitleArgs, subtitleLog } = buildSubtitleArgs(file.ffProbeData.streams, targetContainer);
if (subtitleLog) {
response.infoLog += `📁 ${subtitleLog}\\n`;
}
// Set up FFmpeg arguments for AV1 SVT conversion
// Use explicit stream mapping to prevent data/attachment streams from causing muxing errors
const svtParamsWithTiles = svtParams + tileArgs;
response.preset = `<io>${scaleFilter ? ' ' + scaleFilter : ''} -map 0:v -map 0:a? -map 0:s?${subtitleArgs} -c:v libsvtav1 ${qualityArgs}${hdrArgs} -svtav1-params "${svtParamsWithTiles}" -c:a copy -max_muxing_queue_size 9999`;
// Set container
if (sanitized.container === 'original') {
response.container = `.${file.container}`;
} 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';
}
// MKV container handling with user warning
if (sanitized.container === 'mkv' && looksLikeAppleMp4Family) {
response.infoLog += 'Note: MKV output with Apple/MP4 source. Ensure stream_cleanup ran first.\n';
}
}
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: ${lookaheadVal > 0 ? lookaheadVal : 'auto'}, TF: ${sanitized.enable_tf}\\n`;
response.infoLog += `Quality control - CRF: ${finalCrf}, QMin: ${effectiveQmin}, QMax: ${effectiveQmax}, Film grain: ${sanitized.film_grain}\n`;
response.infoLog += `Output container: ${response.container}\n`;
return response;
} catch (error) {
response.processFile = false;
response.preset = '';
response.container = `.${file.container || 'mkv'}`;
response.reQueueAfter = false;
response.infoLog = `❌ Plugin error: ${error.message}\n`;
return response;
}
};
module.exports.details = details;
module.exports.plugin = plugin;