av1 кодек

This commit is contained in:
2025-11-11 21:07:51 +03:00
parent b843bdf897
commit 2da2b584fa
9 changed files with 274 additions and 98 deletions

File diff suppressed because one or more lines are too long

View File

@@ -10,14 +10,16 @@
* dvc-cli ./video.mp4 ./output -r 720,1080
*/
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, getVideoMetadata } from './index';
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, checkAV1Support, getVideoMetadata } from './index';
import cliProgress from 'cli-progress';
import { statSync } from 'node:fs';
import type { CodecType } from './types';
// Parse arguments
const args = process.argv.slice(2);
let customProfiles: string[] | undefined;
let posterTimecode: string | undefined;
let codecType: CodecType = 'dual'; // Default to dual codec
const positionalArgs: string[] = [];
// First pass: extract flags and their values
@@ -43,6 +45,15 @@ for (let i = 0; i < args.length; i++) {
} else if (args[i] === '-p' || args[i] === '--poster') {
posterTimecode = args[i + 1];
i++; // Skip next arg
} else if (args[i] === '-c' || args[i] === '--codec') {
const codec = args[i + 1];
if (codec === 'av1' || codec === 'h264' || codec === 'dual') {
codecType = codec;
} else {
console.error(`❌ Invalid codec: ${codec}. Valid options: av1, h264, dual`);
process.exit(1);
}
i++; // Skip next arg
} else if (!args[i].startsWith('-')) {
// Positional argument
positionalArgs.push(args[i]);
@@ -54,14 +65,21 @@ const input = positionalArgs[0];
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
if (!input) {
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]');
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-p poster-timecode]');
console.error('\nOptions:');
console.error(' -r, --resolutions Video resolutions (e.g., 360,480,720 or 720@60,1080@60)');
console.error(' -c, --codec Video codec: av1, h264, or dual (default: dual)');
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
console.error('\nExamples:');
console.error(' dvc-cli video.mp4');
console.error(' dvc-cli video.mp4 ./output');
console.error(' dvc-cli video.mp4 -r 360,480,720');
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60');
console.error(' dvc-cli video.mp4 -c av1');
console.error(' dvc-cli video.mp4 -c h264');
console.error(' dvc-cli video.mp4 -c dual');
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1');
console.error(' dvc-cli video.mp4 -p 00:00:05');
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -p 10');
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -p 10');
process.exit(1);
}
@@ -69,10 +87,16 @@ console.log('🔍 Checking system...\n');
const hasFFmpeg = await checkFFmpeg();
const hasNvenc = await checkNvenc();
const av1Support = await checkAV1Support();
const hasMP4Box = await checkMP4Box();
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
console.log(`NVENC: ${hasNvenc ? '✅ (GPU acceleration)' : '⚠️ (CPU only)'}`);
console.log(`NVENC (H.264): ${hasNvenc ? '✅ (GPU acceleration)' : '⚠️ (CPU only)'}`);
if (av1Support.available) {
console.log(`AV1 Encoder: ✅ ${av1Support.encoder} (GPU acceleration)`);
} else {
console.log(`AV1 Encoder: ⚠️ (not available, will use CPU fallback)`);
}
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}\n`);
if (!hasFFmpeg) {
@@ -85,6 +109,13 @@ if (!hasMP4Box) {
process.exit(1);
}
// Validate codec selection
if ((codecType === 'av1' || codecType === 'dual') && !av1Support.available) {
console.error(`⚠️ Warning: AV1 encoding requested but no hardware AV1 encoder found.`);
console.error(` CPU-based AV1 encoding (libsvtav1) will be VERY slow.`);
console.error(` Consider using --codec h264 for faster encoding.\n`);
}
// Get video metadata and file size
console.log('📊 Analyzing video...\n');
const metadata = await getVideoMetadata(input);
@@ -105,6 +136,7 @@ if (metadata.audioBitrate) {
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
}
console.log(`\n📁 Output: ${outputDir}`);
console.log(`🎬 Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264 for maximum compatibility)' : ''}`);
if (customProfiles) {
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
}
@@ -133,6 +165,7 @@ try {
outputDir,
customProfiles,
posterTimecode,
codec: codecType,
segmentDuration: 2,
useNvenc: hasNvenc,
generateThumbnails: true,
@@ -182,7 +215,8 @@ try {
console.log(` Manifest: ${result.manifestPath}`);
console.log(` Duration: ${result.duration.toFixed(2)}s`);
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
console.log(` Encoder: ${result.usedNvenc ? '⚡ NVENC (GPU)' : '🔧 libx264 (CPU)'}`);
console.log(` Codec: ${result.codecType}${result.codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
console.log(` Encoder: ${result.usedNvenc ? '⚡ GPU accelerated' : '🔧 CPU'}`);
if (result.posterPath) {
console.log(` Poster: ${result.posterPath}`);

View File

@@ -6,12 +6,14 @@ import type {
DashConvertResult,
VideoProfile,
ThumbnailConfig,
ConversionProgress
ConversionProgress,
CodecType
} from '../types';
import {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
getVideoMetadata,
ensureDir
} from '../utils';
@@ -33,6 +35,7 @@ export async function convertToDash(
segmentDuration = 2,
profiles: userProfiles,
customProfiles,
codec = 'dual',
useNvenc,
generateThumbnails = true,
thumbnailConfig = {},
@@ -54,6 +57,7 @@ export async function convertToDash(
segmentDuration,
userProfiles,
customProfiles,
codec,
useNvenc,
generateThumbnails,
thumbnailConfig,
@@ -82,6 +86,7 @@ async function convertToDashInternal(
segmentDuration: number,
userProfiles: VideoProfile[] | undefined,
customProfiles: string[] | undefined,
codec: CodecType,
useNvenc: boolean | undefined,
generateThumbnails: boolean,
thumbnailConfig: ThumbnailConfig,
@@ -177,15 +182,37 @@ async function convertToDashInternal(
await ensureDir(videoOutputDir);
reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined);
// Determine which codecs to use based on codec parameter
const codecs: Array<{ type: 'h264' | 'av1'; codec: string; preset: string }> = [];
if (codec === 'h264' || codec === 'dual') {
const h264Codec = willUseNvenc ? 'h264_nvenc' : 'libx264';
const h264Preset = willUseNvenc ? 'p4' : 'medium';
codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset });
}
if (codec === 'av1' || codec === 'dual') {
// Check for AV1 hardware encoder
const av1Support = await checkAV1Support();
const av1Codec = av1Support.available ? av1Support.encoder! : 'libsvtav1';
const av1Preset = av1Support.available ? (av1Codec === 'av1_nvenc' ? 'p4' : 'medium') : '8';
codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset });
}
const codecNames = codecs.map(c => c.type.toUpperCase()).join(' + ');
reportProgress('analyzing', 20, `Using ${codecNames} encoding (${willUseNvenc ? 'GPU' : 'CPU'})`, undefined);
// Video codec selection
const videoCodec = willUseNvenc ? 'h264_nvenc' : 'libx264';
const codecPreset = willUseNvenc ? 'p4' : 'medium';
const maxConcurrent = willUseNvenc ? 3 : 2;
// STAGE 1: Encode profiles to MP4 (parallel - heavy work)
reportProgress('encoding', 25, `Stage 1: Encoding ${profiles.length} profiles to MP4...`);
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
for (let codecIndex = 0; codecIndex < codecs.length; codecIndex++) {
const { type, codec: videoCodec, preset: codecPreset } = codecs[codecIndex];
const codecProgress = codecIndex / codecs.length;
const codecProgressRange = 1 / codecs.length;
reportProgress('encoding', 25 + codecProgress * 40, `Stage 1: Encoding ${type.toUpperCase()} (${profiles.length} profiles)...`);
const tempMP4Paths = await encodeProfilesToMP4(
input,
@@ -195,43 +222,52 @@ async function convertToDashInternal(
codecPreset,
metadata.duration,
segmentDuration,
metadata.fps || 25, // Use detected FPS or default to 25
metadata.audioBitrate, // Source audio bitrate for smart selection
metadata.fps || 25,
metadata.audioBitrate,
parallel,
maxConcurrent,
type, // Pass codec type to differentiate output files
undefined, // optimizations - for future use
(profileName, percent) => {
const profileIndex = profiles.findIndex(p => p.name === profileName);
const baseProgress = 25 + (profileIndex / profiles.length) * 40;
const profileProgress = (percent / 100) * (40 / profiles.length);
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${profileName}...`, profileName);
const baseProgress = 25 + codecProgress * 40;
const profileProgress = (percent / 100) * (40 * codecProgressRange / profiles.length);
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${type.toUpperCase()} ${profileName}...`, `${type}-${profileName}`);
// Also report individual profile progress
if (onProgress) {
onProgress({
stage: 'encoding',
percent: baseProgress + profileProgress,
currentProfile: profileName,
profilePercent: percent, // Actual profile progress 0-100
message: `Encoding ${profileName}...`
currentProfile: `${type}-${profileName}`,
profilePercent: percent,
message: `Encoding ${type.toUpperCase()} ${profileName}...`
});
}
}
);
reportProgress('encoding', 65, 'Stage 1 complete: All profiles encoded');
codecMP4Paths.set(type, tempMP4Paths);
}
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
// STAGE 2: Package to DASH using MP4Box (light work, fast)
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
const manifestPath = await packageToDash(
tempMP4Paths,
codecMP4Paths,
videoOutputDir,
profiles,
segmentDuration
segmentDuration,
codec
);
const videoPaths = Array.from(tempMP4Paths.values());
// Collect all video paths from all codecs
const videoPaths: string[] = [];
for (const mp4Paths of codecMP4Paths.values()) {
videoPaths.push(...Array.from(mp4Paths.values()));
}
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
@@ -293,7 +329,8 @@ async function convertToDashInternal(
posterPath,
duration: metadata.duration,
profiles,
usedNvenc: willUseNvenc
usedNvenc: willUseNvenc,
codecType: codec
};
}

View File

@@ -16,10 +16,11 @@ export async function encodeProfileToMP4(
segmentDuration: number,
fps: number,
sourceAudioBitrate: number | undefined,
codecType: 'h264' | 'av1',
optimizations?: VideoOptimizations,
onProgress?: (percent: number) => void
): Promise<string> {
const outputPath = join(tempDir, `video_${profile.name}.mp4`);
const outputPath = join(tempDir, `video_${codecType}_${profile.name}.mp4`);
const args = [
'-y',
@@ -27,20 +28,44 @@ export async function encodeProfileToMP4(
'-c:v', videoCodec
];
// Add NVENC specific options
// Add codec-specific options
if (videoCodec === 'h264_nvenc') {
// NVIDIA H.264
args.push('-rc:v', 'vbr');
args.push('-preset', preset);
args.push('-2pass', '0');
} else if (videoCodec === 'av1_nvenc') {
// NVIDIA AV1
args.push('-rc:v', 'vbr');
args.push('-preset', preset);
args.push('-2pass', '0');
} else if (videoCodec === 'av1_qsv') {
// Intel QSV AV1
args.push('-preset', preset);
args.push('-global_quality', '23'); // Quality level for QSV
} else if (videoCodec === 'av1_amf') {
// AMD AMF AV1
args.push('-quality', 'balanced');
args.push('-rc', 'vbr_latency');
} else if (videoCodec === 'libsvtav1') {
// CPU-based SVT-AV1
args.push('-preset', preset); // 0-13, 8 is medium speed
args.push('-svtav1-params', 'tune=0:enable-overlays=1');
} else {
// Default (libx264, libx265, etc.)
args.push('-preset', preset);
}
// Video encoding parameters
// AV1 is ~40% more efficient than H.264 at same quality (Netflix/YouTube standard)
const bitrateMultiplier = codecType === 'av1' ? 0.6 : 1.0;
const targetBitrate = Math.round(parseInt(profile.videoBitrate) * bitrateMultiplier);
const bitrateString = `${targetBitrate}k`;
args.push(
'-b:v', profile.videoBitrate,
'-maxrate', profile.videoBitrate,
'-bufsize', `${parseInt(profile.videoBitrate) * 2}k`
'-b:v', bitrateString,
'-maxrate', bitrateString,
'-bufsize', `${targetBitrate * 2}k`
);
// Set GOP size for DASH segments
@@ -105,6 +130,7 @@ export async function encodeProfilesToMP4(
sourceAudioBitrate: number | undefined,
parallel: boolean,
maxConcurrent: number,
codecType: 'h264' | 'av1',
optimizations?: VideoOptimizations,
onProgress?: (profileName: string, percent: number) => void
): Promise<Map<string, string>> {
@@ -125,6 +151,7 @@ export async function encodeProfilesToMP4(
segmentDuration,
fps,
sourceAudioBitrate,
codecType,
optimizations,
(percent) => {
if (onProgress) {
@@ -153,6 +180,7 @@ export async function encodeProfilesToMP4(
segmentDuration,
fps,
sourceAudioBitrate,
codecType,
optimizations,
(percent) => {
if (onProgress) {

View File

@@ -1,17 +1,18 @@
import { join } from 'node:path';
import { execMP4Box } from '../utils';
import type { VideoProfile } from '../types';
import type { VideoProfile, CodecType } from '../types';
/**
* Package MP4 files into DASH format using MP4Box
* Stage 2: Light work - just packaging, no encoding
* Creates one master MPD manifest with all profiles
* Creates one master MPD manifest with all profiles and codecs
*/
export async function packageToDash(
mp4Files: Map<string, string>,
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number
segmentDuration: number,
codecType: CodecType
): Promise<string> {
const manifestPath = join(outputDir, 'manifest.mpd');
@@ -20,22 +21,32 @@ export async function packageToDash(
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
'-frag', String(segmentDuration * 1000),
'-rap', // Force segments to start with random access points
'-segment-timeline', // Use SegmentTimeline for accurate segment durations
'-segment-name', '$RepresentationID$_$Number$',
'-out', manifestPath
];
// Add all MP4 files with their profile IDs
// Add all MP4 files for each codec
let firstFile = true;
for (const [codec, mp4Files] of codecMP4Files.entries()) {
for (const profile of profiles) {
const mp4Path = mp4Files.get(profile.name);
if (!mp4Path) {
throw new Error(`MP4 file not found for profile: ${profile.name}`);
throw new Error(`MP4 file not found for profile: ${profile.name}, codec: ${codec}`);
}
// Representation ID includes codec: e.g., "720p-h264", "720p-av1"
const representationId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
// Add video track with representation ID
args.push(`${mp4Path}#video:id=${profile.name}`);
// Add audio track (shared across all profiles)
if (profile === profiles[0]) {
args.push(`${mp4Path}#video:id=${representationId}`);
// Add audio track only once (shared across all profiles and codecs)
if (firstFile) {
args.push(`${mp4Path}#audio:id=audio`);
firstFile = false;
}
}
}
@@ -44,10 +55,10 @@ export async function packageToDash(
// MP4Box creates files in the same directory as output MPD
// Move segment files to profile subdirectories for clean structure
await organizeSegments(outputDir, profiles);
await organizeSegments(outputDir, profiles, codecType);
// Update MPD to reflect new file structure with subdirectories
await updateManifestPaths(manifestPath, profiles);
await updateManifestPaths(manifestPath, profiles, codecType);
return manifestPath;
}
@@ -58,15 +69,28 @@ export async function packageToDash(
*/
async function organizeSegments(
outputDir: string,
profiles: VideoProfile[]
profiles: VideoProfile[],
codecType: CodecType
): Promise<void> {
const { readdir, rename, mkdir } = await import('node:fs/promises');
// Create profile subdirectories
// For dual-codec mode, create codec-specific subdirectories (e.g., "720p-h264/", "720p-av1/")
// For single-codec mode, use simple profile names (e.g., "720p/")
const codecs: Array<'h264' | 'av1'> = [];
if (codecType === 'h264' || codecType === 'dual') codecs.push('h264');
if (codecType === 'av1' || codecType === 'dual') codecs.push('av1');
const representationIds: string[] = [];
for (const codec of codecs) {
for (const profile of profiles) {
const profileDir = join(outputDir, profile.name);
const repId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
representationIds.push(repId);
const profileDir = join(outputDir, repId);
await mkdir(profileDir, { recursive: true });
}
}
// Create audio subdirectory
const audioDir = join(outputDir, 'audio');
@@ -90,11 +114,11 @@ async function organizeSegments(
continue;
}
// Move video segment files to their profile directories
for (const profile of profiles) {
if (file.startsWith(`${profile.name}_`)) {
// Move video segment files to their representation directories
for (const repId of representationIds) {
if (file.startsWith(`${repId}_`)) {
const oldPath = join(outputDir, file);
const newPath = join(outputDir, profile.name, file);
const newPath = join(outputDir, repId, file);
await rename(oldPath, newPath);
break;
}
@@ -107,7 +131,8 @@ async function organizeSegments(
*/
async function updateManifestPaths(
manifestPath: string,
profiles: VideoProfile[]
profiles: VideoProfile[],
codecType: CodecType
): Promise<void> {
const { readFile, writeFile } = await import('node:fs/promises');

View File

@@ -9,7 +9,8 @@ export type {
ThumbnailConfig,
ConversionProgress,
VideoMetadata,
VideoOptimizations
VideoOptimizations,
CodecType
} from './types';
// Utility exports
@@ -17,6 +18,7 @@ export {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
getVideoMetadata,
selectAudioBitrate
} from './utils';

View File

@@ -1,3 +1,8 @@
/**
* Video codec type for encoding
*/
export type CodecType = 'av1' | 'h264' | 'dual';
/**
* Configuration options for DASH conversion
*/
@@ -17,6 +22,9 @@ export interface DashConvertOptions {
/** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */
customProfiles?: string[];
/** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */
codec?: CodecType;
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
useNvenc?: boolean;
@@ -123,6 +131,9 @@ export interface DashConvertResult {
/** Whether NVENC was used */
usedNvenc: boolean;
/** Codec type used for encoding */
codecType: CodecType;
}
/**

View File

@@ -3,6 +3,7 @@ export {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
execFFmpeg,
execMP4Box
} from './system';

View File

@@ -45,6 +45,42 @@ export async function checkNvenc(): Promise<boolean> {
});
}
/**
* Check if AV1 hardware encoding is available
* Supports: NVENC (RTX 40xx), QSV (Intel 11+), AMF (AMD RX 7000)
*/
export async function checkAV1Support(): Promise<{
available: boolean;
encoder?: 'av1_nvenc' | 'av1_qsv' | 'av1_amf';
}> {
return new Promise((resolve) => {
const proc = spawn('ffmpeg', ['-hide_banner', '-encoders']);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('error', () => resolve({ available: false }));
proc.on('close', (code) => {
if (code !== 0) {
resolve({ available: false });
} else {
// Check for hardware AV1 encoders in order of preference
if (output.includes('av1_nvenc')) {
resolve({ available: true, encoder: 'av1_nvenc' });
} else if (output.includes('av1_qsv')) {
resolve({ available: true, encoder: 'av1_qsv' });
} else if (output.includes('av1_amf')) {
resolve({ available: true, encoder: 'av1_amf' });
} else {
resolve({ available: false });
}
}
});
});
}
/**
* Execute FFmpeg command with progress tracking
*/