Files
create-vod/src/cli.ts

458 lines
17 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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);
}