This commit is contained in:
2025-11-09 10:40:35 +03:00
parent 3086d6907c
commit 8c61e0e9db
6 changed files with 352 additions and 27 deletions

View File

@@ -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 <input-video> [output-dir]
# или после установки:
dvc <input-video> [output-dir]
dvc <input-video> [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 для ускорения

View File

@@ -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 <input-video> [output-dir]');
console.error('❌ Usage: dvc <input-video> [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}`);

View File

@@ -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 };
}

View File

@@ -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<DashConvertResult> {
@@ -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

View File

@@ -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<string> {
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
*/

View File

@@ -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;