Files
create-vod/src/core/encoding.ts

310 lines
10 KiB
TypeScript
Raw Normal View History

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<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[] = [];
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<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,
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;
}