import { join } from 'node:path'; import { execFFmpeg, selectAudioBitrate } from '../utils'; import type { VideoProfile, VideoOptimizations, CodecQualitySettings, HardwareAccelerator } from '../types'; /** * Get default CQ/CRF value based on resolution and codec */ function getDefaultQuality(height: number, codecType: 'h264' | 'av1', isGPU: boolean): number { if (isGPU) { // GPU encoders use CQ - ФИКСИРОВАННЫЕ ЗНАЧЕНИЯ ДЛЯ ТЕСТИРОВАНИЯ if (codecType === 'h264') { // H.264 NVENC CQ = 32 (для всех разрешений) return 32; } else { // AV1 NVENC CQ = 42 (для всех разрешений) return 42; } } else { // CPU encoders use CRF if (codecType === 'h264') { // libx264 CRF (на ~3-5 ниже чем NVENC CQ) if (height <= 360) return 25; if (height <= 480) return 24; if (height <= 720) return 23; if (height <= 1080) return 22; if (height <= 1440) return 21; return 20; // 4K } else { // libsvtav1 CRF (шкала 0-63, на ~20% выше чем NVENC CQ) if (height <= 360) return 40; if (height <= 480) return 38; if (height <= 720) return 35; if (height <= 1080) return 32; if (height <= 1440) return 30; return 28; // 4K } } } /** * Encode single profile to MP4 * Stage 1: Heavy work - video encoding with optional optimizations */ export async function encodeProfileToMP4( input: string, tempDir: string, profile: VideoProfile, videoCodec: string, preset: string, duration: number, segmentDuration: number, sourceAudioBitrate: number | undefined, codecType: 'h264' | 'av1', qualitySettings?: CodecQualitySettings, optimizations?: VideoOptimizations, muted: boolean = false, decoderAccel?: HardwareAccelerator, onProgress?: (percent: number) => void ): Promise { const outputPath = join(tempDir, `video_${codecType}_${profile.name}.mp4`); const args = ['-y']; // Hardware decode (optional) if (decoderAccel) { if (decoderAccel === 'nvenc') { args.push('-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda'); } else if (decoderAccel === 'qsv') { args.push('-hwaccel', 'qsv'); } else if (decoderAccel === 'vaapi') { args.push('-hwaccel', 'vaapi'); } else if (decoderAccel === 'videotoolbox') { args.push('-hwaccel', 'videotoolbox'); } else if (decoderAccel === 'v4l2') { args.push('-hwaccel', 'v4l2'); } } args.push('-i', input, '-c:v', videoCodec); // Determine if using GPU or CPU encoder const isGPU = videoCodec.includes('nvenc') || videoCodec.includes('qsv') || videoCodec.includes('amf') || videoCodec.includes('vaapi') || videoCodec.includes('videotoolbox') || videoCodec.includes('v4l2'); // Determine quality value (CQ for GPU, CRF for CPU) let qualityValue: number; if (isGPU && qualitySettings?.cq !== undefined) { qualityValue = qualitySettings.cq; } else if (!isGPU && qualitySettings?.crf !== undefined) { qualityValue = qualitySettings.crf; } else { // Use default quality based on resolution qualityValue = getDefaultQuality(profile.height, codecType, isGPU); } // Add codec-specific options with CQ/CRF if (videoCodec === 'h264_nvenc') { // NVIDIA H.264 with CQ args.push('-rc:v', 'vbr'); args.push('-cq', String(qualityValue)); args.push('-preset', preset); args.push('-2pass', '0'); } else if (videoCodec === 'av1_nvenc') { // NVIDIA AV1 with CQ args.push('-rc:v', 'vbr'); args.push('-cq', String(qualityValue)); args.push('-preset', preset); args.push('-2pass', '0'); } else if (videoCodec === 'av1_qsv') { // Intel QSV AV1 args.push('-preset', preset); args.push('-global_quality', String(qualityValue)); } else if (videoCodec === 'h264_qsv') { // Intel QSV H.264 args.push('-preset', preset); args.push('-global_quality', String(qualityValue)); } else if (videoCodec === 'av1_amf') { // AMD AMF AV1 args.push('-quality', 'balanced'); args.push('-rc', 'cqp'); args.push('-qp_i', String(qualityValue)); args.push('-qp_p', String(qualityValue)); } else if (videoCodec === 'h264_amf') { // AMD AMF H.264 args.push('-quality', 'balanced'); args.push('-rc', 'cqp'); args.push('-qp_i', String(qualityValue)); args.push('-qp_p', String(qualityValue)); } else if (videoCodec === 'libsvtav1') { // CPU-based SVT-AV1 with CRF args.push('-crf', String(qualityValue)); args.push('-preset', preset); // 0-13, 8 is medium speed args.push('-svtav1-params', 'tune=0:enable-overlays=1'); } else if (videoCodec === 'libx264') { // CPU-based x264 with CRF args.push('-crf', String(qualityValue)); args.push('-preset', preset); } else { // Default fallback args.push('-preset', preset); } // Add maxrate as safety limit (optional but recommended for streaming) // This prevents extreme bitrate spikes on complex scenes const bitrateMultiplier = codecType === 'av1' ? 0.6 : 1.0; const maxBitrate = Math.round(parseInt(profile.videoBitrate) * bitrateMultiplier * 1.5); // +50% headroom args.push('-maxrate', `${maxBitrate}k`); args.push('-bufsize', `${maxBitrate * 2}k`); // Set GOP size for DASH segments // Keyframes must align with segment boundaries const fps = profile.fps || 30; const gopSize = Math.round(fps * segmentDuration); args.push( '-g', String(gopSize), // GOP size (e.g., 25 fps * 2 sec = 50 frames) '-keyint_min', String(gopSize), // Minimum interval between keyframes '-sc_threshold', '0' // Disable scene change detection (keeps GOP consistent) ); // Build video filter chain const filters: string[] = []; const targetWidth = profile.width; const targetHeight = profile.height; const useCudaScale = decoderAccel === 'nvenc'; if (useCudaScale) { // CUDA path: вписываем в профиль с сохранением исходного AR filters.push(`scale_cuda=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2`); } else { filters.push(`scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2`); } // Apply optimizations (for future use) if (optimizations) { if (optimizations.deinterlace) { filters.push('yadif'); } if (optimizations.denoise) { filters.push('hqdn3d'); } if (optimizations.customFilters) { filters.push(...optimizations.customFilters); } } // Если использовали GPU-скейл, возвращаем кадры в системную память перед CPU-фильтрами if (useCudaScale) { filters.push('hwdownload', 'format=nv12'); } // Центрируем кадр, чтобы браузеры (Firefox/videotoolbox) не игнорировали PAR filters.push(`pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`, 'setsar=1'); args.push('-vf', filters.join(',')); if (!muted) { // Audio encoding с нормализацией таймингов и автоподпаддингом тишиной const targetAudioBitrate = parseInt(profile.audioBitrate) || 256; const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate); args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate); const targetDur = duration.toFixed(3); const audioFilters: string[] = [ 'aresample=async=1:min_hard_comp=0.1:first_pts=0', `apad=whole_dur=${targetDur}`, `atrim=0:${targetDur}` ]; if (optimizations?.audioNormalize) { audioFilters.push('loudnorm'); } args.push('-af', audioFilters.join(',')); } else { args.push('-an'); // без аудио дорожки } // Output args.push('-f', 'mp4', outputPath); await execFFmpeg(args, onProgress, duration); return outputPath; } /** * Encode all profiles to MP4 (parallel or sequential) * Stage 1: Main encoding work */ export async function encodeProfilesToMP4( input: string, tempDir: string, profiles: VideoProfile[], videoCodec: string, preset: string, duration: number, segmentDuration: number, sourceAudioBitrate: number | undefined, parallel: boolean, maxConcurrent: number, codecType: 'h264' | 'av1', qualitySettings?: CodecQualitySettings, optimizations?: VideoOptimizations, muted: boolean = false, decoderAccel?: HardwareAccelerator, onProgress?: (profileName: string, percent: number) => void ): Promise> { const mp4Files = new Map(); if (parallel && profiles.length > 1) { // Parallel encoding with batching for (let i = 0; i < profiles.length; i += maxConcurrent) { const batch = profiles.slice(i, i + maxConcurrent); const batchPromises = batch.map((profile) => encodeProfileToMP4( input, tempDir, profile, videoCodec, preset, duration, segmentDuration, sourceAudioBitrate, codecType, qualitySettings, optimizations, muted, decoderAccel, (percent) => { if (onProgress) { onProgress(profile.name, percent); } } ) ); const batchResults = await Promise.all(batchPromises); batchResults.forEach((mp4Path, idx) => { const profile = batch[idx]; mp4Files.set(profile.name, mp4Path); }); } } else { // Sequential encoding for (const profile of profiles) { const mp4Path = await encodeProfileToMP4( input, tempDir, profile, videoCodec, preset, duration, segmentDuration, sourceAudioBitrate, codecType, qualitySettings, optimizations, muted, decoderAccel, (percent) => { if (onProgress) { onProgress(profile.name, percent); } } ); mp4Files.set(profile.name, mp4Path); } } return mp4Files; }