diff --git a/README.md b/README.md index 63177da..aae1509 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью. -**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты (1080p/720p/480p/360p) • 🖼️ Thumbnail спрайты • 📊 Прогресс в реальном времени +**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты • 🖼️ Thumbnail спрайты • 📸 Генерация постера • 📊 Прогресс в реальном времени ## Быстрый старт ```bash # Использование через npx (без установки) -npx @grom13/dvc-cli video.mp4 ./output +npx @grom13/dvc-cli video.mp4 # Или глобальная установка npm install -g @grom13/dvc-cli -dvc video.mp4 ./output +dvc video.mp4 ``` **Системные требования:** @@ -27,26 +27,74 @@ sudo apt install ffmpeg gpac brew install ffmpeg gpac ``` -**Результат:** В папке `./output/video/` будет создан `manifest.mpd` и видео сегменты для разных качеств. +**Результат:** В текущей директории будет создана папка `video/` с файлами `manifest.mpd`, видео сегментами, постером и превью спрайтами. ## Параметры CLI ```bash -npx @grom13/dvc-cli [output-dir] -# или после установки: -dvc [output-dir] +dvc [output-dir] [-r resolutions] [-p poster-timecode] ``` +### Основные параметры + | Параметр | Описание | По умолчанию | Обязательный | |----------|----------|--------------|--------------| | `input-video` | Путь к входному видео файлу | - | ✅ | -| `output-dir` | Директория для выходных файлов | `./output` | ❌ | +| `output-dir` | Директория для выходных файлов | `.` (текущая папка) | ❌ | -**Автоматические настройки:** -- Длительность сегментов: 2 секунды -- NVENC: автоопределение (GPU если доступен, иначе CPU) -- Профили качества: автоматический выбор на основе разрешения исходного видео -- Превью спрайты: генерируются автоматически (160x90px, каждые 10 сек) -- Параллельное кодирование: включено +### Опциональные ключи + +| Ключ | Описание | Формат | Пример | +|------|----------|--------|--------| +| `-r, --resolutions` | Выбор профилей качества | `360`, `720@60`, `1080-60` | `-r 720,1080,1440@60` | +| `-p, --poster` | Таймкод для постера | `HH:MM:SS` или секунды | `-p 00:00:05` или `-p 10` | + +### Примеры использования + +```bash +# Базовая конвертация (результат в текущей папке) +dvc video.mp4 + +# Указать выходную директорию +dvc video.mp4 ./output + +# Только выбранные разрешения +dvc video.mp4 -r 720,1080,1440 + +# Высокий FPS для игровых стримов +dvc video.mp4 -r 720@60,1080@60 + +# Постер с 5-й секунды +dvc video.mp4 -p 5 + +# Постер в формате времени +dvc video.mp4 -p 00:01:30 + +# Комбинация параметров +dvc video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10 +``` + +### Поддерживаемые разрешения + +| Разрешение | Стандартное название | FPS варианты | +|------------|---------------------|--------------| +| `360` | 360p (640×360) | 30, 60, 90, 120 | +| `480` | 480p (854×480) | 30, 60, 90, 120 | +| `720` | 720p HD (1280×720) | 30, 60, 90, 120 | +| `1080` | 1080p Full HD (1920×1080) | 30, 60, 90, 120 | +| `1440` | 1440p 2K (2560×1440) | 30, 60, 90, 120 | +| `2160` | 2160p 4K (3840×2160) | 30, 60, 90, 120 | + +**Примечание:** Высокие FPS (60/90/120) создаются автоматически только если исходное видео поддерживает соответствующий FPS. + +## Автоматические настройки + +- **Длительность сегментов:** 2 секунды +- **NVENC:** автоопределение (GPU если доступен, иначе CPU) +- **Профили качества:** автоматический выбор на основе разрешения исходного видео +- **Битрейт:** динамический расчет по формуле BPP (Bits Per Pixel) +- **Превью спрайты:** генерируются автоматически (160×90px, интервал 1 сек) +- **Постер:** извлекается с 1-й секунды видео (можно изменить через `-p`) +- **Параллельное кодирование:** включено **Требования:** Node.js ≥18.0.0, FFmpeg, MP4Box (gpac), опционально NVIDIA GPU для ускорения diff --git a/src/cli.ts b/src/cli.ts index 7923453..68e4c59 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -15,10 +15,48 @@ import cliProgress from 'cli-progress'; import { statSync } from 'node:fs'; const input = process.argv[2]; -const outputDir = process.argv[3] || './output'; +const outputDir = process.argv[3] || '.'; // Текущая директория по умолчанию + +// Parse optional -r or --resolutions argument +let customProfiles: string[] | undefined; +let posterTimecode: string | undefined; + +for (let i = 4; i < process.argv.length; i++) { + if (process.argv[i] === '-r' || process.argv[i] === '--resolutions') { + // Collect all arguments after -r until next flag or end + const profilesArgs: string[] = []; + for (let j = i + 1; j < process.argv.length; j++) { + // Stop if we hit another flag (starts with -) + if (process.argv[j].startsWith('-')) { + break; + } + profilesArgs.push(process.argv[j]); + } + + // If there's only one arg, it might contain commas: "720,1080" + // If there are multiple args, they might be: "720" "1080" or "720," "1080" + // Solution: join with comma, then split by comma/space + const joinedArgs = profilesArgs.join(','); + customProfiles = joinedArgs + .split(/[,\s]+/) // Split by comma or whitespace + .map(s => s.trim()) + .filter(s => s.length > 0); + } + + if (process.argv[i] === '-p' || process.argv[i] === '--poster') { + posterTimecode = process.argv[i + 1]; + } +} if (!input) { - console.error('❌ Usage: bun run test.ts [output-dir]'); + console.error('❌ Usage: dvc [output-dir] [-r resolutions] [-p poster-timecode]'); + console.error('\nExamples:'); + console.error(' dvc video.mp4'); + console.error(' dvc video.mp4 ./output'); + console.error(' dvc video.mp4 -r 360,480,720'); + console.error(' dvc video.mp4 -r 720@60,1080@60,2160@60'); + console.error(' dvc video.mp4 -p 00:00:05'); + console.error(' dvc video.mp4 ./output -r 720,1080 -p 10'); process.exit(1); } @@ -61,8 +99,14 @@ if (metadata.videoBitrate) { if (metadata.audioBitrate) { console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`); } -console.log(`\n📁 Output: ${outputDir}\n`); -console.log('🚀 Starting conversion...\n'); +console.log(`\n📁 Output: ${outputDir}`); +if (customProfiles) { + console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`); +} +if (posterTimecode) { + console.log(`🖼️ Poster timecode: ${posterTimecode}`); +} +console.log('\n🚀 Starting conversion...\n'); // Create multibar container const multibar = new cliProgress.MultiBar({ @@ -82,9 +126,12 @@ try { const result = await convertToDash({ input, outputDir, + customProfiles, + posterTimecode, segmentDuration: 2, useNvenc: hasNvenc, generateThumbnails: true, + generatePoster: true, parallel: true, onProgress: (progress) => { const stageName = progress.stage === 'encoding' ? 'Encoding' : @@ -132,6 +179,10 @@ try { console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`); console.log(` Encoder: ${result.usedNvenc ? '⚡ NVENC (GPU)' : '🔧 libx264 (CPU)'}`); + if (result.posterPath) { + console.log(` Poster: ${result.posterPath}`); + } + if (result.thumbnailSpritePath) { console.log(` Thumbnails: ${result.thumbnailSpritePath}`); console.log(` VTT file: ${result.thumbnailVttPath}`); diff --git a/src/config/profiles.ts b/src/config/profiles.ts index 229289c..e08df6d 100644 --- a/src/config/profiles.ts +++ b/src/config/profiles.ts @@ -77,7 +77,7 @@ export const DEFAULT_PROFILES: VideoProfile[] = [ audioBitrate: '256k' }, { - name: '4K', + name: '2160p', width: 3840, height: 2160, videoBitrate: calculateBitrate(3840, 2160, 30), @@ -154,3 +154,123 @@ export function createHighFPSProfile( }; } +/** + * Parse profile string into resolution and FPS + * Examples: + * '360' => { resolution: '360p', fps: 30 } + * '720@60' => { resolution: '720p', fps: 60 } + * '1080-60' => { resolution: '1080p', fps: 60 } + * '360p', '720p@60' also supported (with 'p') + */ +function parseProfileString(profileStr: string): { resolution: string; fps: number } | null { + const trimmed = profileStr.trim(); + + // Match patterns: 360, 720@60, 1080-60, 360p, 720p@60, 1080p-60 + const match = trimmed.match(/^(\d+)p?(?:[@-](\d+))?$/i); + + if (!match) { + return null; + } + + const resolution = match[1] + 'p'; // Always add 'p' + const fps = match[2] ? parseInt(match[2]) : 30; + + return { resolution, fps }; +} + +/** + * Get profile by resolution name and FPS + * Returns VideoProfile or null if not found + */ +export function getProfileByName( + resolution: string, + fps: number = 30, + maxBitrate?: number +): VideoProfile | null { + const baseProfile = DEFAULT_PROFILES.find(p => p.name === resolution); + + if (!baseProfile) { + return null; + } + + if (fps === 30) { + return { + ...baseProfile, + videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate) + }; + } + + return createHighFPSProfile(baseProfile, fps, maxBitrate); +} + +/** + * Validate if profile can be created from source + * Returns error message or null if valid + */ +export function validateProfile( + profileStr: string, + sourceWidth: number, + sourceHeight: number, + sourceFPS: number +): string | null { + const parsed = parseProfileString(profileStr); + + if (!parsed) { + return `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60`; + } + + const profile = getProfileByName(parsed.resolution, parsed.fps); + + if (!profile) { + return `Unknown resolution: ${parsed.resolution}. Available: 360, 480, 720, 1080, 1440, 2160`; + } + + // Check if source supports this resolution + if (profile.width > sourceWidth || profile.height > sourceHeight) { + return `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})`; + } + + // Check if source supports this FPS + if (parsed.fps > sourceFPS) { + return `Source FPS (${sourceFPS}) is lower than requested ${parsed.fps} FPS in ${profileStr}`; + } + + return null; // Valid +} + +/** + * Create profiles from custom string list + * Example: ['360p', '720p@60', '1080p'] => VideoProfile[] + */ +export function createProfilesFromStrings( + profileStrings: string[], + sourceWidth: number, + sourceHeight: number, + sourceFPS: number, + sourceBitrate?: number +): { profiles: VideoProfile[]; errors: string[] } { + const profiles: VideoProfile[] = []; + const errors: string[] = []; + + for (const profileStr of profileStrings) { + // Validate + const error = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS); + + if (error) { + errors.push(error); + continue; + } + + // Parse and create + const parsed = parseProfileString(profileStr); + if (!parsed) continue; // Already validated, shouldn't happen + + const profile = getProfileByName(parsed.resolution, parsed.fps, sourceBitrate); + if (profile) { + profiles.push(profile); + } + } + + return { profiles, errors }; +} + diff --git a/src/core/converter.ts b/src/core/converter.ts index ee8580f..e77e582 100644 --- a/src/core/converter.ts +++ b/src/core/converter.ts @@ -15,8 +15,8 @@ import { getVideoMetadata, ensureDir } from '../utils'; -import { selectProfiles } from '../config/profiles'; -import { generateThumbnailSprite } from './thumbnails'; +import { selectProfiles, createProfilesFromStrings } from '../config/profiles'; +import { generateThumbnailSprite, generatePoster } from './thumbnails'; import { encodeProfilesToMP4 } from './encoding'; import { packageToDash } from './packaging'; @@ -32,9 +32,12 @@ export async function convertToDash( outputDir, segmentDuration = 2, profiles: userProfiles, + customProfiles, useNvenc, generateThumbnails = true, thumbnailConfig = {}, + generatePoster: shouldGeneratePoster = true, + posterTimecode = '00:00:01', parallel = true, onProgress } = options; @@ -50,9 +53,12 @@ export async function convertToDash( tempDir, segmentDuration, userProfiles, + customProfiles, useNvenc, generateThumbnails, thumbnailConfig, + shouldGeneratePoster, + posterTimecode, parallel, onProgress ); @@ -75,9 +81,12 @@ async function convertToDashInternal( tempDir: string, segmentDuration: number, userProfiles: VideoProfile[] | undefined, + customProfiles: string[] | undefined, useNvenc: boolean | undefined, generateThumbnails: boolean, thumbnailConfig: ThumbnailConfig, + generatePosterFlag: boolean, + posterTimecode: string, parallel: boolean, onProgress?: (progress: ConversionProgress) => void ): Promise { @@ -112,12 +121,44 @@ async function convertToDashInternal( } // Select profiles - const profiles = userProfiles || selectProfiles( - metadata.width, - metadata.height, - metadata.fps, - metadata.videoBitrate - ); + let profiles: VideoProfile[]; + + if (customProfiles && customProfiles.length > 0) { + // User specified custom profiles via CLI + const result = createProfilesFromStrings( + customProfiles, + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); + + // Show errors if any + if (result.errors.length > 0) { + console.warn('\n⚠️ Profile warnings:'); + for (const error of result.errors) { + console.warn(` - ${error}`); + } + console.warn(''); + } + + profiles = result.profiles; + + if (profiles.length === 0) { + throw new Error('No valid profiles found in custom list. Check warnings above.'); + } + } else if (userProfiles) { + // Programmatic API usage + profiles = userProfiles; + } else { + // Default: auto-select based on source + profiles = selectProfiles( + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); + } if (profiles.length === 0) { throw new Error('No suitable profiles found for input video resolution'); @@ -126,6 +167,14 @@ async function convertToDashInternal( // Create video name directory const inputBasename = basename(input, extname(input)); const videoOutputDir = join(outputDir, inputBasename); + + // Clean up previous conversion if exists + try { + await rm(videoOutputDir, { recursive: true, force: true }); + } catch (err) { + // Directory might not exist, that's fine + } + await ensureDir(videoOutputDir); reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined); @@ -212,6 +261,21 @@ async function convertToDashInternal( reportProgress('thumbnails', 90, 'Thumbnails generated'); } + + // Generate poster + let posterPath: string | undefined; + + if (generatePosterFlag) { + reportProgress('thumbnails', 92, 'Generating poster image...'); + + posterPath = await generatePoster( + input, + videoOutputDir, + posterTimecode + ); + + reportProgress('thumbnails', 95, 'Poster generated'); + } // Generate MPD manifest reportProgress('manifest', 95, 'Finalizing manifest...'); @@ -226,6 +290,7 @@ async function convertToDashInternal( videoPaths, thumbnailSpritePath, thumbnailVttPath, + posterPath, duration: metadata.duration, profiles, usedNvenc: willUseNvenc diff --git a/src/core/thumbnails.ts b/src/core/thumbnails.ts index fb1e304..2a8c6a9 100644 --- a/src/core/thumbnails.ts +++ b/src/core/thumbnails.ts @@ -3,6 +3,35 @@ import type { ThumbnailConfig } from '../types'; import { execFFmpeg, formatVttTime } from '../utils'; import { exists, readdir, unlink, rmdir } from 'node:fs/promises'; +/** + * Generate poster image from video at specific timecode + * @param inputPath - Path to input video + * @param outputDir - Directory to save poster + * @param timecode - Timecode in format "HH:MM:SS" or seconds (default: "00:00:01") + * @returns Path to generated poster + */ +export async function generatePoster( + inputPath: string, + outputDir: string, + timecode: string = '00:00:01' +): Promise { + const posterPath = join(outputDir, 'poster.jpg'); + + // Parse timecode: if it's a number, treat as seconds, otherwise use as-is + const timeArg = /^\d+(\.\d+)?$/.test(timecode) ? timecode : timecode; + + await execFFmpeg([ + '-ss', timeArg, // Seek to timecode + '-i', inputPath, // Input file + '-vframes', '1', // Extract 1 frame + '-q:v', '2', // High quality (2-5 range, 2 is best) + '-y', // Overwrite output + posterPath + ]); + + return posterPath; +} + /** * Generate thumbnail sprite and VTT file */ diff --git a/src/types/index.ts b/src/types/index.ts index 73fd54c..7d81a96 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -14,6 +14,9 @@ export interface DashConvertOptions { /** Video quality profiles to generate */ profiles?: VideoProfile[]; + /** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */ + customProfiles?: string[]; + /** Enable NVENC hardware acceleration (auto-detect if undefined) */ useNvenc?: boolean; @@ -23,6 +26,12 @@ export interface DashConvertOptions { /** Thumbnail sprite configuration */ thumbnailConfig?: ThumbnailConfig; + /** Generate poster image (default: true) */ + generatePoster?: boolean; + + /** Poster timecode in format HH:MM:SS or seconds (default: 00:00:01) */ + posterTimecode?: string; + /** Parallel encoding (default: true) */ parallel?: boolean; @@ -103,6 +112,9 @@ export interface DashConvertResult { /** Path to thumbnail VTT file (if generated) */ thumbnailVttPath?: string; + /** Path to poster image (if generated) */ + posterPath?: string; + /** Video duration in seconds */ duration: number;