Files
create-vod/src/core/encoding.ts
S.Gromov 187697eca6 fix: Замена кодека при GPU скейлинге на nv12
fix: Исправлена проблема с звуком и телепортами по частям видео
2026-01-22 10:43:54 +03:00

310 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}