Plugins: - misc_fixes v2.8: Pre-processing, container remux, stream conforming - stream_organizer v4.8: English priority, subtitle extraction, SRT conversion - combined_audio_standardizer v1.13: AAC/Opus encoding, downmix creation - av1_svt_converter v2.22: AV1 video encoding via SVT-AV1 Structure: - Local/ - Plugin .js files (mount in Tdarr) - agent_notes/ - Development documentation - Latest-Reports/ - Error logs for analysis
758 lines
26 KiB
JavaScript
758 lines
26 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 advanced quality control and performance optimizations for SVT-AV1 v3.0+ (2025).
|
||
Features resolution-aware CRF, improved threading, and flexible bitrate control (custom maxrate or source-relative strategies).
|
||
**Balanced high-quality defaults**: Preset 6, CRF 26, tune 0 (VQ), 10-bit, SCD 1, AQ 2, lookahead -1, TF on, keyint -2, fast-decode 0.
|
||
Use presets 3–5 and/or lower CRF for higher quality when speed is less important.
|
||
`,
|
||
Version: '2.22',
|
||
Tags: 'video,av1,svt,quality,performance,speed-optimized,capped-crf',
|
||
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) 24–28 = high quality, 30+ = faster/transcoding. 10–20 = 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 maxrate relative to detected source bitrate.',
|
||
},
|
||
{
|
||
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), 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: '-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, ~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: '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),
|
||
skip_hevc: stripStar(inputs.skip_hevc),
|
||
force_transcode: stripStar(inputs.force_transcode),
|
||
};
|
||
|
||
// 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`;
|
||
}
|
||
}
|
||
|
||
// Calculate target maxrate using precedence logic
|
||
let calculatedMaxrate = null;
|
||
let maxrateSource = '';
|
||
|
||
// Priority 1: target_bitrate_strategy (if not static)
|
||
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;
|
||
}
|
||
calculatedMaxrate = Math.round(sourceBitrateKbps * multiplier);
|
||
maxrateSource = `target_bitrate_strategy '${sanitized.target_bitrate_strategy}': Source ${sourceBitrateKbps}k → Maxrate ${calculatedMaxrate}k`;
|
||
response.infoLog += `Using ${maxrateSource}.\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) {
|
||
calculatedMaxrate = customValue;
|
||
maxrateSource = `custom_maxrate: ${calculatedMaxrate}k`;
|
||
response.infoLog += `Using ${maxrateSource}.\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);
|
||
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;
|
||
}
|
||
|
||
if (calculatedMaxrate) {
|
||
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 {
|
||
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;
|