Compare commits

...

3 Commits

12 changed files with 1219 additions and 168 deletions

View File

@@ -1,8 +1,8 @@
# DASH Video Converter 🎬
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
CLI инструмент для конвертации видео в форматы DASH и HLS с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты • 🖼️ Thumbnail спрайты • 📸 Генерация постера • 📊 Прогресс в реальном времени
**Возможности:** ⚡ NVENC ускорение • 🎯 DASH + HLS форматы • 📊 Множественные битрейты • 🖼️ Thumbnail спрайты • 📸 Генерация постера • ⏱️ Прогресс в реальном времени
## Быстрый старт
@@ -27,12 +27,12 @@ sudo apt install ffmpeg gpac
brew install ffmpeg gpac
```
**Результат:** В текущей директории будет создана папка `video/` с файлами `manifest.mpd`, видео сегментами, постером и превью спрайтами.
**Результат:** В текущей директории будет создана папка `video/` с сегментами в папках `{profile}-{codec}/`, манифестами DASH и HLS в корне, постером и превью спрайтами.
## Параметры CLI
```bash
dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]
```
### Основные параметры
@@ -47,12 +47,14 @@ dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
| Ключ | Описание | Формат | Пример |
|------|----------|--------|--------|
| `-r, --resolutions` | Выбор профилей качества | `360`, `720@60`, `1080-60` | `-r 720,1080,1440@60` |
| `-c, --codec` | Видео кодек | `h264`, `av1`, `dual` | `-c dual` (по умолчанию) |
| `-f, --format` | Формат стриминга | `dash`, `hls`, `both` | `-f both` (по умолчанию) |
| `-p, --poster` | Таймкод для постера | `HH:MM:SS` или секунды | `-p 00:00:05` или `-p 10` |
### Примеры использования
```bash
# Базовая конвертация (результат в текущей папке)
# Базовая конвертация (DASH + HLS, dual codec, автопрофили)
dvc-cli video.mp4
# Указать выходную директорию
@@ -64,14 +66,17 @@ dvc-cli video.mp4 -r 720,1080,1440
# Высокий FPS для игровых стримов
dvc-cli video.mp4 -r 720@60,1080@60
# Только DASH формат
dvc-cli video.mp4 -f dash
# Только HLS для Safari/iOS
dvc-cli video.mp4 -f hls -c h264
# Постер с 5-й секунды
dvc-cli video.mp4 -p 5
# Постер в формате времени
dvc-cli video.mp4 -p 00:01:30
# Комбинация параметров
dvc-cli video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10
dvc-cli video.mp4 ./output -r 720,1080@60,1440@60 -c dual -f both -p 00:00:10
```
### Поддерживаемые разрешения

File diff suppressed because one or more lines are too long

385
docs/CLI_REFERENCE.md Normal file
View File

@@ -0,0 +1,385 @@
# CLI Reference — Справочник команд
Полное руководство по использованию DASH Video Converter CLI.
---
## Синтаксис
```bash
dvc-cli <input-video> [output-dir] [options]
```
## Позиционные аргументы
| Аргумент | Описание | По умолчанию |
|----------|----------|--------------|
| `input-video` | Путь к входному видео файлу | **Обязательный** |
| `output-dir` | Директория для сохранения результата | `.` (текущая папка) |
---
## Опции
### `-r, --resolutions` — Профили разрешений
Задает список профилей для генерации. Можно указывать разрешение и FPS.
**Формат:**
- `<resolution>` — только разрешение (FPS = 30)
- `<resolution>@<fps>` — разрешение с FPS (разделитель `@`)
- `<resolution>-<fps>` — разрешение с FPS (разделитель `-`)
**Поддерживаемые разрешения:**
- `360p` (640×360)
- `480p` (854×480)
- `720p` (1280×720)
- `1080p` (1920×1080)
- `1440p` (2560×1440)
- `2160p` (3840×2160)
**Примеры:**
```bash
# Базовые разрешения (30 FPS)
dvc-cli video.mp4 -r 360,720,1080
# С указанием FPS
dvc-cli video.mp4 -r 720@60,1080@60
# Смешанный формат
dvc-cli video.mp4 -r 360 720@60 1080 1440@120
```
**Автоматическая коррекция FPS:**
- Если запрошенный FPS > FPS источника → используется FPS источника
- Максимальный FPS: **120** (ограничение системы)
- Система выведет предупреждение при коррекции
**По умолчанию:** Автоматический выбор всех подходящих разрешений (≤ разрешения источника) с 30 FPS.
---
### `-c, --codec` — Видео кодек
Выбор видео кодека для кодирования.
**Значения:**
- `h264` — только H.264 (максимальная совместимость)
- `av1` — только AV1 (лучшее сжатие, новые браузеры)
- `dual`оба кодека (рекомендуется)
**Примеры:**
```bash
# Только H.264 (быстрее, больше места)
dvc-cli video.mp4 -c h264
# Только AV1 (медленнее, меньше места)
dvc-cli video.mp4 -c av1
# Оба кодека (максимальная совместимость)
dvc-cli video.mp4 -c dual
```
**GPU ускорение:**
- H.264: `h264_nvenc` (NVIDIA), fallback → `libx264` (CPU)
- AV1: `av1_nvenc` (NVIDIA), `av1_qsv` (Intel), `av1_amf` (AMD), fallback → `libsvtav1` (CPU)
**По умолчанию:** `dual`
---
### `-f, --format` — Формат стриминга
Выбор формата адаптивного стриминга.
**Значения:**
- `dash` — только DASH (MPEG-DASH)
- `hls` — только HLS (HTTP Live Streaming)
- `both`оба формата
**Примеры:**
```bash
# Только DASH
dvc-cli video.mp4 -f dash
# Только HLS (для Safari/iOS)
dvc-cli video.mp4 -f hls
# Оба формата (максимальная совместимость)
dvc-cli video.mp4 -f both
```
**Особенности:**
| Формат | Кодеки | Совместимость | Примечание |
|--------|--------|---------------|------------|
| DASH | H.264 + AV1 | Chrome, Firefox, Edge, Safari (с dash.js) | Стандарт индустрии |
| HLS | H.264 только | Safari, iOS, все браузеры | Требует H.264 |
| both | H.264 + AV1 (DASH), H.264 (HLS) | Максимальная | Рекомендуется |
**Ограничения:**
- HLS требует `--codec h264` или `--codec dual`
- AV1 не поддерживается в HLS (Safari не поддерживает AV1)
**Файловая структура:**
```
output/
└── video_name/
├── 720p-h264/ ← Сегменты H.264 720p
│ ├── 720p-h264_.mp4
│ ├── 720p-h264_1.m4s
│ └── playlist.m3u8 ← HLS медиа плейлист
├── 720p-av1/ ← Сегменты AV1 720p (только для DASH)
│ ├── 720p-av1_.mp4
│ └── 720p-av1_1.m4s
├── audio/ ← Аудио сегменты
│ ├── audio_.mp4
│ ├── audio_1.m4s
│ └── playlist.m3u8
├── manifest.mpd ← DASH манифест (корень)
├── master.m3u8 ← HLS мастер плейлист (корень)
├── poster.jpg ← Общие файлы
├── thumbnails.jpg
└── thumbnails.vtt
```
**Преимущества структуры:**
- Сегменты хранятся один раз (нет дублирования)
- DASH и HLS используют одни и те же .m4s файлы
- Экономия 50% места при `format=both`
**По умолчанию:** `both` (максимальная совместимость)
---
### `-p, --poster` — Тайм-код постера
Время, с которого извлечь кадр для постера.
**Формат:**
- Секунды: `5`, `10.5`
- Тайм-код: `00:00:05`, `00:01:30`
**Примеры:**
```bash
# 5 секунд от начала
dvc-cli video.mp4 -p 5
# 1 минута 30 секунд
dvc-cli video.mp4 -p 00:01:30
```
**По умолчанию:** `00:00:01` (1 секунда от начала)
---
## Примеры использования
### Базовое использование
```bash
# Простейший запуск (оба формата, dual codec, автопрофили)
dvc-cli video.mp4
# С указанием выходной директории
dvc-cli video.mp4 ./output
```
### Кастомные профили
```bash
# Только 720p и 1080p
dvc-cli video.mp4 -r 720,1080
# High FPS профили
dvc-cli video.mp4 -r 720@60,1080@60,1440@120
# Один профиль 4K
dvc-cli video.mp4 -r 2160
```
### Выбор кодека
```bash
# Быстрое кодирование (только H.264)
dvc-cli video.mp4 -c h264
# Лучшее сжатие (только AV1)
dvc-cli video.mp4 -c av1
# Максимальная совместимость
dvc-cli video.mp4 -c dual
```
### Выбор формата
```bash
# DASH для современных браузеров
dvc-cli video.mp4 -f dash
# HLS для Safari/iOS
dvc-cli video.mp4 -f hls -c h264
# Оба формата для всех устройств
dvc-cli video.mp4 -f both -c dual
```
### Комбинированные примеры
```bash
# Производственная конфигурация
dvc-cli video.mp4 ./cdn/videos -r 360,720,1080 -c dual -f both
# High-end конфигурация (4K, high FPS)
dvc-cli video.mp4 -r 720@60,1080@60,1440@120,2160@60 -c dual -f both
# Быстрая конвертация для тестов
dvc-cli video.mp4 -r 720 -c h264 -f dash
# Mobile-first (низкие разрешения, HLS)
dvc-cli video.mp4 -r 360,480,720 -c h264 -f hls
# Кастомный постер
dvc-cli video.mp4 -r 720,1080 -p 00:02:30
```
---
## Системные требования
### Обязательные зависимости
- **FFmpeg** — кодирование видео
- **MP4Box (GPAC)** — упаковка DASH/HLS
Установка:
```bash
# Arch Linux
sudo pacman -S ffmpeg gpac
# Ubuntu/Debian
sudo apt install ffmpeg gpac
# macOS
brew install ffmpeg gpac
```
### Опциональные (для GPU ускорения)
- **NVIDIA GPU** — для H.264/AV1 кодирования через NVENC
- **Intel GPU** — для AV1 через QSV
- **AMD GPU** — для AV1 через AMF
---
## Производительность
### CPU vs GPU
| Кодек | CPU | GPU (NVENC) | Ускорение |
|-------|-----|-------------|-----------|
| H.264 | libx264 | h264_nvenc | ~10-20x |
| AV1 | libsvtav1 | av1_nvenc | ~15-30x |
### Параллельное кодирование
- **GPU**: до 3 профилей одновременно
- **CPU**: до 2 профилей одновременно
### Время конвертации (примерные данные)
Видео 4K, 10 секунд, dual codec, 3 профиля:
| Конфигурация | Время |
|--------------|-------|
| CPU (libx264 + libsvtav1) | ~5-10 минут |
| GPU (NVENC) | ~30-60 секунд |
---
## Рекомендации
### Для максимальной совместимости
```bash
dvc-cli video.mp4 -c dual -f both
```
Генерирует:
- DASH с H.264 + AV1 (Chrome, Firefox, Edge)
- HLS с H.264 (Safari, iOS)
- Все современные устройства поддерживаются
### Для быстрой разработки
```bash
dvc-cli video.mp4 -r 720 -c h264 -f dash
```
Быстрое кодирование одного профиля.
### Для продакшена
```bash
dvc-cli video.mp4 -r 360,480,720,1080,1440 -c dual -f both
```
Широкий диапазон профилей для всех устройств.
### Для 4K контента
```bash
dvc-cli video.mp4 -r 720,1080,1440,2160 -c dual -f both
```
От HD до 4K для премиум контента.
---
## Устранение проблем
### HLS требует H.264
**Ошибка:**
```
❌ Error: HLS format requires H.264 codec
```
**Решение:**
```bash
# Используйте h264 или dual
dvc-cli video.mp4 -f hls -c h264
# или
dvc-cli video.mp4 -f hls -c dual
```
### FPS источника ниже запрошенного
**Предупреждение:**
```
⚠️ Requested 120 FPS, but source is 60 FPS. Using 60 FPS instead
```
Это нормально! Система автоматически ограничивает FPS до максимума источника.
### MP4Box не найден
**Ошибка:**
```
❌ MP4Box not found
```
**Решение:**
```bash
sudo pacman -S gpac # Arch
sudo apt install gpac # Ubuntu
```
---
## См. также
- [FEATURES.md](./FEATURES.md) — Возможности и технические детали
- [PUBLISHING.md](./PUBLISHING.md) — Публикация пакета в npm
- [README.md](../README.md) — Быстрый старт

View File

@@ -10,14 +10,17 @@
* dvc-cli ./video.mp4 ./output -r 720,1080
*/
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, getVideoMetadata } from './index';
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, checkAV1Support, getVideoMetadata } from './index';
import cliProgress from 'cli-progress';
import { statSync } from 'node:fs';
import type { CodecType, StreamingFormat } from './types';
// Parse arguments
const args = process.argv.slice(2);
let customProfiles: string[] | undefined;
let posterTimecode: string | undefined;
let codecType: CodecType = 'dual'; // Default to dual codec
let formatType: StreamingFormat = 'both'; // Default to both formats (DASH + HLS)
const positionalArgs: string[] = [];
// First pass: extract flags and their values
@@ -43,6 +46,24 @@ for (let i = 0; i < args.length; i++) {
} 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 codec = args[i + 1];
if (codec === 'av1' || codec === 'h264' || codec === 'dual') {
codecType = codec;
} else {
console.error(`❌ Invalid codec: ${codec}. Valid options: av1, h264, dual`);
process.exit(1);
}
i++; // Skip next arg
} else if (args[i] === '-f' || args[i] === '--format') {
const format = args[i + 1];
if (format === 'dash' || format === 'hls' || format === 'both') {
formatType = format;
} else {
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls, both`);
process.exit(1);
}
i++; // Skip next arg
} else if (!args[i].startsWith('-')) {
// Positional argument
positionalArgs.push(args[i]);
@@ -54,14 +75,23 @@ const input = positionalArgs[0];
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
if (!input) {
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]');
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]');
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, h264, or dual (default: dual)');
console.error(' -f, --format Streaming format: dash, hls, or both (default: dash)');
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
console.error('\nExamples:');
console.error(' dvc-cli video.mp4');
console.error(' dvc-cli video.mp4 ./output');
console.error(' dvc-cli video.mp4 -r 360,480,720');
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60');
console.error(' dvc-cli video.mp4 -c av1');
console.error(' dvc-cli video.mp4 -f hls');
console.error(' dvc-cli video.mp4 -f both');
console.error(' dvc-cli video.mp4 -c dual -f both');
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash');
console.error(' dvc-cli video.mp4 -p 00:00:05');
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -p 10');
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -f both -p 10');
process.exit(1);
}
@@ -69,10 +99,16 @@ console.log('🔍 Checking system...\n');
const hasFFmpeg = await checkFFmpeg();
const hasNvenc = await checkNvenc();
const av1Support = await checkAV1Support();
const hasMP4Box = await checkMP4Box();
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
console.log(`NVENC: ${hasNvenc ? '✅ (GPU acceleration)' : '⚠️ (CPU only)'}`);
console.log(`NVENC (H.264): ${hasNvenc ? '✅ (GPU acceleration)' : '⚠️ (CPU only)'}`);
if (av1Support.available) {
console.log(`AV1 Encoder: ✅ ${av1Support.encoder} (GPU acceleration)`);
} else {
console.log(`AV1 Encoder: ⚠️ (not available, will use CPU fallback)`);
}
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}\n`);
if (!hasFFmpeg) {
@@ -85,6 +121,20 @@ if (!hasMP4Box) {
process.exit(1);
}
// Validate codec selection
if ((codecType === 'av1' || codecType === 'dual') && !av1Support.available) {
console.error(`⚠️ Warning: AV1 encoding 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`);
}
// Validate HLS requires H.264
if ((formatType === 'hls' || formatType === 'both') && codecType === 'av1') {
console.error(`❌ Error: HLS format requires H.264 codec for Safari/iOS compatibility.`);
console.error(` Please use --codec h264 or --codec dual with --format hls\n`);
process.exit(1);
}
// Get video metadata and file size
console.log('📊 Analyzing video...\n');
const metadata = await getVideoMetadata(input);
@@ -105,6 +155,8 @@ if (metadata.audioBitrate) {
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
}
console.log(`\n📁 Output: ${outputDir}`);
console.log(`🎬 Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264 for maximum compatibility)' : ''}`);
console.log(`📺 Format: ${formatType}${formatType === 'both' ? ' (DASH + HLS for maximum compatibility)' : formatType === 'hls' ? ' (H.264 only for Safari/iOS)' : ''}`);
if (customProfiles) {
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
}
@@ -133,6 +185,8 @@ try {
outputDir,
customProfiles,
posterTimecode,
codec: codecType,
format: formatType,
segmentDuration: 2,
useNvenc: hasNvenc,
generateThumbnails: true,
@@ -179,10 +233,20 @@ try {
console.log('\n✅ Conversion completed successfully!\n');
console.log('📊 Results:');
console.log(` Manifest: ${result.manifestPath}`);
if (result.manifestPath) {
console.log(` DASH Manifest: ${result.manifestPath}`);
}
if (result.hlsManifestPath) {
console.log(` HLS Manifest: ${result.hlsManifestPath}`);
}
console.log(` Duration: ${result.duration.toFixed(2)}s`);
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
console.log(` Encoder: ${result.usedNvenc ? '⚡ NVENC (GPU)' : '🔧 libx264 (CPU)'}`);
console.log(` Format: ${result.format}`);
console.log(` Codec: ${result.codecType}${result.codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
console.log(` Encoder: ${result.usedNvenc ? '⚡ GPU accelerated' : '🔧 CPU'}`);
if (result.posterPath) {
console.log(` Poster: ${result.posterPath}`);
@@ -193,7 +257,7 @@ try {
console.log(` VTT file: ${result.thumbnailVttPath}`);
}
console.log('\n🎉 Done! You can now use the manifest file in your video player.');
console.log('\n🎉 Done! You can now use the manifest file(s) in your video player.');
} catch (error) {
multibar.stop();

View File

@@ -86,12 +86,10 @@ export const DEFAULT_PROFILES: VideoProfile[] = [
];
/**
* Select appropriate profiles based on input video resolution and FPS
* Select appropriate profiles based on input video resolution
* Only creates profiles that are equal to or smaller than input resolution
* Creates high FPS variants if source supports it (according to FEATURES.md):
* - 60 FPS versions if source >= 45 FPS
* - 90 FPS versions if source >= 75 FPS
* - 120 FPS versions if source >= 95 FPS
* Always generates 30 FPS profiles by default
* For high FPS (>30), user must explicitly specify in customProfiles
*/
export function selectProfiles(
inputWidth: number,
@@ -110,31 +108,11 @@ export function selectProfiles(
for (const profile of baseProfiles) {
profiles.push({
...profile,
videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate)
videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate),
fps: 30
});
}
// Add 60 FPS profiles if source >= 45 FPS
if (inputFPS >= 45) {
for (const profile of baseProfiles) {
profiles.push(createHighFPSProfile(profile, 60, sourceBitrate));
}
}
// Add 90 FPS profiles if source >= 75 FPS
if (inputFPS >= 75) {
for (const profile of baseProfiles) {
profiles.push(createHighFPSProfile(profile, 90, sourceBitrate));
}
}
// Add 120 FPS profiles if source >= 95 FPS
if (inputFPS >= 95) {
for (const profile of baseProfiles) {
profiles.push(createHighFPSProfile(profile, 120, sourceBitrate));
}
}
return profiles;
}
@@ -150,7 +128,8 @@ export function createHighFPSProfile(
return {
...baseProfile,
name: `${baseProfile.name}-${fps}`,
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, fps, maxBitrate)
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, fps, maxBitrate),
fps
};
}
@@ -196,7 +175,8 @@ export function getProfileByName(
if (fps === 30) {
return {
...baseProfile,
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate)
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate),
fps: 30
};
}
@@ -205,37 +185,47 @@ export function getProfileByName(
/**
* Validate if profile can be created from source
* Returns error message or null if valid
* Returns object with error, warning, and adjusted FPS
*/
export function validateProfile(
profileStr: string,
sourceWidth: number,
sourceHeight: number,
sourceFPS: number
): string | null {
): { error?: string; warning?: string; adjustedFps?: number } {
const parsed = parseProfileString(profileStr);
if (!parsed) {
return `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60`;
return { error: `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`;
return { error: `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})`;
return { error: `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})` };
}
// Check if source supports this FPS
// Check if requested FPS exceeds source FPS
const MAX_FPS = 120;
let adjustedFps = parsed.fps;
let warning: string | undefined;
if (parsed.fps > sourceFPS) {
return `Source FPS (${sourceFPS}) is lower than requested ${parsed.fps} FPS in ${profileStr}`;
// Cap to source FPS (but not more than MAX_FPS)
adjustedFps = Math.min(sourceFPS, MAX_FPS);
warning = `Requested ${parsed.fps} FPS in ${profileStr}, but source is ${sourceFPS} FPS. Using ${adjustedFps} FPS instead`;
} else if (parsed.fps > MAX_FPS) {
// Cap to MAX_FPS
adjustedFps = MAX_FPS;
warning = `Requested ${parsed.fps} FPS in ${profileStr} exceeds maximum ${MAX_FPS} FPS. Using ${adjustedFps} FPS instead`;
}
return null; // Valid
return warning ? { warning, adjustedFps } : {}; // Valid
}
/**
@@ -248,29 +238,37 @@ export function createProfilesFromStrings(
sourceHeight: number,
sourceFPS: number,
sourceBitrate?: number
): { profiles: VideoProfile[]; errors: string[] } {
): { profiles: VideoProfile[]; errors: string[]; warnings: string[] } {
const profiles: VideoProfile[] = [];
const errors: string[] = [];
const warnings: string[] = [];
for (const profileStr of profileStrings) {
// Validate
const error = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS);
const result = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS);
if (error) {
errors.push(error);
if (result.error) {
errors.push(result.error);
continue;
}
if (result.warning) {
warnings.push(result.warning);
}
// Parse and create
const parsed = parseProfileString(profileStr);
if (!parsed) continue; // Already validated, shouldn't happen
const profile = getProfileByName(parsed.resolution, parsed.fps, sourceBitrate);
// Use adjusted FPS if available (when requested FPS > source FPS)
const targetFps = result.adjustedFps !== undefined ? result.adjustedFps : parsed.fps;
const profile = getProfileByName(parsed.resolution, targetFps, sourceBitrate);
if (profile) {
profiles.push(profile);
}
}
return { profiles, errors };
return { profiles, errors, warnings };
}

View File

@@ -6,19 +6,22 @@ import type {
DashConvertResult,
VideoProfile,
ThumbnailConfig,
ConversionProgress
ConversionProgress,
CodecType,
StreamingFormat
} from '../types';
import {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
getVideoMetadata,
ensureDir
} from '../utils';
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
import { generateThumbnailSprite, generatePoster } from './thumbnails';
import { encodeProfilesToMP4 } from './encoding';
import { packageToDash } from './packaging';
import { packageToFormats } from './packaging';
/**
* Convert video to DASH format with NVENC acceleration
@@ -33,6 +36,8 @@ export async function convertToDash(
segmentDuration = 2,
profiles: userProfiles,
customProfiles,
codec = 'dual',
format = 'both',
useNvenc,
generateThumbnails = true,
thumbnailConfig = {},
@@ -54,6 +59,8 @@ export async function convertToDash(
segmentDuration,
userProfiles,
customProfiles,
codec,
format,
useNvenc,
generateThumbnails,
thumbnailConfig,
@@ -82,6 +89,8 @@ async function convertToDashInternal(
segmentDuration: number,
userProfiles: VideoProfile[] | undefined,
customProfiles: string[] | undefined,
codec: CodecType,
format: StreamingFormat,
useNvenc: boolean | undefined,
generateThumbnails: boolean,
thumbnailConfig: ThumbnailConfig,
@@ -135,17 +144,26 @@ async function convertToDashInternal(
// Show errors if any
if (result.errors.length > 0) {
console.warn('\n⚠️ Profile warnings:');
console.warn('\n Profile errors:');
for (const error of result.errors) {
console.warn(` - ${error}`);
}
console.warn('');
}
// Show warnings if any
if (result.warnings.length > 0) {
console.warn('\n⚠ Profile warnings:');
for (const warning of result.warnings) {
console.warn(` - ${warning}`);
}
console.warn('');
}
profiles = result.profiles;
if (profiles.length === 0) {
throw new Error('No valid profiles found in custom list. Check warnings above.');
throw new Error('No valid profiles found in custom list. Check errors above.');
}
} else if (userProfiles) {
// Programmatic API usage
@@ -177,63 +195,94 @@ async function convertToDashInternal(
await ensureDir(videoOutputDir);
reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined);
// Determine which codecs to use based on codec parameter
const codecs: Array<{ type: 'h264' | 'av1'; codec: string; preset: string }> = [];
if (codec === 'h264' || codec === 'dual') {
const h264Codec = willUseNvenc ? 'h264_nvenc' : 'libx264';
const h264Preset = willUseNvenc ? 'p4' : 'medium';
codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset });
}
if (codec === 'av1' || codec === 'dual') {
// Check for AV1 hardware encoder
const av1Support = await checkAV1Support();
const av1Codec = av1Support.available ? av1Support.encoder! : 'libsvtav1';
const av1Preset = av1Support.available ? (av1Codec === 'av1_nvenc' ? 'p4' : 'medium') : '8';
codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset });
}
const codecNames = codecs.map(c => c.type.toUpperCase()).join(' + ');
reportProgress('analyzing', 20, `Using ${codecNames} encoding (${willUseNvenc ? 'GPU' : 'CPU'})`, undefined);
// Video codec selection
const videoCodec = willUseNvenc ? 'h264_nvenc' : 'libx264';
const codecPreset = willUseNvenc ? 'p4' : 'medium';
const maxConcurrent = willUseNvenc ? 3 : 2;
// STAGE 1: Encode profiles to MP4 (parallel - heavy work)
reportProgress('encoding', 25, `Stage 1: Encoding ${profiles.length} profiles to MP4...`);
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
const tempMP4Paths = await encodeProfilesToMP4(
input,
tempDir,
profiles,
videoCodec,
codecPreset,
metadata.duration,
segmentDuration,
metadata.fps || 25, // Use detected FPS or default to 25
metadata.audioBitrate, // Source audio bitrate for smart selection
parallel,
maxConcurrent,
undefined, // optimizations - for future use
(profileName, percent) => {
const profileIndex = profiles.findIndex(p => p.name === profileName);
const baseProgress = 25 + (profileIndex / profiles.length) * 40;
const profileProgress = (percent / 100) * (40 / profiles.length);
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${profileName}...`, profileName);
for (let codecIndex = 0; codecIndex < codecs.length; codecIndex++) {
const { type, codec: videoCodec, preset: codecPreset } = codecs[codecIndex];
const codecProgress = codecIndex / codecs.length;
const codecProgressRange = 1 / codecs.length;
// Also report individual profile progress
if (onProgress) {
onProgress({
stage: 'encoding',
percent: baseProgress + profileProgress,
currentProfile: profileName,
profilePercent: percent, // Actual profile progress 0-100
message: `Encoding ${profileName}...`
});
reportProgress('encoding', 25 + codecProgress * 40, `Stage 1: Encoding ${type.toUpperCase()} (${profiles.length} profiles)...`);
const tempMP4Paths = await encodeProfilesToMP4(
input,
tempDir,
profiles,
videoCodec,
codecPreset,
metadata.duration,
segmentDuration,
metadata.audioBitrate,
parallel,
maxConcurrent,
type, // Pass codec type to differentiate output files
undefined, // optimizations - for future use
(profileName, percent) => {
const profileIndex = profiles.findIndex(p => p.name === profileName);
const baseProgress = 25 + codecProgress * 40;
const profileProgress = (percent / 100) * (40 * codecProgressRange / profiles.length);
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${type.toUpperCase()} ${profileName}...`, `${type}-${profileName}`);
// Also report individual profile progress
if (onProgress) {
onProgress({
stage: 'encoding',
percent: baseProgress + profileProgress,
currentProfile: `${type}-${profileName}`,
profilePercent: percent,
message: `Encoding ${type.toUpperCase()} ${profileName}...`
});
}
}
}
);
);
reportProgress('encoding', 65, 'Stage 1 complete: All profiles encoded');
codecMP4Paths.set(type, tempMP4Paths);
}
// STAGE 2: Package to DASH using MP4Box (light work, fast)
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
const manifestPath = await packageToDash(
tempMP4Paths,
// STAGE 2: Package to segments and manifests (unified, no duplication)
reportProgress('encoding', 70, `Stage 2: Creating segments and manifests...`);
const { manifestPath, hlsManifestPath } = await packageToFormats(
codecMP4Paths,
videoOutputDir,
profiles,
segmentDuration
segmentDuration,
codec,
format
);
const videoPaths = Array.from(tempMP4Paths.values());
// Collect all video paths from all codecs
const videoPaths: string[] = [];
for (const mp4Paths of codecMP4Paths.values()) {
videoPaths.push(...Array.from(mp4Paths.values()));
}
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
reportProgress('encoding', 80, 'Stage 2 complete: All formats packaged');
// Generate thumbnails
let thumbnailSpritePath: string | undefined;
@@ -277,23 +326,26 @@ async function convertToDashInternal(
reportProgress('thumbnails', 95, 'Poster generated');
}
// Generate MPD manifest
reportProgress('manifest', 95, 'Finalizing manifest...');
// Finalize
reportProgress('manifest', 95, 'Finalizing...');
// Note: manifestPath is already created by MP4Box in packageToDash
// Note: manifestPath/hlsManifestPath are already created by MP4Box in packageToDash/packageToHLS
// No need for separate generateManifest function
reportProgress('complete', 100, 'Conversion complete!');
return {
manifestPath,
hlsManifestPath,
videoPaths,
thumbnailSpritePath,
thumbnailVttPath,
posterPath,
duration: metadata.duration,
profiles,
usedNvenc: willUseNvenc
usedNvenc: willUseNvenc,
codecType: codec,
format
};
}

View File

@@ -14,12 +14,12 @@ export async function encodeProfileToMP4(
preset: string,
duration: number,
segmentDuration: number,
fps: number,
sourceAudioBitrate: number | undefined,
codecType: 'h264' | 'av1',
optimizations?: VideoOptimizations,
onProgress?: (percent: number) => void
): Promise<string> {
const outputPath = join(tempDir, `video_${profile.name}.mp4`);
const outputPath = join(tempDir, `video_${codecType}_${profile.name}.mp4`);
const args = [
'-y',
@@ -27,24 +27,49 @@ export async function encodeProfileToMP4(
'-c:v', videoCodec
];
// Add NVENC specific options
// Add codec-specific options
if (videoCodec === 'h264_nvenc') {
// NVIDIA H.264
args.push('-rc:v', 'vbr');
args.push('-preset', preset);
args.push('-2pass', '0');
} else if (videoCodec === 'av1_nvenc') {
// NVIDIA AV1
args.push('-rc:v', 'vbr');
args.push('-preset', preset);
args.push('-2pass', '0');
} else if (videoCodec === 'av1_qsv') {
// Intel QSV AV1
args.push('-preset', preset);
args.push('-global_quality', '23'); // Quality level for QSV
} else if (videoCodec === 'av1_amf') {
// AMD AMF AV1
args.push('-quality', 'balanced');
args.push('-rc', 'vbr_latency');
} else if (videoCodec === 'libsvtav1') {
// CPU-based SVT-AV1
args.push('-preset', preset); // 0-13, 8 is medium speed
args.push('-svtav1-params', 'tune=0:enable-overlays=1');
} else {
// Default (libx264, libx265, etc.)
args.push('-preset', preset);
}
// Video encoding parameters
// AV1 is ~40% more efficient than H.264 at same quality (Netflix/YouTube standard)
const bitrateMultiplier = codecType === 'av1' ? 0.6 : 1.0;
const targetBitrate = Math.round(parseInt(profile.videoBitrate) * bitrateMultiplier);
const bitrateString = `${targetBitrate}k`;
args.push(
'-b:v', profile.videoBitrate,
'-maxrate', profile.videoBitrate,
'-bufsize', `${parseInt(profile.videoBitrate) * 2}k`
'-b:v', bitrateString,
'-maxrate', bitrateString,
'-bufsize', `${targetBitrate * 2}k`
);
// Set GOP size for DASH segments
// Keyframes must align with segment boundaries
const fps = profile.fps || 30;
const gopSize = Math.round(fps * segmentDuration);
args.push(
'-g', String(gopSize), // GOP size (e.g., 25 fps * 2 sec = 50 frames)
@@ -101,10 +126,10 @@ export async function encodeProfilesToMP4(
preset: string,
duration: number,
segmentDuration: number,
fps: number,
sourceAudioBitrate: number | undefined,
parallel: boolean,
maxConcurrent: number,
codecType: 'h264' | 'av1',
optimizations?: VideoOptimizations,
onProgress?: (profileName: string, percent: number) => void
): Promise<Map<string, string>> {
@@ -123,8 +148,8 @@ export async function encodeProfilesToMP4(
preset,
duration,
segmentDuration,
fps,
sourceAudioBitrate,
codecType,
optimizations,
(percent) => {
if (onProgress) {
@@ -151,8 +176,8 @@ export async function encodeProfilesToMP4(
preset,
duration,
segmentDuration,
fps,
sourceAudioBitrate,
codecType,
optimizations,
(percent) => {
if (onProgress) {

View File

@@ -1,17 +1,19 @@
import { join } from 'node:path';
import { execMP4Box } from '../utils';
import type { VideoProfile } from '../types';
import type { VideoProfile, CodecType, StreamingFormat } from '../types';
import { readFile, writeFile, readdir, rename, mkdir } from 'node:fs/promises';
/**
* Package MP4 files into DASH format using MP4Box
* Stage 2: Light work - just packaging, no encoding
* Creates one master MPD manifest with all profiles
* Creates one master MPD manifest with all profiles and codecs
*/
export async function packageToDash(
mp4Files: Map<string, string>,
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number
segmentDuration: number,
codecType: CodecType
): Promise<string> {
const manifestPath = join(outputDir, 'manifest.mpd');
@@ -20,22 +22,32 @@ export async function packageToDash(
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
'-frag', String(segmentDuration * 1000),
'-rap', // Force segments to start with random access points
'-segment-timeline', // Use SegmentTimeline for accurate segment durations
'-segment-name', '$RepresentationID$_$Number$',
'-out', manifestPath
];
// Add all MP4 files with their profile IDs
for (const profile of profiles) {
const mp4Path = mp4Files.get(profile.name);
if (!mp4Path) {
throw new Error(`MP4 file not found for profile: ${profile.name}`);
}
// Add all MP4 files for each codec
let firstFile = true;
// Add video track with representation ID
args.push(`${mp4Path}#video:id=${profile.name}`);
// Add audio track (shared across all profiles)
if (profile === profiles[0]) {
args.push(`${mp4Path}#audio:id=audio`);
for (const [codec, mp4Files] of codecMP4Files.entries()) {
for (const profile of profiles) {
const mp4Path = mp4Files.get(profile.name);
if (!mp4Path) {
throw new Error(`MP4 file not found for profile: ${profile.name}, codec: ${codec}`);
}
// Representation ID includes codec: e.g., "720p-h264", "720p-av1"
const representationId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
// Add video track with representation ID
args.push(`${mp4Path}#video:id=${representationId}`);
// Add audio track only once (shared across all profiles and codecs)
if (firstFile) {
args.push(`${mp4Path}#audio:id=audio`);
firstFile = false;
}
}
}
@@ -44,10 +56,10 @@ export async function packageToDash(
// MP4Box creates files in the same directory as output MPD
// Move segment files to profile subdirectories for clean structure
await organizeSegments(outputDir, profiles);
await organizeSegments(outputDir, profiles, codecType);
// Update MPD to reflect new file structure with subdirectories
await updateManifestPaths(manifestPath, profiles);
await updateManifestPaths(manifestPath, profiles, codecType);
return manifestPath;
}
@@ -58,14 +70,27 @@ export async function packageToDash(
*/
async function organizeSegments(
outputDir: string,
profiles: VideoProfile[]
profiles: VideoProfile[],
codecType: CodecType
): Promise<void> {
const { readdir, rename, mkdir } = await import('node:fs/promises');
// Create profile subdirectories
for (const profile of profiles) {
const profileDir = join(outputDir, profile.name);
await mkdir(profileDir, { recursive: true });
// For dual-codec mode, create codec-specific subdirectories (e.g., "720p-h264/", "720p-av1/")
// For single-codec mode, use simple profile names (e.g., "720p/")
const codecs: Array<'h264' | 'av1'> = [];
if (codecType === 'h264' || codecType === 'dual') codecs.push('h264');
if (codecType === 'av1' || codecType === 'dual') codecs.push('av1');
const representationIds: string[] = [];
for (const codec of codecs) {
for (const profile of profiles) {
const repId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
representationIds.push(repId);
const profileDir = join(outputDir, repId);
await mkdir(profileDir, { recursive: true });
}
}
// Create audio subdirectory
@@ -90,11 +115,11 @@ async function organizeSegments(
continue;
}
// Move video segment files to their profile directories
for (const profile of profiles) {
if (file.startsWith(`${profile.name}_`)) {
// Move video segment files to their representation directories
for (const repId of representationIds) {
if (file.startsWith(`${repId}_`)) {
const oldPath = join(outputDir, file);
const newPath = join(outputDir, profile.name, file);
const newPath = join(outputDir, repId, file);
await rename(oldPath, newPath);
break;
}
@@ -107,7 +132,8 @@ async function organizeSegments(
*/
async function updateManifestPaths(
manifestPath: string,
profiles: VideoProfile[]
profiles: VideoProfile[],
codecType: CodecType
): Promise<void> {
const { readFile, writeFile } = await import('node:fs/promises');
@@ -133,3 +159,430 @@ async function updateManifestPaths(
await writeFile(manifestPath, mpd, 'utf-8');
}
/**
* Package MP4 files into HLS format using MP4Box
* Stage 2: Light work - just packaging, no encoding
* Creates master.m3u8 playlist with H.264 profiles only (for Safari/iOS compatibility)
*/
export async function packageToHLS(
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType
): Promise<string> {
const manifestPath = join(outputDir, 'master.m3u8');
// Build MP4Box command for HLS
const args = [
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
'-frag', String(segmentDuration * 1000),
'-rap', // Force segments to start with random access points
'-segment-timeline', // Use SegmentTimeline for accurate segment durations
'-segment-name', '$RepresentationID$_$Number$',
'-profile', 'live', // HLS mode instead of DASH
'-out', manifestPath
];
// For HLS, use only H.264 codec (Safari/iOS compatibility)
const h264Files = codecMP4Files.get('h264');
if (!h264Files) {
throw new Error('H.264 codec files not found. HLS requires H.264 for Safari/iOS compatibility.');
}
let firstFile = true;
for (const profile of profiles) {
const mp4Path = h264Files.get(profile.name);
if (!mp4Path) {
throw new Error(`MP4 file not found for profile: ${profile.name}, codec: h264`);
}
// Representation ID for HLS (no codec suffix since we only use H.264)
const representationId = profile.name;
// Add video track with representation ID
args.push(`${mp4Path}#video:id=${representationId}`);
// Add audio track only once (shared across all profiles)
if (firstFile) {
args.push(`${mp4Path}#audio:id=audio`);
firstFile = false;
}
}
// Execute MP4Box
await execMP4Box(args);
// MP4Box creates files in the same directory as output manifest
// Move segment files to profile subdirectories for clean structure
await organizeSegmentsHLS(outputDir, profiles);
// Update manifest to reflect new file structure with subdirectories
await updateManifestPathsHLS(manifestPath, profiles);
return manifestPath;
}
/**
* Organize HLS segments into profile subdirectories
* HLS only uses H.264, so no codec suffix in directory names
*/
async function organizeSegmentsHLS(
outputDir: string,
profiles: VideoProfile[]
): Promise<void> {
const representationIds: string[] = [];
for (const profile of profiles) {
const repId = profile.name; // Just profile name, no codec
representationIds.push(repId);
const profileDir = join(outputDir, repId);
await mkdir(profileDir, { recursive: true });
}
// Create audio subdirectory
const audioDir = join(outputDir, 'audio');
await mkdir(audioDir, { recursive: true });
// Get all files in output directory
const files = await readdir(outputDir);
// Move segment files to their respective directories
for (const file of files) {
// Skip manifest
if (file === 'master.m3u8') {
continue;
}
// Move audio files to audio/ directory
if (file.startsWith('audio_') || file === 'audio_init.m4s') {
const oldPath = join(outputDir, file);
const newPath = join(audioDir, file);
await rename(oldPath, newPath);
continue;
}
// Move video segment files to their representation directories
for (const repId of representationIds) {
if (file.startsWith(`${repId}_`)) {
const oldPath = join(outputDir, file);
const newPath = join(outputDir, repId, file);
await rename(oldPath, newPath);
break;
}
}
}
}
/**
* Update HLS master manifest to reflect subdirectory structure
*/
async function updateManifestPathsHLS(
manifestPath: string,
profiles: VideoProfile[]
): Promise<void> {
let m3u8 = await readFile(manifestPath, 'utf-8');
// MP4Box uses $RepresentationID$ template variable
// Replace: media="$RepresentationID$_$Number$.m4s"
// With: media="$RepresentationID$/$RepresentationID$_$Number$.m4s"
m3u8 = m3u8.replace(
/media="\$RepresentationID\$_\$Number\$\.m4s"/g,
'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"'
);
// Replace: initialization="$RepresentationID$_.mp4"
// With: initialization="$RepresentationID$/$RepresentationID$_.mp4"
m3u8 = m3u8.replace(
/initialization="\$RepresentationID\$_\.mp4"/g,
'initialization="$RepresentationID$/$RepresentationID$_.mp4"'
);
await writeFile(manifestPath, m3u8, 'utf-8');
}
/**
* Unified packaging: creates segments once and generates both DASH and HLS manifests
* No duplication - segments stored in {profile}-{codec}/ folders
*/
export async function packageToFormats(
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codec: CodecType,
format: StreamingFormat
): Promise<{ manifestPath?: string; hlsManifestPath?: string }> {
// Step 1: Generate segments using MP4Box (DASH mode)
const tempManifestPath = join(outputDir, '.temp_manifest.mpd');
const args = [
'-dash', String(segmentDuration * 1000),
'-frag', String(segmentDuration * 1000),
'-rap',
'-segment-timeline',
'-segment-name', '$RepresentationID$_$Number$',
'-out', tempManifestPath
];
// Add all MP4 files
let firstFile = true;
for (const [codecType, mp4Files] of codecMP4Files.entries()) {
for (const profile of profiles) {
const mp4Path = mp4Files.get(profile.name);
if (!mp4Path) {
throw new Error(`MP4 file not found for profile: ${profile.name}, codec: ${codecType}`);
}
const representationId = codec === 'dual' ? `${profile.name}-${codecType}` : profile.name;
args.push(`${mp4Path}#video:id=${representationId}`);
if (firstFile) {
args.push(`${mp4Path}#audio:id=audio`);
firstFile = false;
}
}
}
// Execute MP4Box to create segments
await execMP4Box(args);
// Step 2: Organize segments into {profile}-{codec}/ folders
await organizeSegmentsUnified(outputDir, profiles, codec);
// Step 3: Generate manifests based on format
let manifestPath: string | undefined;
let hlsManifestPath: string | undefined;
if (format === 'dash' || format === 'both') {
// Move and update DASH manifest
manifestPath = join(outputDir, 'manifest.mpd');
await rename(tempManifestPath, manifestPath);
await updateDashManifestPaths(manifestPath, profiles, codec);
}
if (format === 'hls' || format === 'both') {
// Generate HLS playlists
hlsManifestPath = await generateHLSPlaylists(
outputDir,
profiles,
segmentDuration,
codec
);
}
// Clean up temp manifest if not used
try {
const { unlink } = await import('node:fs/promises');
await unlink(tempManifestPath);
} catch {
// Already moved or doesn't exist
}
return { manifestPath, hlsManifestPath };
}
/**
* Organize segments into unified structure: {profile}-{codec}/
*/
async function organizeSegmentsUnified(
outputDir: string,
profiles: VideoProfile[],
codecType: CodecType
): Promise<void> {
const representationIds: string[] = [];
// Determine which codecs are used
const codecs: Array<'h264' | 'av1'> = [];
if (codecType === 'h264' || codecType === 'dual') codecs.push('h264');
if (codecType === 'av1' || codecType === 'dual') codecs.push('av1');
// Create directories for each profile-codec combination
for (const codecName of codecs) {
for (const profile of profiles) {
const repId = codecType === 'dual' ? `${profile.name}-${codecName}` : profile.name;
representationIds.push(repId);
const profileDir = join(outputDir, repId);
await mkdir(profileDir, { recursive: true });
}
}
// Create audio directory
const audioDir = join(outputDir, 'audio');
await mkdir(audioDir, { recursive: true });
// Get all files in output directory
const files = await readdir(outputDir);
// Move segment files to their respective directories
for (const file of files) {
// Skip manifests and directories
if (file.endsWith('.mpd') || file.endsWith('.m3u8') || !file.includes('_')) {
continue;
}
// Move audio files
if (file.startsWith('audio_')) {
const oldPath = join(outputDir, file);
const newPath = join(audioDir, file);
await rename(oldPath, newPath);
continue;
}
// Move video segment files
for (const repId of representationIds) {
if (file.startsWith(`${repId}_`)) {
const oldPath = join(outputDir, file);
const newPath = join(outputDir, repId, file);
await rename(oldPath, newPath);
break;
}
}
}
}
/**
* Update DASH manifest paths to point to {profile}-{codec}/ folders
*/
async function updateDashManifestPaths(
manifestPath: string,
profiles: VideoProfile[],
codecType: CodecType
): Promise<void> {
let mpd = await readFile(manifestPath, 'utf-8');
// Update paths: $RepresentationID$_$Number$.m4s → $RepresentationID$/$RepresentationID$_$Number$.m4s
mpd = mpd.replace(
/media="\$RepresentationID\$_\$Number\$\.m4s"/g,
'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"'
);
mpd = mpd.replace(
/initialization="\$RepresentationID\$_\.mp4"/g,
'initialization="$RepresentationID$/$RepresentationID$_.mp4"'
);
await writeFile(manifestPath, mpd, 'utf-8');
}
/**
* Generate HLS playlists (media playlists in folders + master in root)
*/
async function generateHLSPlaylists(
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType
): Promise<string> {
const masterPlaylistPath = join(outputDir, 'master.m3u8');
const variants: Array<{ path: string; bandwidth: number; resolution: string; fps: number }> = [];
// Generate media playlist for each H.264 profile
for (const profile of profiles) {
const profileDir = codecType === 'dual' ? `${profile.name}-h264` : profile.name;
const profilePath = join(outputDir, profileDir);
// Read segment files from profile directory
const files = await readdir(profilePath);
const segmentFiles = files
.filter(f => f.endsWith('.m4s'))
.sort((a, b) => {
const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0');
const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0');
return numA - numB;
});
const initFile = files.find(f => f.endsWith('_.mp4'));
if (!initFile || segmentFiles.length === 0) {
continue; // Skip if no segments found
}
// Generate media playlist content
let playlistContent = '#EXTM3U\n';
playlistContent += `#EXT-X-VERSION:6\n`;
playlistContent += `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}\n`;
playlistContent += `#EXT-X-MEDIA-SEQUENCE:1\n`;
playlistContent += `#EXT-X-INDEPENDENT-SEGMENTS\n`;
playlistContent += `#EXT-X-MAP:URI="${initFile}"\n`;
for (const segmentFile of segmentFiles) {
playlistContent += `#EXTINF:${segmentDuration},\n`;
playlistContent += `${segmentFile}\n`;
}
playlistContent += `#EXT-X-ENDLIST\n`;
// Write media playlist
const playlistPath = join(profilePath, 'playlist.m3u8');
await writeFile(playlistPath, playlistContent, 'utf-8');
// Add to variants list
const bandwidth = parseInt(profile.videoBitrate) * 1000;
variants.push({
path: `${profileDir}/playlist.m3u8`,
bandwidth,
resolution: `${profile.width}x${profile.height}`,
fps: profile.fps || 30
});
}
// Generate audio media playlist
const audioDir = join(outputDir, 'audio');
const audioFiles = await readdir(audioDir);
const audioSegments = audioFiles
.filter(f => f.endsWith('.m4s'))
.sort((a, b) => {
const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0');
const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0');
return numA - numB;
});
const audioInit = audioFiles.find(f => f.endsWith('_.mp4'));
if (audioInit && audioSegments.length > 0) {
let audioPlaylistContent = '#EXTM3U\n';
audioPlaylistContent += `#EXT-X-VERSION:6\n`;
audioPlaylistContent += `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}\n`;
audioPlaylistContent += `#EXT-X-MEDIA-SEQUENCE:1\n`;
audioPlaylistContent += `#EXT-X-INDEPENDENT-SEGMENTS\n`;
audioPlaylistContent += `#EXT-X-MAP:URI="${audioInit}"\n`;
for (const segmentFile of audioSegments) {
audioPlaylistContent += `#EXTINF:${segmentDuration},\n`;
audioPlaylistContent += `${segmentFile}\n`;
}
audioPlaylistContent += `#EXT-X-ENDLIST\n`;
await writeFile(join(audioDir, 'playlist.m3u8'), audioPlaylistContent, 'utf-8');
}
// Generate master playlist
let masterContent = '#EXTM3U\n';
masterContent += '#EXT-X-VERSION:6\n';
masterContent += '#EXT-X-INDEPENDENT-SEGMENTS\n\n';
// Add audio reference
masterContent += `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="audio",AUTOSELECT=YES,URI="audio/playlist.m3u8",CHANNELS="2"\n\n`;
// Add video variants
for (const variant of variants) {
masterContent += `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},CODECS="avc1.4D4020,mp4a.40.2",RESOLUTION=${variant.resolution},FRAME-RATE=${variant.fps},AUDIO="audio"\n`;
masterContent += `${variant.path}\n\n`;
}
await writeFile(masterPlaylistPath, masterContent, 'utf-8');
return masterPlaylistPath;
}

View File

@@ -9,7 +9,8 @@ export type {
ThumbnailConfig,
ConversionProgress,
VideoMetadata,
VideoOptimizations
VideoOptimizations,
CodecType
} from './types';
// Utility exports
@@ -17,6 +18,7 @@ export {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
getVideoMetadata,
selectAudioBitrate
} from './utils';

View File

@@ -1,3 +1,13 @@
/**
* Video codec type for encoding
*/
export type CodecType = 'av1' | 'h264' | 'dual';
/**
* Streaming format type
*/
export type StreamingFormat = 'dash' | 'hls' | 'both';
/**
* Configuration options for DASH conversion
*/
@@ -17,6 +27,12 @@ export interface DashConvertOptions {
/** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */
customProfiles?: string[];
/** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */
codec?: CodecType;
/** Streaming format to generate: 'dash', 'hls', or 'both' (default: 'both') */
format?: StreamingFormat;
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
useNvenc?: boolean;
@@ -57,6 +73,9 @@ export interface VideoProfile {
/** Audio bitrate (e.g., "128k") */
audioBitrate: string;
/** Target FPS for this profile (default: 30) */
fps?: number;
}
/**
@@ -100,8 +119,11 @@ export interface ConversionProgress {
* Result of DASH conversion
*/
export interface DashConvertResult {
/** Path to generated MPD manifest */
manifestPath: string;
/** Path to generated DASH manifest (if format is 'dash' or 'both') */
manifestPath?: string;
/** Path to generated HLS manifest (if format is 'hls' or 'both') */
hlsManifestPath?: string;
/** Paths to generated video segments */
videoPaths: string[];
@@ -123,6 +145,12 @@ export interface DashConvertResult {
/** Whether NVENC was used */
usedNvenc: boolean;
/** Codec type used for encoding */
codecType: CodecType;
/** Streaming format generated */
format: StreamingFormat;
}
/**

View File

@@ -3,6 +3,7 @@ export {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
execFFmpeg,
execMP4Box
} from './system';

View File

@@ -45,6 +45,42 @@ export async function checkNvenc(): Promise<boolean> {
});
}
/**
* Check if AV1 hardware encoding is available
* Supports: NVENC (RTX 40xx), QSV (Intel 11+), AMF (AMD RX 7000)
*/
export async function checkAV1Support(): Promise<{
available: boolean;
encoder?: 'av1_nvenc' | 'av1_qsv' | 'av1_amf';
}> {
return new Promise((resolve) => {
const proc = spawn('ffmpeg', ['-hide_banner', '-encoders']);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('error', () => resolve({ available: false }));
proc.on('close', (code) => {
if (code !== 0) {
resolve({ available: false });
} else {
// Check for hardware AV1 encoders in order of preference
if (output.includes('av1_nvenc')) {
resolve({ available: true, encoder: 'av1_nvenc' });
} else if (output.includes('av1_qsv')) {
resolve({ available: true, encoder: 'av1_qsv' });
} else if (output.includes('av1_amf')) {
resolve({ available: true, encoder: 'av1_amf' });
} else {
resolve({ available: false });
}
}
});
});
}
/**
* Execute FFmpeg command with progress tracking
*/