- Use FFMPEG and FFPROBE environment variables instead of --ffmpeg/--ffprobe flags - Older ab-av1 versions don't support those command-line arguments - Sets env vars to point to tdarr-ffmpeg when executing ab-av1
1089 lines
40 KiB
JavaScript
1089 lines
40 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 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.33',
|
||
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) 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 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), 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),
|
||
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(),
|
||
];
|
||
|
||
const command = `${abav1Path} ${args.join(' ')}`;
|
||
|
||
// Execute with timeout (5 minutes should be enough for sample encodes)
|
||
// Set FFMPEG and FFPROBE environment variables to point to tdarr-ffmpeg
|
||
const output = execSync(command, {
|
||
encoding: 'utf8',
|
||
timeout: 300000, // 5 minute timeout
|
||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||
stdio: ['pipe', 'pipe', 'pipe'],
|
||
env: {
|
||
...process.env,
|
||
FFMPEG: 'tdarr-ffmpeg',
|
||
FFPROBE: 'tdarr-ffmpeg',
|
||
}
|
||
});
|
||
|
||
// 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;
|