sync
This commit is contained in:
76
README.md
76
README.md
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
||||||
|
|
||||||
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты (1080p/720p/480p/360p) • 🖼️ Thumbnail спрайты • 📊 Прогресс в реальном времени
|
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты • 🖼️ Thumbnail спрайты • 📸 Генерация постера • 📊 Прогресс в реальном времени
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Использование через npx (без установки)
|
# Использование через npx (без установки)
|
||||||
npx @grom13/dvc-cli video.mp4 ./output
|
npx @grom13/dvc-cli video.mp4
|
||||||
|
|
||||||
# Или глобальная установка
|
# Или глобальная установка
|
||||||
npm install -g @grom13/dvc-cli
|
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
|
brew install ffmpeg gpac
|
||||||
```
|
```
|
||||||
|
|
||||||
**Результат:** В папке `./output/video/` будет создан `manifest.mpd` и видео сегменты для разных качеств.
|
**Результат:** В текущей директории будет создана папка `video/` с файлами `manifest.mpd`, видео сегментами, постером и превью спрайтами.
|
||||||
|
|
||||||
## Параметры CLI
|
## Параметры CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npx @grom13/dvc-cli <input-video> [output-dir]
|
dvc <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
|
||||||
# или после установки:
|
|
||||||
dvc <input-video> [output-dir]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Основные параметры
|
||||||
|
|
||||||
| Параметр | Описание | По умолчанию | Обязательный |
|
| Параметр | Описание | По умолчанию | Обязательный |
|
||||||
|----------|----------|--------------|--------------|
|
|----------|----------|--------------|--------------|
|
||||||
| `input-video` | Путь к входному видео файлу | - | ✅ |
|
| `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 для ускорения
|
**Требования:** 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';
|
import { statSync } from 'node:fs';
|
||||||
|
|
||||||
const input = process.argv[2];
|
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) {
|
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);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,8 +99,14 @@ if (metadata.videoBitrate) {
|
|||||||
if (metadata.audioBitrate) {
|
if (metadata.audioBitrate) {
|
||||||
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
|
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
|
||||||
}
|
}
|
||||||
console.log(`\n📁 Output: ${outputDir}\n`);
|
console.log(`\n📁 Output: ${outputDir}`);
|
||||||
console.log('🚀 Starting conversion...\n');
|
if (customProfiles) {
|
||||||
|
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
|
||||||
|
}
|
||||||
|
if (posterTimecode) {
|
||||||
|
console.log(`🖼️ Poster timecode: ${posterTimecode}`);
|
||||||
|
}
|
||||||
|
console.log('\n🚀 Starting conversion...\n');
|
||||||
|
|
||||||
// Create multibar container
|
// Create multibar container
|
||||||
const multibar = new cliProgress.MultiBar({
|
const multibar = new cliProgress.MultiBar({
|
||||||
@@ -82,9 +126,12 @@ try {
|
|||||||
const result = await convertToDash({
|
const result = await convertToDash({
|
||||||
input,
|
input,
|
||||||
outputDir,
|
outputDir,
|
||||||
|
customProfiles,
|
||||||
|
posterTimecode,
|
||||||
segmentDuration: 2,
|
segmentDuration: 2,
|
||||||
useNvenc: hasNvenc,
|
useNvenc: hasNvenc,
|
||||||
generateThumbnails: true,
|
generateThumbnails: true,
|
||||||
|
generatePoster: true,
|
||||||
parallel: true,
|
parallel: true,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
const stageName = progress.stage === 'encoding' ? 'Encoding' :
|
const stageName = progress.stage === 'encoding' ? 'Encoding' :
|
||||||
@@ -132,6 +179,10 @@ try {
|
|||||||
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
|
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
|
||||||
console.log(` Encoder: ${result.usedNvenc ? '⚡ NVENC (GPU)' : '🔧 libx264 (CPU)'}`);
|
console.log(` Encoder: ${result.usedNvenc ? '⚡ NVENC (GPU)' : '🔧 libx264 (CPU)'}`);
|
||||||
|
|
||||||
|
if (result.posterPath) {
|
||||||
|
console.log(` Poster: ${result.posterPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
if (result.thumbnailSpritePath) {
|
if (result.thumbnailSpritePath) {
|
||||||
console.log(` Thumbnails: ${result.thumbnailSpritePath}`);
|
console.log(` Thumbnails: ${result.thumbnailSpritePath}`);
|
||||||
console.log(` VTT file: ${result.thumbnailVttPath}`);
|
console.log(` VTT file: ${result.thumbnailVttPath}`);
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export const DEFAULT_PROFILES: VideoProfile[] = [
|
|||||||
audioBitrate: '256k'
|
audioBitrate: '256k'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '4K',
|
name: '2160p',
|
||||||
width: 3840,
|
width: 3840,
|
||||||
height: 2160,
|
height: 2160,
|
||||||
videoBitrate: calculateBitrate(3840, 2160, 30),
|
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,
|
getVideoMetadata,
|
||||||
ensureDir
|
ensureDir
|
||||||
} from '../utils';
|
} from '../utils';
|
||||||
import { selectProfiles } from '../config/profiles';
|
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
|
||||||
import { generateThumbnailSprite } from './thumbnails';
|
import { generateThumbnailSprite, generatePoster } from './thumbnails';
|
||||||
import { encodeProfilesToMP4 } from './encoding';
|
import { encodeProfilesToMP4 } from './encoding';
|
||||||
import { packageToDash } from './packaging';
|
import { packageToDash } from './packaging';
|
||||||
|
|
||||||
@@ -32,9 +32,12 @@ export async function convertToDash(
|
|||||||
outputDir,
|
outputDir,
|
||||||
segmentDuration = 2,
|
segmentDuration = 2,
|
||||||
profiles: userProfiles,
|
profiles: userProfiles,
|
||||||
|
customProfiles,
|
||||||
useNvenc,
|
useNvenc,
|
||||||
generateThumbnails = true,
|
generateThumbnails = true,
|
||||||
thumbnailConfig = {},
|
thumbnailConfig = {},
|
||||||
|
generatePoster: shouldGeneratePoster = true,
|
||||||
|
posterTimecode = '00:00:01',
|
||||||
parallel = true,
|
parallel = true,
|
||||||
onProgress
|
onProgress
|
||||||
} = options;
|
} = options;
|
||||||
@@ -50,9 +53,12 @@ export async function convertToDash(
|
|||||||
tempDir,
|
tempDir,
|
||||||
segmentDuration,
|
segmentDuration,
|
||||||
userProfiles,
|
userProfiles,
|
||||||
|
customProfiles,
|
||||||
useNvenc,
|
useNvenc,
|
||||||
generateThumbnails,
|
generateThumbnails,
|
||||||
thumbnailConfig,
|
thumbnailConfig,
|
||||||
|
shouldGeneratePoster,
|
||||||
|
posterTimecode,
|
||||||
parallel,
|
parallel,
|
||||||
onProgress
|
onProgress
|
||||||
);
|
);
|
||||||
@@ -75,9 +81,12 @@ async function convertToDashInternal(
|
|||||||
tempDir: string,
|
tempDir: string,
|
||||||
segmentDuration: number,
|
segmentDuration: number,
|
||||||
userProfiles: VideoProfile[] | undefined,
|
userProfiles: VideoProfile[] | undefined,
|
||||||
|
customProfiles: string[] | undefined,
|
||||||
useNvenc: boolean | undefined,
|
useNvenc: boolean | undefined,
|
||||||
generateThumbnails: boolean,
|
generateThumbnails: boolean,
|
||||||
thumbnailConfig: ThumbnailConfig,
|
thumbnailConfig: ThumbnailConfig,
|
||||||
|
generatePosterFlag: boolean,
|
||||||
|
posterTimecode: string,
|
||||||
parallel: boolean,
|
parallel: boolean,
|
||||||
onProgress?: (progress: ConversionProgress) => void
|
onProgress?: (progress: ConversionProgress) => void
|
||||||
): Promise<DashConvertResult> {
|
): Promise<DashConvertResult> {
|
||||||
@@ -112,12 +121,44 @@ async function convertToDashInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select profiles
|
// Select profiles
|
||||||
const profiles = userProfiles || selectProfiles(
|
let profiles: VideoProfile[];
|
||||||
metadata.width,
|
|
||||||
metadata.height,
|
if (customProfiles && customProfiles.length > 0) {
|
||||||
metadata.fps,
|
// User specified custom profiles via CLI
|
||||||
metadata.videoBitrate
|
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) {
|
if (profiles.length === 0) {
|
||||||
throw new Error('No suitable profiles found for input video resolution');
|
throw new Error('No suitable profiles found for input video resolution');
|
||||||
@@ -126,6 +167,14 @@ async function convertToDashInternal(
|
|||||||
// Create video name directory
|
// Create video name directory
|
||||||
const inputBasename = basename(input, extname(input));
|
const inputBasename = basename(input, extname(input));
|
||||||
const videoOutputDir = join(outputDir, inputBasename);
|
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);
|
await ensureDir(videoOutputDir);
|
||||||
|
|
||||||
reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined);
|
reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined);
|
||||||
@@ -212,6 +261,21 @@ async function convertToDashInternal(
|
|||||||
|
|
||||||
reportProgress('thumbnails', 90, 'Thumbnails generated');
|
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
|
// Generate MPD manifest
|
||||||
reportProgress('manifest', 95, 'Finalizing manifest...');
|
reportProgress('manifest', 95, 'Finalizing manifest...');
|
||||||
@@ -226,6 +290,7 @@ async function convertToDashInternal(
|
|||||||
videoPaths,
|
videoPaths,
|
||||||
thumbnailSpritePath,
|
thumbnailSpritePath,
|
||||||
thumbnailVttPath,
|
thumbnailVttPath,
|
||||||
|
posterPath,
|
||||||
duration: metadata.duration,
|
duration: metadata.duration,
|
||||||
profiles,
|
profiles,
|
||||||
usedNvenc: willUseNvenc
|
usedNvenc: willUseNvenc
|
||||||
|
|||||||
@@ -3,6 +3,35 @@ import type { ThumbnailConfig } from '../types';
|
|||||||
import { execFFmpeg, formatVttTime } from '../utils';
|
import { execFFmpeg, formatVttTime } from '../utils';
|
||||||
import { exists, readdir, unlink, rmdir } from 'node:fs/promises';
|
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
|
* Generate thumbnail sprite and VTT file
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -14,6 +14,9 @@ export interface DashConvertOptions {
|
|||||||
/** Video quality profiles to generate */
|
/** Video quality profiles to generate */
|
||||||
profiles?: VideoProfile[];
|
profiles?: VideoProfile[];
|
||||||
|
|
||||||
|
/** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */
|
||||||
|
customProfiles?: string[];
|
||||||
|
|
||||||
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
||||||
useNvenc?: boolean;
|
useNvenc?: boolean;
|
||||||
|
|
||||||
@@ -23,6 +26,12 @@ export interface DashConvertOptions {
|
|||||||
/** Thumbnail sprite configuration */
|
/** Thumbnail sprite configuration */
|
||||||
thumbnailConfig?: ThumbnailConfig;
|
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 encoding (default: true) */
|
||||||
parallel?: boolean;
|
parallel?: boolean;
|
||||||
|
|
||||||
@@ -103,6 +112,9 @@ export interface DashConvertResult {
|
|||||||
/** Path to thumbnail VTT file (if generated) */
|
/** Path to thumbnail VTT file (if generated) */
|
||||||
thumbnailVttPath?: string;
|
thumbnailVttPath?: string;
|
||||||
|
|
||||||
|
/** Path to poster image (if generated) */
|
||||||
|
posterPath?: string;
|
||||||
|
|
||||||
/** Video duration in seconds */
|
/** Video duration in seconds */
|
||||||
duration: number;
|
duration: number;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user