279 lines
8.7 KiB
TypeScript
279 lines
8.7 KiB
TypeScript
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,
|
|
decoderAccel?: HardwareAccelerator,
|
|
onProgress?: (percent: number) => void
|
|
): Promise<string> {
|
|
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[] = [`scale=${profile.width}:${profile.height}`];
|
|
|
|
// 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);
|
|
}
|
|
}
|
|
|
|
args.push('-vf', filters.join(','));
|
|
|
|
// Audio encoding
|
|
// Select optimal bitrate based on source (don't upscale)
|
|
const targetAudioBitrate = parseInt(profile.audioBitrate) || 256;
|
|
const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate);
|
|
args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate);
|
|
|
|
// Audio optimizations
|
|
if (optimizations?.audioNormalize) {
|
|
args.push('-af', 'loudnorm');
|
|
}
|
|
|
|
// 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,
|
|
decoderAccel?: HardwareAccelerator,
|
|
onProgress?: (profileName: string, percent: number) => void
|
|
): Promise<Map<string, string>> {
|
|
const mp4Files = new Map<string, string>();
|
|
|
|
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,
|
|
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,
|
|
decoderAccel,
|
|
(percent) => {
|
|
if (onProgress) {
|
|
onProgress(profile.name, percent);
|
|
}
|
|
}
|
|
);
|
|
|
|
mp4Files.set(profile.name, mp4Path);
|
|
}
|
|
}
|
|
|
|
return mp4Files;
|
|
}
|