894 lines
30 KiB
JavaScript
894 lines
30 KiB
JavaScript
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), 8–9 = very fast, 3–4 = 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, ~5–10% 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, 10–20% 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, ~15–25% 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), 1–50 = 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;
|