#!/usr/bin/env node /** * DASH Video Converter CLI * * Usage: * create-vod [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 | 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 [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 Hardware encoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)'); console.error(' -d, --decoder Hardware decoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)'); console.error('\nQuality Options (override defaults):'); console.error(' --h264-cq H.264 GPU CQ value (0-51, lower = better, default: auto)'); console.error(' --h264-crf H.264 CPU CRF value (0-51, lower = better, default: auto)'); console.error(' --av1-cq AV1 GPU CQ value (0-51, lower = better, default: auto)'); console.error(' --av1-crf 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 = { nvenc: 100, qsv: 90, amf: 80, vaapi: 70, videotoolbox: 65, v4l2: 60, cpu: 1 }; const encoderMap: Record = { 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 = {}; 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); }