373 lines
14 KiB
JavaScript
373 lines
14 KiB
JavaScript
#!/usr/bin/env node
|
||
|
||
/**
|
||
* DASH Video Converter CLI
|
||
*
|
||
* Usage:
|
||
* create-vod <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
|
||
*
|
||
* Example:
|
||
* create-vod ./video.mp4 ./output -r 720,1080
|
||
*/
|
||
|
||
import { convertToDash, checkFFmpeg, checkMP4Box, getVideoMetadata, detectHardwareEncoders } from './index';
|
||
import cliProgress from 'cli-progress';
|
||
import { statSync } from 'node:fs';
|
||
import { basename, extname } from 'node:path';
|
||
import type { CodecType, StreamingFormat, QualitySettings, HardwareAccelerationOption } from './types';
|
||
import { selectProfiles, createProfilesFromStrings } from './config/profiles';
|
||
|
||
// Parse arguments
|
||
const args = process.argv.slice(2);
|
||
let customProfiles: string[] | undefined;
|
||
let posterTimecode: string | undefined;
|
||
let codecType: CodecType = 'dual'; // Default to dual codec
|
||
let formatType: StreamingFormat = 'both'; // Default to both formats (DASH + HLS)
|
||
const positionalArgs: string[] = [];
|
||
|
||
// Quality settings
|
||
let h264CQ: number | undefined;
|
||
let h264CRF: number | undefined;
|
||
let av1CQ: number | undefined;
|
||
let av1CRF: number | undefined;
|
||
let accelerator: HardwareAccelerationOption | undefined;
|
||
|
||
// First pass: extract flags and their values
|
||
for (let i = 0; i < args.length; i++) {
|
||
if (args[i] === '-r' || args[i] === '--resolutions') {
|
||
// Collect all arguments after -r until next flag or end
|
||
const profilesArgs: string[] = [];
|
||
for (let j = i + 1; j < args.length; j++) {
|
||
// Stop if we hit another flag (starts with -)
|
||
if (args[j].startsWith('-')) {
|
||
break;
|
||
}
|
||
profilesArgs.push(args[j]);
|
||
i = j; // Skip these args in main loop
|
||
}
|
||
|
||
// Parse profiles
|
||
const joinedArgs = profilesArgs.join(',');
|
||
customProfiles = joinedArgs
|
||
.split(/[,\s]+/) // Split by comma or whitespace
|
||
.map(s => s.trim())
|
||
.filter(s => s.length > 0);
|
||
} 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] === '-f' || args[i] === '--format') {
|
||
const format = args[i + 1];
|
||
if (format === 'dash' || format === 'hls' || format === 'both') {
|
||
formatType = format;
|
||
} else {
|
||
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls, both`);
|
||
process.exit(1);
|
||
}
|
||
i++; // Skip next arg
|
||
} else if (args[i] === '--h264-cq') {
|
||
h264CQ = parseInt(args[i + 1]);
|
||
if (isNaN(h264CQ) || h264CQ < 0 || h264CQ > 51) {
|
||
console.error(`❌ Invalid H.264 CQ value: ${args[i + 1]}. Must be 0-51`);
|
||
process.exit(1);
|
||
}
|
||
i++; // Skip next arg
|
||
} else if (args[i] === '--h264-crf') {
|
||
h264CRF = parseInt(args[i + 1]);
|
||
if (isNaN(h264CRF) || h264CRF < 0 || h264CRF > 51) {
|
||
console.error(`❌ Invalid H.264 CRF value: ${args[i + 1]}. Must be 0-51`);
|
||
process.exit(1);
|
||
}
|
||
i++; // Skip next arg
|
||
} else if (args[i] === '--av1-cq') {
|
||
av1CQ = parseInt(args[i + 1]);
|
||
if (isNaN(av1CQ) || av1CQ < 0 || av1CQ > 51) {
|
||
console.error(`❌ Invalid AV1 CQ value: ${args[i + 1]}. Must be 0-51`);
|
||
process.exit(1);
|
||
}
|
||
i++; // Skip next arg
|
||
} else if (args[i] === '--av1-crf') {
|
||
av1CRF = parseInt(args[i + 1]);
|
||
if (isNaN(av1CRF) || av1CRF < 0 || av1CRF > 63) {
|
||
console.error(`❌ Invalid AV1 CRF value: ${args[i + 1]}. Must be 0-63`);
|
||
process.exit(1);
|
||
}
|
||
i++; // Skip next arg
|
||
} else if (args[i] === '--accel' || args[i] === '--hardware') {
|
||
const acc = args[i + 1];
|
||
const allowed: HardwareAccelerationOption[] = ['auto', 'nvenc', 'qsv', 'amf', 'cpu'];
|
||
if (!allowed.includes(acc as HardwareAccelerationOption)) {
|
||
console.error(`❌ Invalid accelerator: ${acc}. Valid: auto, nvenc, qsv, amf, cpu`);
|
||
process.exit(1);
|
||
}
|
||
accelerator = acc as HardwareAccelerationOption;
|
||
i++;
|
||
} else if (!args[i].startsWith('-')) {
|
||
// Positional argument
|
||
positionalArgs.push(args[i]);
|
||
}
|
||
}
|
||
|
||
// Extract positional arguments
|
||
const input = positionalArgs[0];
|
||
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
||
|
||
if (!input) {
|
||
console.error('❌ Usage: create-vod <input-video> [output-dir] [options]');
|
||
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(' -f, --format Streaming format: dash, hls, or both (default: both)');
|
||
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
|
||
console.error(' --accel <type> Hardware accelerator: auto|nvenc|qsv|amf|cpu (default: auto)');
|
||
console.error('\nQuality Options (override defaults):');
|
||
console.error(' --h264-cq <value> H.264 GPU CQ value (0-51, lower = better, default: auto)');
|
||
console.error(' --h264-crf <value> H.264 CPU CRF value (0-51, lower = better, default: auto)');
|
||
console.error(' --av1-cq <value> AV1 GPU CQ value (0-51, lower = better, default: auto)');
|
||
console.error(' --av1-crf <value> AV1 CPU CRF value (0-63, lower = better, default: auto)');
|
||
console.error('\nExamples:');
|
||
console.error(' create-vod video.mp4');
|
||
console.error(' create-vod video.mp4 ./output');
|
||
console.error(' create-vod video.mp4 -r 360,480,720');
|
||
console.error(' create-vod video.mp4 -c av1 --av1-cq 40');
|
||
console.error(' create-vod video.mp4 -c dual --h264-cq 30 --av1-cq 39');
|
||
console.error(' create-vod video.mp4 -f hls');
|
||
console.error(' create-vod video.mp4 -c dual -f both');
|
||
console.error(' create-vod video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash');
|
||
console.error(' create-vod video.mp4 -p 00:00:05');
|
||
console.error(' create-vod video.mp4 ./output -r 720,1080 -c dual -f both -p 10 --h264-cq 28 --av1-cq 37');
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log('🔍 Checking system...\n');
|
||
|
||
const hasFFmpeg = await checkFFmpeg();
|
||
const hasMP4Box = await checkMP4Box();
|
||
const hwEncoders = await detectHardwareEncoders();
|
||
|
||
const accelPriority: Record<string, number> = {
|
||
nvenc: 100,
|
||
qsv: 90,
|
||
amf: 80,
|
||
vaapi: 70,
|
||
videotoolbox: 65,
|
||
v4l2: 60
|
||
};
|
||
|
||
const bestAccel = hwEncoders
|
||
.slice()
|
||
.sort((a, b) => (accelPriority[b.accelerator] || 0) - (accelPriority[a.accelerator] || 0))[0];
|
||
|
||
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
|
||
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}`);
|
||
const accelList = Array.from(new Set(hwEncoders.map(e => e.accelerator.toUpperCase())));
|
||
const bestAccelName = bestAccel ? bestAccel.accelerator.toUpperCase() : undefined;
|
||
const accelRest = accelList.filter(name => name !== bestAccelName);
|
||
const accelLabel = bestAccelName
|
||
? `✅ ${bestAccelName}${accelRest.length > 0 ? ` (${accelRest.join(', ')})` : ''}`
|
||
: '❌';
|
||
console.log(`Hardware: ${accelLabel}`);
|
||
console.log('');
|
||
|
||
if (!hasFFmpeg) {
|
||
console.error('❌ FFmpeg not found. Please install FFmpeg first.');
|
||
process.exit(1);
|
||
}
|
||
|
||
if (!hasMP4Box) {
|
||
console.error('❌ MP4Box not found. Please install: sudo pacman -S gpac');
|
||
process.exit(1);
|
||
}
|
||
|
||
// Validate codec selection
|
||
const hasAv1Hardware = hwEncoders.some(item => item.av1Encoder);
|
||
|
||
if ((codecType === 'av1' || codecType === 'dual') && !hasAv1Hardware) {
|
||
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`);
|
||
}
|
||
|
||
// Validate HLS requires H.264
|
||
if ((formatType === 'hls' || formatType === 'both') && codecType === 'av1') {
|
||
console.error(`❌ Error: HLS format requires H.264 codec for Safari/iOS compatibility.`);
|
||
console.error(` Please use --codec h264 or --codec dual with --format hls\n`);
|
||
process.exit(1);
|
||
}
|
||
|
||
// Get video metadata and file size
|
||
console.log('📊 Analyzing video...\n');
|
||
const metadata = await getVideoMetadata(input);
|
||
const fileStats = statSync(input);
|
||
const fileSizeMB = (fileStats.size / (1024 * 1024)).toFixed(2);
|
||
|
||
console.log('📹 Video Information:');
|
||
console.log(` File: ${input}`);
|
||
console.log(` Size: ${fileSizeMB} MB`);
|
||
console.log(` Resolution: ${metadata.width}x${metadata.height}`);
|
||
console.log(` FPS: ${metadata.fps.toFixed(2)}`);
|
||
console.log(` Duration: ${Math.floor(metadata.duration / 60)}m ${Math.floor(metadata.duration % 60)}s`);
|
||
console.log(` Codec: ${metadata.codec}`);
|
||
if (metadata.videoBitrate) {
|
||
console.log(` Video Bitrate: ${(metadata.videoBitrate / 1000).toFixed(2)} Mbps`);
|
||
}
|
||
if (metadata.audioBitrate) {
|
||
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
|
||
}
|
||
|
||
// Pre-calc profiles for display (match internal selection logic)
|
||
let displayProfiles: string[] = [];
|
||
if (customProfiles && customProfiles.length > 0) {
|
||
const profileResult = createProfilesFromStrings(
|
||
customProfiles,
|
||
metadata.width,
|
||
metadata.height,
|
||
metadata.fps,
|
||
metadata.videoBitrate
|
||
);
|
||
|
||
if (profileResult.errors.length > 0) {
|
||
console.error('\n❌ Profile errors:');
|
||
profileResult.errors.forEach(err => console.error(` - ${err}`));
|
||
process.exit(1);
|
||
}
|
||
|
||
if (profileResult.warnings.length > 0) {
|
||
console.warn('\n⚠️ Profile warnings:');
|
||
profileResult.warnings.forEach(warn => console.warn(` - ${warn}`));
|
||
}
|
||
|
||
displayProfiles = profileResult.profiles.map(p => p.name);
|
||
} else {
|
||
const autoProfiles = selectProfiles(
|
||
metadata.width,
|
||
metadata.height,
|
||
metadata.fps,
|
||
metadata.videoBitrate
|
||
);
|
||
displayProfiles = autoProfiles.map(p => p.name);
|
||
}
|
||
|
||
const manifestDesc =
|
||
formatType === 'both' ? 'DASH (manifest.mpd), HLS (master.m3u8)' :
|
||
formatType === 'dash' ? 'DASH (manifest.mpd)' : 'HLS (master.m3u8)';
|
||
|
||
const thumbnailsPlanned = true;
|
||
const posterPlanned = posterTimecode || '00:00:00';
|
||
|
||
console.log('\n📦 Parameters:');
|
||
console.log(` Input: ${input}`);
|
||
console.log(` Output: ${outputDir}`);
|
||
console.log(` Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
|
||
console.log(` Profiles: ${displayProfiles.join(', ')}`);
|
||
console.log(` Manifests: ${manifestDesc}`);
|
||
console.log(` Poster: ${posterPlanned} (will be generated)`);
|
||
console.log(` Thumbnails: ${thumbnailsPlanned ? 'yes (with VTT)' : 'no'}`);
|
||
console.log(` Accelerator: ${bestAccel ? bestAccel.accelerator.toUpperCase() : 'CPU'}`);
|
||
|
||
// Build quality settings if any are specified
|
||
let quality: QualitySettings | undefined;
|
||
if (h264CQ !== undefined || h264CRF !== undefined || av1CQ !== undefined || av1CRF !== undefined) {
|
||
quality = {};
|
||
|
||
if (h264CQ !== undefined || h264CRF !== undefined) {
|
||
quality.h264 = {};
|
||
if (h264CQ !== undefined) quality.h264.cq = h264CQ;
|
||
if (h264CRF !== undefined) quality.h264.crf = h264CRF;
|
||
console.log(`🎚️ H.264 Quality: ${h264CQ !== undefined ? `CQ ${h264CQ}` : ''}${h264CRF !== undefined ? ` CRF ${h264CRF}` : ''}`);
|
||
}
|
||
|
||
if (av1CQ !== undefined || av1CRF !== undefined) {
|
||
quality.av1 = {};
|
||
if (av1CQ !== undefined) quality.av1.cq = av1CQ;
|
||
if (av1CRF !== undefined) quality.av1.crf = av1CRF;
|
||
console.log(`🎚️ AV1 Quality: ${av1CQ !== undefined ? `CQ ${av1CQ}` : ''}${av1CRF !== undefined ? ` CRF ${av1CRF}` : ''}`);
|
||
}
|
||
}
|
||
|
||
console.log('\n🚀 Starting conversion...\n');
|
||
|
||
// Create multibar container
|
||
const multibar = new cliProgress.MultiBar({
|
||
format: '{stage} | {bar} | {percentage}% | {name}',
|
||
barCompleteChar: '█',
|
||
barIncompleteChar: '░',
|
||
hideCursor: true,
|
||
clearOnComplete: false,
|
||
stopOnComplete: true
|
||
}, cliProgress.Presets.shades_classic);
|
||
|
||
// Track progress bars for each profile
|
||
const bars: Record<string, any> = {};
|
||
let overallBar: any = null;
|
||
|
||
try {
|
||
const result = await convertToDash({
|
||
input,
|
||
outputDir,
|
||
customProfiles,
|
||
posterTimecode,
|
||
codec: codecType,
|
||
format: formatType,
|
||
segmentDuration: 2,
|
||
hardwareAccelerator: accelerator,
|
||
quality,
|
||
generateThumbnails: true,
|
||
generatePoster: true,
|
||
parallel: true,
|
||
onProgress: (progress) => {
|
||
const stageName = progress.stage === 'encoding' ? 'Encoding' :
|
||
progress.stage === 'thumbnails' ? 'Thumbnails' :
|
||
progress.stage === 'manifest' ? 'Manifest' :
|
||
progress.stage === 'analyzing' ? 'Analyzing' : 'Complete';
|
||
|
||
// Stage 1: Encoding - show individual profile bars
|
||
if (progress.stage === 'encoding' && progress.currentProfile) {
|
||
if (!bars[progress.currentProfile]) {
|
||
bars[progress.currentProfile] = multibar.create(100, 0, {
|
||
stage: 'Encode',
|
||
name: progress.currentProfile
|
||
});
|
||
}
|
||
// Use profilePercent (0-100) for individual bars, not overall percent
|
||
const profileProgress = progress.profilePercent ?? progress.percent;
|
||
bars[progress.currentProfile].update(profileProgress, {
|
||
stage: 'Encode',
|
||
name: progress.currentProfile
|
||
});
|
||
}
|
||
|
||
// Overall progress bar
|
||
if (!overallBar) {
|
||
overallBar = multibar.create(100, 0, {
|
||
stage: stageName,
|
||
name: 'Overall'
|
||
});
|
||
}
|
||
|
||
overallBar.update(progress.percent, {
|
||
stage: stageName,
|
||
name: progress.message || 'Overall'
|
||
});
|
||
}
|
||
});
|
||
|
||
multibar.stop();
|
||
|
||
console.log('\n✅ Conversion completed successfully!\n');
|
||
|
||
} catch (error) {
|
||
multibar.stop();
|
||
console.error('\n\n❌ Error during conversion:');
|
||
console.error(error);
|
||
process.exit(1);
|
||
}
|