Files
create-vod/src/cli.ts

458 lines
17 KiB
TypeScript
Raw Normal View History

#!/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, detectHardwareDecoders, testEncoder, testDecoder } from './index';
import cliProgress from 'cli-progress';
import { statSync } from 'node:fs';
import { basename, extname } from 'node:path';
import type { QualitySettings, HardwareAccelerationOption, HardwareAccelerator, CodecType } 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 codecChoice: Array<CodecType> | undefined; // default h264
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;
let decoder: 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 codecArg = args[i + 1];
const parts = codecArg.split(/[,\s]+/).map(p => p.trim()).filter(Boolean);
const allowed = new Set(['h264', 'av1']);
for (const p of parts) {
if (!allowed.has(p)) {
console.error(`❌ Invalid codec: ${p}. Valid options: av1, h264`);
process.exit(1);
}
}
codecChoice = Array.from(new Set(parts)) as Array<'h264' | 'av1'>;
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] === '-e' || args[i] === '--encoder') {
const acc = args[i + 1];
const allowed: HardwareAccelerationOption[] = ['auto', 'nvenc', 'qsv', 'amf', 'cpu', 'vaapi', 'videotoolbox', 'v4l2'];
if (!allowed.includes(acc as HardwareAccelerationOption)) {
console.error(`❌ Invalid accelerator: ${acc}. Valid: auto, nvenc, qsv, amf, vaapi, videotoolbox, v4l2, cpu`);
process.exit(1);
}
accelerator = acc as HardwareAccelerationOption;
i++;
} else if (args[i] === '-d' || args[i] === '--decoder') {
const acc = args[i + 1];
const allowed: HardwareAccelerationOption[] = ['auto', 'nvenc', 'qsv', 'amf', 'vaapi', 'videotoolbox', 'v4l2', 'cpu'];
if (!allowed.includes(acc as HardwareAccelerationOption)) {
console.error(`❌ Invalid decoder: ${acc}. Valid: auto, nvenc, qsv, amf, vaapi, videotoolbox, v4l2, cpu`);
process.exit(1);
}
decoder = 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 or h264 (default: auto = h264 + AV1 if HW)');
console.error(' -f, --format Streaming format: dash or hls (default: auto = dash + hls)');
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
console.error(' -e, --encoder <type> Hardware encoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)');
console.error(' -d, --decoder <type> Hardware decoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|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 h264 --h264-cq 30');
console.error(' create-vod video.mp4 -f hls');
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 -p 10 --h264-cq 28');
process.exit(1);
}
console.log('🔍 Checking system...\n');
const hasFFmpeg = await checkFFmpeg();
const hasMP4Box = await checkMP4Box();
const hwEncoders = await detectHardwareEncoders();
const hwDecoders = await detectHardwareDecoders();
const hasAv1Hardware = hwEncoders.some(item => item.av1Encoder);
const accelPriority: Record<string, number> = {
nvenc: 100,
qsv: 90,
amf: 80,
vaapi: 70,
videotoolbox: 65,
v4l2: 60,
cpu: 1
};
const encoderMap: Record<string, string> = {
nvenc: 'h264_nvenc',
qsv: 'h264_qsv',
amf: 'h264_amf',
vaapi: 'h264_vaapi',
videotoolbox: 'h264_videotoolbox',
v4l2: 'h264_v4l2m2m',
cpu: 'libx264'
};
const encoderCandidates = Array.from(new Set([...hwEncoders.map(e => e.accelerator), 'cpu']));
const decoderCandidates = Array.from(new Set([...hwDecoders.map(d => d.accelerator), 'cpu']));
async function filterEncoders() {
const result: HardwareAccelerationOption[] = [];
for (const acc of encoderCandidates) {
if (acc === 'amf') {
continue;
}
const encoderName = encoderMap[acc] || 'libx264';
const ok = await testEncoder(encoderName);
if (ok) result.push(acc as HardwareAccelerationOption);
}
return result;
}
async function filterDecoders() {
const result: HardwareAccelerationOption[] = [];
for (const acc of decoderCandidates) {
if (acc === 'cpu') {
result.push('cpu');
continue;
}
const ok = await testDecoder(acc as HardwareAccelerator, input);
if (ok) result.push(acc as HardwareAccelerationOption);
}
return result;
}
const availableEncoders = await filterEncoders();
const availableDecoders = await filterDecoders();
const bestAccel = availableEncoders
.slice()
.sort((a, b) => (accelPriority[b] || 0) - (accelPriority[a] || 0))[0];
const bestDecoder = availableDecoders
.slice()
.sort((a, b) => (accelPriority[b] || 0) - (accelPriority[a] || 0))[0];
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}`);
const accelList = Array.from(new Set(availableEncoders.map(e => e.toUpperCase())));
const decList = Array.from(new Set(availableDecoders.map(d => d.toUpperCase())));
const encoderSelectedPlanned = accelerator
? accelerator.toUpperCase()
: ((bestAccel && bestAccel.toUpperCase()) || 'CPU');
const encoderAll = accelList.length > 0 ? accelList : ['CPU'];
const decoderSelectedPlanned = decoder
? decoder.toUpperCase()
: ((bestDecoder && bestDecoder.toUpperCase()) || 'CPU');
const decoderAll = decList.length > 0 ? decList : ['CPU'];
console.log(`Encoder: ${encoderSelectedPlanned === 'AUTO' ? ((bestAccel && bestAccel.toUpperCase()) || 'CPU') : encoderSelectedPlanned} (${encoderAll.join(', ')})`);
console.log(`Decoder: ${decoderSelectedPlanned === 'AUTO' ? ((bestDecoder && bestDecoder.toUpperCase()) || 'CPU') : decoderSelectedPlanned} (${decoderAll.join(', ')})`);
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);
}
// Resolve codec selection
const codecsRequested = codecChoice && codecChoice.length > 0 ? codecChoice : ['h264'];
let includeH264 = codecsRequested.includes('h264');
let includeAv1 = codecsRequested.includes('av1');
if (!includeH264) {
console.warn('⚠️ H.264 is mandatory for compatibility. Adding H.264.');
includeH264 = true;
}
if (includeAv1 && !hasAv1Hardware) {
console.error(`⚠️ AV1 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`);
}
// Formats are always both
const wantDash = true;
const wantHls = true;
// 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 = [
wantDash ? 'DASH (manifest.mpd)' : null,
wantHls ? 'HLS (master.m3u8)' : null
].filter(Boolean).join(', ');
const thumbnailsPlanned = true;
const posterPlanned = posterTimecode || '00:00:00';
const codecListDisplay = [
includeH264 ? 'h264' : null,
includeAv1 ? 'av1' : null
].filter(Boolean).join(', ');
const codecNote = (!includeAv1 && codecsRequested.includes('av1')) ? ' (AV1 disabled: no HW)' : '';
const bestAccelName = (bestAccel && bestAccel.toUpperCase()) || 'CPU';
const bestDecoderName = (bestDecoder && bestDecoder.toUpperCase()) || 'CPU';
const plannedAccel = accelerator ? accelerator.toUpperCase() : bestAccelName;
const plannedDecoder = decoder ? decoder.toUpperCase() : bestDecoderName;
const acceleratorDisplay = plannedAccel === 'AUTO' ? bestAccelName : plannedAccel;
const decoderDisplay = plannedDecoder === 'AUTO' ? bestDecoderName : plannedDecoder;
const encoderListDisplay = encoderAll.join(', ');
const decoderListDisplay = decoderAll.join(', ');
console.log('\n📦 Parameters:');
console.log(` Input: ${input}`);
console.log(` Output: ${outputDir}`);
console.log(` Codec: ${codecListDisplay}${codecNote}`);
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(` Encoder: ${acceleratorDisplay} (available: ${encoderListDisplay})`);
console.log(` Decoder: ${decoderDisplay} (available: ${decoderListDisplay})`);
// 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 startedAt = Date.now();
const result = await convertToDash({
input,
outputDir,
customProfiles,
posterTimecode,
codec: [
...(includeH264 ? ['h264'] as const : []),
...(includeAv1 ? ['av1'] as const : [])
],
segmentDuration: 2,
hardwareAccelerator: accelerator,
hardwareDecoder: decoder,
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();
const elapsedMs = Date.now() - startedAt;
const elapsedSec = (elapsedMs / 1000).toFixed(2);
console.log(`\n✅ Conversion completed successfully! (${elapsedSec}s)\n`);
} catch (error) {
multibar.stop();
console.error('\n\n❌ Error during conversion:');
console.error(error);
process.exit(1);
}