av1 кодек
This commit is contained in:
34
bin/cli.js
34
bin/cli.js
File diff suppressed because one or more lines are too long
46
src/cli.ts
46
src/cli.ts
@@ -10,14 +10,16 @@
|
|||||||
* dvc-cli ./video.mp4 ./output -r 720,1080
|
* 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 cliProgress from 'cli-progress';
|
||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
|
import type { CodecType } from './types';
|
||||||
|
|
||||||
// Parse arguments
|
// Parse arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
let customProfiles: string[] | undefined;
|
let customProfiles: string[] | undefined;
|
||||||
let posterTimecode: string | undefined;
|
let posterTimecode: string | undefined;
|
||||||
|
let codecType: CodecType = 'dual'; // Default to dual codec
|
||||||
const positionalArgs: string[] = [];
|
const positionalArgs: string[] = [];
|
||||||
|
|
||||||
// First pass: extract flags and their values
|
// 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') {
|
} else if (args[i] === '-p' || args[i] === '--poster') {
|
||||||
posterTimecode = args[i + 1];
|
posterTimecode = args[i + 1];
|
||||||
i++; // Skip next arg
|
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('-')) {
|
} else if (!args[i].startsWith('-')) {
|
||||||
// Positional argument
|
// Positional argument
|
||||||
positionalArgs.push(args[i]);
|
positionalArgs.push(args[i]);
|
||||||
@@ -54,14 +65,21 @@ const input = positionalArgs[0];
|
|||||||
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
||||||
|
|
||||||
if (!input) {
|
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('\nExamples:');
|
||||||
console.error(' dvc-cli video.mp4');
|
console.error(' dvc-cli video.mp4');
|
||||||
console.error(' dvc-cli video.mp4 ./output');
|
console.error(' dvc-cli video.mp4 ./output');
|
||||||
console.error(' dvc-cli video.mp4 -r 360,480,720');
|
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 -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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,10 +87,16 @@ console.log('🔍 Checking system...\n');
|
|||||||
|
|
||||||
const hasFFmpeg = await checkFFmpeg();
|
const hasFFmpeg = await checkFFmpeg();
|
||||||
const hasNvenc = await checkNvenc();
|
const hasNvenc = await checkNvenc();
|
||||||
|
const av1Support = await checkAV1Support();
|
||||||
const hasMP4Box = await checkMP4Box();
|
const hasMP4Box = await checkMP4Box();
|
||||||
|
|
||||||
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
|
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`);
|
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}\n`);
|
||||||
|
|
||||||
if (!hasFFmpeg) {
|
if (!hasFFmpeg) {
|
||||||
@@ -85,6 +109,13 @@ if (!hasMP4Box) {
|
|||||||
process.exit(1);
|
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
|
// Get video metadata and file size
|
||||||
console.log('📊 Analyzing video...\n');
|
console.log('📊 Analyzing video...\n');
|
||||||
const metadata = await getVideoMetadata(input);
|
const metadata = await getVideoMetadata(input);
|
||||||
@@ -105,6 +136,7 @@ if (metadata.audioBitrate) {
|
|||||||
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
|
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
|
||||||
}
|
}
|
||||||
console.log(`\n📁 Output: ${outputDir}`);
|
console.log(`\n📁 Output: ${outputDir}`);
|
||||||
|
console.log(`🎬 Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264 for maximum compatibility)' : ''}`);
|
||||||
if (customProfiles) {
|
if (customProfiles) {
|
||||||
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
|
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -133,6 +165,7 @@ try {
|
|||||||
outputDir,
|
outputDir,
|
||||||
customProfiles,
|
customProfiles,
|
||||||
posterTimecode,
|
posterTimecode,
|
||||||
|
codec: codecType,
|
||||||
segmentDuration: 2,
|
segmentDuration: 2,
|
||||||
useNvenc: hasNvenc,
|
useNvenc: hasNvenc,
|
||||||
generateThumbnails: true,
|
generateThumbnails: true,
|
||||||
@@ -182,7 +215,8 @@ try {
|
|||||||
console.log(` Manifest: ${result.manifestPath}`);
|
console.log(` Manifest: ${result.manifestPath}`);
|
||||||
console.log(` Duration: ${result.duration.toFixed(2)}s`);
|
console.log(` Duration: ${result.duration.toFixed(2)}s`);
|
||||||
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
|
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) {
|
if (result.posterPath) {
|
||||||
console.log(` Poster: ${result.posterPath}`);
|
console.log(` Poster: ${result.posterPath}`);
|
||||||
|
|||||||
@@ -6,12 +6,14 @@ import type {
|
|||||||
DashConvertResult,
|
DashConvertResult,
|
||||||
VideoProfile,
|
VideoProfile,
|
||||||
ThumbnailConfig,
|
ThumbnailConfig,
|
||||||
ConversionProgress
|
ConversionProgress,
|
||||||
|
CodecType
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
checkFFmpeg,
|
checkFFmpeg,
|
||||||
checkMP4Box,
|
checkMP4Box,
|
||||||
checkNvenc,
|
checkNvenc,
|
||||||
|
checkAV1Support,
|
||||||
getVideoMetadata,
|
getVideoMetadata,
|
||||||
ensureDir
|
ensureDir
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
@@ -33,6 +35,7 @@ export async function convertToDash(
|
|||||||
segmentDuration = 2,
|
segmentDuration = 2,
|
||||||
profiles: userProfiles,
|
profiles: userProfiles,
|
||||||
customProfiles,
|
customProfiles,
|
||||||
|
codec = 'dual',
|
||||||
useNvenc,
|
useNvenc,
|
||||||
generateThumbnails = true,
|
generateThumbnails = true,
|
||||||
thumbnailConfig = {},
|
thumbnailConfig = {},
|
||||||
@@ -54,6 +57,7 @@ export async function convertToDash(
|
|||||||
segmentDuration,
|
segmentDuration,
|
||||||
userProfiles,
|
userProfiles,
|
||||||
customProfiles,
|
customProfiles,
|
||||||
|
codec,
|
||||||
useNvenc,
|
useNvenc,
|
||||||
generateThumbnails,
|
generateThumbnails,
|
||||||
thumbnailConfig,
|
thumbnailConfig,
|
||||||
@@ -82,6 +86,7 @@ async function convertToDashInternal(
|
|||||||
segmentDuration: number,
|
segmentDuration: number,
|
||||||
userProfiles: VideoProfile[] | undefined,
|
userProfiles: VideoProfile[] | undefined,
|
||||||
customProfiles: string[] | undefined,
|
customProfiles: string[] | undefined,
|
||||||
|
codec: CodecType,
|
||||||
useNvenc: boolean | undefined,
|
useNvenc: boolean | undefined,
|
||||||
generateThumbnails: boolean,
|
generateThumbnails: boolean,
|
||||||
thumbnailConfig: ThumbnailConfig,
|
thumbnailConfig: ThumbnailConfig,
|
||||||
@@ -177,61 +182,92 @@ async function convertToDashInternal(
|
|||||||
|
|
||||||
await ensureDir(videoOutputDir);
|
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;
|
const maxConcurrent = willUseNvenc ? 3 : 2;
|
||||||
|
|
||||||
// STAGE 1: Encode profiles to MP4 (parallel - heavy work)
|
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
|
||||||
reportProgress('encoding', 25, `Stage 1: Encoding ${profiles.length} profiles to MP4...`);
|
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
|
||||||
|
|
||||||
const tempMP4Paths = await encodeProfilesToMP4(
|
for (let codecIndex = 0; codecIndex < codecs.length; codecIndex++) {
|
||||||
input,
|
const { type, codec: videoCodec, preset: codecPreset } = codecs[codecIndex];
|
||||||
tempDir,
|
const codecProgress = codecIndex / codecs.length;
|
||||||
profiles,
|
const codecProgressRange = 1 / codecs.length;
|
||||||
videoCodec,
|
|
||||||
codecPreset,
|
|
||||||
metadata.duration,
|
|
||||||
segmentDuration,
|
|
||||||
metadata.fps || 25, // Use detected FPS or default to 25
|
|
||||||
metadata.audioBitrate, // Source audio bitrate for smart selection
|
|
||||||
parallel,
|
|
||||||
maxConcurrent,
|
|
||||||
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);
|
|
||||||
|
|
||||||
// Also report individual profile progress
|
reportProgress('encoding', 25 + codecProgress * 40, `Stage 1: Encoding ${type.toUpperCase()} (${profiles.length} profiles)...`);
|
||||||
if (onProgress) {
|
|
||||||
onProgress({
|
const tempMP4Paths = await encodeProfilesToMP4(
|
||||||
stage: 'encoding',
|
input,
|
||||||
percent: baseProgress + profileProgress,
|
tempDir,
|
||||||
currentProfile: profileName,
|
profiles,
|
||||||
profilePercent: percent, // Actual profile progress 0-100
|
videoCodec,
|
||||||
message: `Encoding ${profileName}...`
|
codecPreset,
|
||||||
});
|
metadata.duration,
|
||||||
|
segmentDuration,
|
||||||
|
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 + 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: `${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)
|
// STAGE 2: Package to DASH using MP4Box (light work, fast)
|
||||||
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
|
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
|
||||||
|
|
||||||
const manifestPath = await packageToDash(
|
const manifestPath = await packageToDash(
|
||||||
tempMP4Paths,
|
codecMP4Paths,
|
||||||
videoOutputDir,
|
videoOutputDir,
|
||||||
profiles,
|
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');
|
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
|
||||||
|
|
||||||
@@ -293,7 +329,8 @@ async function convertToDashInternal(
|
|||||||
posterPath,
|
posterPath,
|
||||||
duration: metadata.duration,
|
duration: metadata.duration,
|
||||||
profiles,
|
profiles,
|
||||||
usedNvenc: willUseNvenc
|
usedNvenc: willUseNvenc,
|
||||||
|
codecType: codec
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,10 +16,11 @@ export async function encodeProfileToMP4(
|
|||||||
segmentDuration: number,
|
segmentDuration: number,
|
||||||
fps: number,
|
fps: number,
|
||||||
sourceAudioBitrate: number | undefined,
|
sourceAudioBitrate: number | undefined,
|
||||||
|
codecType: 'h264' | 'av1',
|
||||||
optimizations?: VideoOptimizations,
|
optimizations?: VideoOptimizations,
|
||||||
onProgress?: (percent: number) => void
|
onProgress?: (percent: number) => void
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const outputPath = join(tempDir, `video_${profile.name}.mp4`);
|
const outputPath = join(tempDir, `video_${codecType}_${profile.name}.mp4`);
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
'-y',
|
'-y',
|
||||||
@@ -27,20 +28,44 @@ export async function encodeProfileToMP4(
|
|||||||
'-c:v', videoCodec
|
'-c:v', videoCodec
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add NVENC specific options
|
// Add codec-specific options
|
||||||
if (videoCodec === 'h264_nvenc') {
|
if (videoCodec === 'h264_nvenc') {
|
||||||
|
// NVIDIA H.264
|
||||||
args.push('-rc:v', 'vbr');
|
args.push('-rc:v', 'vbr');
|
||||||
args.push('-preset', preset);
|
args.push('-preset', preset);
|
||||||
args.push('-2pass', '0');
|
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 {
|
} else {
|
||||||
|
// Default (libx264, libx265, etc.)
|
||||||
args.push('-preset', preset);
|
args.push('-preset', preset);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Video encoding parameters
|
// 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(
|
args.push(
|
||||||
'-b:v', profile.videoBitrate,
|
'-b:v', bitrateString,
|
||||||
'-maxrate', profile.videoBitrate,
|
'-maxrate', bitrateString,
|
||||||
'-bufsize', `${parseInt(profile.videoBitrate) * 2}k`
|
'-bufsize', `${targetBitrate * 2}k`
|
||||||
);
|
);
|
||||||
|
|
||||||
// Set GOP size for DASH segments
|
// Set GOP size for DASH segments
|
||||||
@@ -105,6 +130,7 @@ export async function encodeProfilesToMP4(
|
|||||||
sourceAudioBitrate: number | undefined,
|
sourceAudioBitrate: number | undefined,
|
||||||
parallel: boolean,
|
parallel: boolean,
|
||||||
maxConcurrent: number,
|
maxConcurrent: number,
|
||||||
|
codecType: 'h264' | 'av1',
|
||||||
optimizations?: VideoOptimizations,
|
optimizations?: VideoOptimizations,
|
||||||
onProgress?: (profileName: string, percent: number) => void
|
onProgress?: (profileName: string, percent: number) => void
|
||||||
): Promise<Map<string, string>> {
|
): Promise<Map<string, string>> {
|
||||||
@@ -125,6 +151,7 @@ export async function encodeProfilesToMP4(
|
|||||||
segmentDuration,
|
segmentDuration,
|
||||||
fps,
|
fps,
|
||||||
sourceAudioBitrate,
|
sourceAudioBitrate,
|
||||||
|
codecType,
|
||||||
optimizations,
|
optimizations,
|
||||||
(percent) => {
|
(percent) => {
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
@@ -153,6 +180,7 @@ export async function encodeProfilesToMP4(
|
|||||||
segmentDuration,
|
segmentDuration,
|
||||||
fps,
|
fps,
|
||||||
sourceAudioBitrate,
|
sourceAudioBitrate,
|
||||||
|
codecType,
|
||||||
optimizations,
|
optimizations,
|
||||||
(percent) => {
|
(percent) => {
|
||||||
if (onProgress) {
|
if (onProgress) {
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { execMP4Box } from '../utils';
|
import { execMP4Box } from '../utils';
|
||||||
import type { VideoProfile } from '../types';
|
import type { VideoProfile, CodecType } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Package MP4 files into DASH format using MP4Box
|
* Package MP4 files into DASH format using MP4Box
|
||||||
* Stage 2: Light work - just packaging, no encoding
|
* 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(
|
export async function packageToDash(
|
||||||
mp4Files: Map<string, string>,
|
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
profiles: VideoProfile[],
|
profiles: VideoProfile[],
|
||||||
segmentDuration: number
|
segmentDuration: number,
|
||||||
|
codecType: CodecType
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const manifestPath = join(outputDir, 'manifest.mpd');
|
const manifestPath = join(outputDir, 'manifest.mpd');
|
||||||
|
|
||||||
@@ -20,22 +21,32 @@ export async function packageToDash(
|
|||||||
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
|
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
|
||||||
'-frag', String(segmentDuration * 1000),
|
'-frag', String(segmentDuration * 1000),
|
||||||
'-rap', // Force segments to start with random access points
|
'-rap', // Force segments to start with random access points
|
||||||
|
'-segment-timeline', // Use SegmentTimeline for accurate segment durations
|
||||||
'-segment-name', '$RepresentationID$_$Number$',
|
'-segment-name', '$RepresentationID$_$Number$',
|
||||||
'-out', manifestPath
|
'-out', manifestPath
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add all MP4 files with their profile IDs
|
// Add all MP4 files for each codec
|
||||||
for (const profile of profiles) {
|
let firstFile = true;
|
||||||
const mp4Path = mp4Files.get(profile.name);
|
|
||||||
if (!mp4Path) {
|
|
||||||
throw new Error(`MP4 file not found for profile: ${profile.name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add video track with representation ID
|
for (const [codec, mp4Files] of codecMP4Files.entries()) {
|
||||||
args.push(`${mp4Path}#video:id=${profile.name}`);
|
for (const profile of profiles) {
|
||||||
// Add audio track (shared across all profiles)
|
const mp4Path = mp4Files.get(profile.name);
|
||||||
if (profile === profiles[0]) {
|
if (!mp4Path) {
|
||||||
args.push(`${mp4Path}#audio:id=audio`);
|
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=${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
|
// MP4Box creates files in the same directory as output MPD
|
||||||
// Move segment files to profile subdirectories for clean structure
|
// 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
|
// Update MPD to reflect new file structure with subdirectories
|
||||||
await updateManifestPaths(manifestPath, profiles);
|
await updateManifestPaths(manifestPath, profiles, codecType);
|
||||||
|
|
||||||
return manifestPath;
|
return manifestPath;
|
||||||
}
|
}
|
||||||
@@ -58,14 +69,27 @@ export async function packageToDash(
|
|||||||
*/
|
*/
|
||||||
async function organizeSegments(
|
async function organizeSegments(
|
||||||
outputDir: string,
|
outputDir: string,
|
||||||
profiles: VideoProfile[]
|
profiles: VideoProfile[],
|
||||||
|
codecType: CodecType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { readdir, rename, mkdir } = await import('node:fs/promises');
|
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 (const profile of profiles) {
|
// For single-codec mode, use simple profile names (e.g., "720p/")
|
||||||
const profileDir = join(outputDir, profile.name);
|
const codecs: Array<'h264' | 'av1'> = [];
|
||||||
await mkdir(profileDir, { recursive: true });
|
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 repId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
|
||||||
|
representationIds.push(repId);
|
||||||
|
|
||||||
|
const profileDir = join(outputDir, repId);
|
||||||
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create audio subdirectory
|
// Create audio subdirectory
|
||||||
@@ -90,11 +114,11 @@ async function organizeSegments(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Move video segment files to their profile directories
|
// Move video segment files to their representation directories
|
||||||
for (const profile of profiles) {
|
for (const repId of representationIds) {
|
||||||
if (file.startsWith(`${profile.name}_`)) {
|
if (file.startsWith(`${repId}_`)) {
|
||||||
const oldPath = join(outputDir, file);
|
const oldPath = join(outputDir, file);
|
||||||
const newPath = join(outputDir, profile.name, file);
|
const newPath = join(outputDir, repId, file);
|
||||||
await rename(oldPath, newPath);
|
await rename(oldPath, newPath);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -107,7 +131,8 @@ async function organizeSegments(
|
|||||||
*/
|
*/
|
||||||
async function updateManifestPaths(
|
async function updateManifestPaths(
|
||||||
manifestPath: string,
|
manifestPath: string,
|
||||||
profiles: VideoProfile[]
|
profiles: VideoProfile[],
|
||||||
|
codecType: CodecType
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const { readFile, writeFile } = await import('node:fs/promises');
|
const { readFile, writeFile } = await import('node:fs/promises');
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export type {
|
|||||||
ThumbnailConfig,
|
ThumbnailConfig,
|
||||||
ConversionProgress,
|
ConversionProgress,
|
||||||
VideoMetadata,
|
VideoMetadata,
|
||||||
VideoOptimizations
|
VideoOptimizations,
|
||||||
|
CodecType
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// Utility exports
|
// Utility exports
|
||||||
@@ -17,6 +18,7 @@ export {
|
|||||||
checkFFmpeg,
|
checkFFmpeg,
|
||||||
checkMP4Box,
|
checkMP4Box,
|
||||||
checkNvenc,
|
checkNvenc,
|
||||||
|
checkAV1Support,
|
||||||
getVideoMetadata,
|
getVideoMetadata,
|
||||||
selectAudioBitrate
|
selectAudioBitrate
|
||||||
} from './utils';
|
} from './utils';
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Video codec type for encoding
|
||||||
|
*/
|
||||||
|
export type CodecType = 'av1' | 'h264' | 'dual';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for DASH conversion
|
* Configuration options for DASH conversion
|
||||||
*/
|
*/
|
||||||
@@ -17,6 +22,9 @@ export interface DashConvertOptions {
|
|||||||
/** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */
|
/** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */
|
||||||
customProfiles?: string[];
|
customProfiles?: string[];
|
||||||
|
|
||||||
|
/** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */
|
||||||
|
codec?: CodecType;
|
||||||
|
|
||||||
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
||||||
useNvenc?: boolean;
|
useNvenc?: boolean;
|
||||||
|
|
||||||
@@ -123,6 +131,9 @@ export interface DashConvertResult {
|
|||||||
|
|
||||||
/** Whether NVENC was used */
|
/** Whether NVENC was used */
|
||||||
usedNvenc: boolean;
|
usedNvenc: boolean;
|
||||||
|
|
||||||
|
/** Codec type used for encoding */
|
||||||
|
codecType: CodecType;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export {
|
|||||||
checkFFmpeg,
|
checkFFmpeg,
|
||||||
checkMP4Box,
|
checkMP4Box,
|
||||||
checkNvenc,
|
checkNvenc,
|
||||||
|
checkAV1Support,
|
||||||
execFFmpeg,
|
execFFmpeg,
|
||||||
execMP4Box
|
execMP4Box
|
||||||
} from './system';
|
} from './system';
|
||||||
|
|||||||
@@ -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
|
* Execute FFmpeg command with progress tracking
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user