sync
This commit is contained in:
76
README.md
76
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 <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 для ускорения
|
||||
|
||||
59
src/cli.ts
59
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 <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}`);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user