Compare commits
5 Commits
3086d6907c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b54c059f0 | |||
| 8cf4210d20 | |||
| 2da2b584fa | |||
| b843bdf897 | |||
| 8c61e0e9db |
83
README.md
83
README.md
@@ -1,18 +1,18 @@
|
||||
# DASH Video Converter 🎬
|
||||
|
||||
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
||||
CLI инструмент для конвертации видео в форматы DASH и HLS с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
||||
|
||||
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты (1080p/720p/480p/360p) • 🖼️ Thumbnail спрайты • 📊 Прогресс в реальном времени
|
||||
**Возможности:** ⚡ NVENC ускорение • 🎯 DASH + HLS форматы • 📊 Множественные битрейты • 🖼️ 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-cli video.mp4
|
||||
```
|
||||
|
||||
**Системные требования:**
|
||||
@@ -27,26 +27,79 @@ sudo apt install ffmpeg gpac
|
||||
brew install ffmpeg gpac
|
||||
```
|
||||
|
||||
**Результат:** В папке `./output/video/` будет создан `manifest.mpd` и видео сегменты для разных качеств.
|
||||
**Результат:** В текущей директории будет создана папка `video/` с сегментами в папках `{profile}-{codec}/`, манифестами DASH и HLS в корне, постером и превью спрайтами.
|
||||
|
||||
## Параметры CLI
|
||||
|
||||
```bash
|
||||
npx @grom13/dvc-cli <input-video> [output-dir]
|
||||
# или после установки:
|
||||
dvc <input-video> [output-dir]
|
||||
dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-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` |
|
||||
| `-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
|
||||
|
||||
# Указать выходную директорию
|
||||
dvc-cli video.mp4 ./output
|
||||
|
||||
# Только выбранные разрешения
|
||||
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 ./output -r 720,1080@60,1440@60 -c dual -f both -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 для ускорения
|
||||
|
||||
32
bin/cli.js
32
bin/cli.js
File diff suppressed because one or more lines are too long
385
docs/CLI_REFERENCE.md
Normal file
385
docs/CLI_REFERENCE.md
Normal 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) — Быстрый старт
|
||||
|
||||
10
package.json
10
package.json
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@grom13/dvc-cli",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.3",
|
||||
"description": "Fast DASH video converter with NVENC acceleration and thumbnail sprites",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"dvc": "./bin/cli.js"
|
||||
"dvc-cli": "./bin/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -44,12 +44,12 @@
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/grom13/dvc-cli.git"
|
||||
"url": "https://gromlab.ru/gromov/dvc-cli.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/grom13/dvc-cli/issues"
|
||||
"url": "https://gromlab.ru/gromov/dvc-cli/issues"
|
||||
},
|
||||
"homepage": "https://github.com/grom13/dvc-cli#readme",
|
||||
"homepage": "https://gromlab.ru/gromov/dvc-cli#readme",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
|
||||
144
src/cli.ts
144
src/cli.ts
@@ -4,21 +4,94 @@
|
||||
* DASH Video Converter CLI
|
||||
*
|
||||
* Usage:
|
||||
* dvc <input-video> [output-dir]
|
||||
* dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
|
||||
*
|
||||
* Example:
|
||||
* dvc ./video.mp4 ./output
|
||||
* 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';
|
||||
|
||||
const input = process.argv[2];
|
||||
const outputDir = process.argv[3] || './output';
|
||||
// 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
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '-r' || args[i] === '--resolutions') {
|
||||
// Collect all arguments after -r until next flag or end
|
||||
const profilesArgs: string[] = [];
|
||||
for (let j = i + 1; j < args.length; j++) {
|
||||
// Stop if we hit another flag (starts with -)
|
||||
if (args[j].startsWith('-')) {
|
||||
break;
|
||||
}
|
||||
profilesArgs.push(args[j]);
|
||||
i = j; // Skip these args in main loop
|
||||
}
|
||||
|
||||
// Parse profiles
|
||||
const joinedArgs = profilesArgs.join(',');
|
||||
customProfiles = joinedArgs
|
||||
.split(/[,\s]+/) // Split by comma or whitespace
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.length > 0);
|
||||
} 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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract positional arguments
|
||||
const input = positionalArgs[0];
|
||||
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
||||
|
||||
if (!input) {
|
||||
console.error('❌ Usage: bun run test.ts <input-video> [output-dir]');
|
||||
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 -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 -c dual -f both -p 10');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -26,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) {
|
||||
@@ -42,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);
|
||||
@@ -61,8 +154,16 @@ 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}`);
|
||||
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(', ')}`);
|
||||
}
|
||||
if (posterTimecode) {
|
||||
console.log(`🖼️ Poster timecode: ${posterTimecode}`);
|
||||
}
|
||||
console.log('\n🚀 Starting conversion...\n');
|
||||
|
||||
// Create multibar container
|
||||
const multibar = new cliProgress.MultiBar({
|
||||
@@ -82,9 +183,14 @@ try {
|
||||
const result = await convertToDash({
|
||||
input,
|
||||
outputDir,
|
||||
customProfiles,
|
||||
posterTimecode,
|
||||
codec: codecType,
|
||||
format: formatType,
|
||||
segmentDuration: 2,
|
||||
useNvenc: hasNvenc,
|
||||
generateThumbnails: true,
|
||||
generatePoster: true,
|
||||
parallel: true,
|
||||
onProgress: (progress) => {
|
||||
const stageName = progress.stage === 'encoding' ? 'Encoding' :
|
||||
@@ -127,17 +233,31 @@ 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}`);
|
||||
}
|
||||
|
||||
if (result.thumbnailSpritePath) {
|
||||
console.log(` Thumbnails: ${result.thumbnailSpritePath}`);
|
||||
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();
|
||||
|
||||
@@ -77,7 +77,7 @@ export const DEFAULT_PROFILES: VideoProfile[] = [
|
||||
audioBitrate: '256k'
|
||||
},
|
||||
{
|
||||
name: '4K',
|
||||
name: '2160p',
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
videoBitrate: calculateBitrate(3840, 2160, 30),
|
||||
@@ -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,147 @@ 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
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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),
|
||||
fps: 30
|
||||
};
|
||||
}
|
||||
|
||||
return createHighFPSProfile(baseProfile, fps, maxBitrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if profile can be created from source
|
||||
* Returns object with error, warning, and adjusted FPS
|
||||
*/
|
||||
export function validateProfile(
|
||||
profileStr: string,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
sourceFPS: number
|
||||
): { error?: string; warning?: string; adjustedFps?: number } {
|
||||
const parsed = parseProfileString(profileStr);
|
||||
|
||||
if (!parsed) {
|
||||
return { error: `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60` };
|
||||
}
|
||||
|
||||
const profile = getProfileByName(parsed.resolution, parsed.fps);
|
||||
|
||||
if (!profile) {
|
||||
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 { error: `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})` };
|
||||
}
|
||||
|
||||
// Check if requested FPS exceeds source FPS
|
||||
const MAX_FPS = 120;
|
||||
let adjustedFps = parsed.fps;
|
||||
let warning: string | undefined;
|
||||
|
||||
if (parsed.fps > sourceFPS) {
|
||||
// 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 warning ? { warning, adjustedFps } : {}; // 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[]; warnings: string[] } {
|
||||
const profiles: VideoProfile[] = [];
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const profileStr of profileStrings) {
|
||||
// Validate
|
||||
const result = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS);
|
||||
|
||||
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
|
||||
|
||||
// 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, warnings };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 } 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';
|
||||
import { packageToFormats } from './packaging';
|
||||
|
||||
/**
|
||||
* Convert video to DASH format with NVENC acceleration
|
||||
@@ -32,9 +35,14 @@ export async function convertToDash(
|
||||
outputDir,
|
||||
segmentDuration = 2,
|
||||
profiles: userProfiles,
|
||||
customProfiles,
|
||||
codec = 'dual',
|
||||
format = 'both',
|
||||
useNvenc,
|
||||
generateThumbnails = true,
|
||||
thumbnailConfig = {},
|
||||
generatePoster: shouldGeneratePoster = true,
|
||||
posterTimecode = '00:00:01',
|
||||
parallel = true,
|
||||
onProgress
|
||||
} = options;
|
||||
@@ -50,9 +58,14 @@ export async function convertToDash(
|
||||
tempDir,
|
||||
segmentDuration,
|
||||
userProfiles,
|
||||
customProfiles,
|
||||
codec,
|
||||
format,
|
||||
useNvenc,
|
||||
generateThumbnails,
|
||||
thumbnailConfig,
|
||||
shouldGeneratePoster,
|
||||
posterTimecode,
|
||||
parallel,
|
||||
onProgress
|
||||
);
|
||||
@@ -75,9 +88,14 @@ async function convertToDashInternal(
|
||||
tempDir: string,
|
||||
segmentDuration: number,
|
||||
userProfiles: VideoProfile[] | undefined,
|
||||
customProfiles: string[] | undefined,
|
||||
codec: CodecType,
|
||||
format: StreamingFormat,
|
||||
useNvenc: boolean | undefined,
|
||||
generateThumbnails: boolean,
|
||||
thumbnailConfig: ThumbnailConfig,
|
||||
generatePosterFlag: boolean,
|
||||
posterTimecode: string,
|
||||
parallel: boolean,
|
||||
onProgress?: (progress: ConversionProgress) => void
|
||||
): Promise<DashConvertResult> {
|
||||
@@ -112,12 +130,53 @@ 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 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 errors 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,65 +185,104 @@ 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);
|
||||
// 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;
|
||||
@@ -213,22 +311,41 @@ async function convertToDashInternal(
|
||||
reportProgress('thumbnails', 90, 'Thumbnails generated');
|
||||
}
|
||||
|
||||
// Generate MPD manifest
|
||||
reportProgress('manifest', 95, 'Finalizing manifest...');
|
||||
// Generate poster
|
||||
let posterPath: string | undefined;
|
||||
|
||||
// Note: manifestPath is already created by MP4Box in packageToDash
|
||||
if (generatePosterFlag) {
|
||||
reportProgress('thumbnails', 92, 'Generating poster image...');
|
||||
|
||||
posterPath = await generatePoster(
|
||||
input,
|
||||
videoOutputDir,
|
||||
posterTimecode
|
||||
);
|
||||
|
||||
reportProgress('thumbnails', 95, 'Poster generated');
|
||||
}
|
||||
|
||||
// Finalize
|
||||
reportProgress('manifest', 95, 'Finalizing...');
|
||||
|
||||
// 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
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
import { join } from 'node:path';
|
||||
import type { ThumbnailConfig } from '../types';
|
||||
import { execFFmpeg, formatVttTime } from '../utils';
|
||||
import { exists, readdir, unlink, rmdir } from 'node:fs/promises';
|
||||
import { execFFmpeg, formatVttTime, ensureDir } from '../utils';
|
||||
import { readdir, unlink, rmdir, writeFile } 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
|
||||
@@ -16,7 +45,8 @@ export async function generateThumbnailSprite(
|
||||
|
||||
// Create temp directory for individual thumbnails
|
||||
const tempDir = join(outputDir, '.thumbnails_temp');
|
||||
await Bun.write(join(tempDir, '.keep'), '');
|
||||
await ensureDir(tempDir);
|
||||
await writeFile(join(tempDir, '.keep'), '');
|
||||
|
||||
// Generate individual thumbnails
|
||||
const thumbnailPattern = join(tempDir, 'thumb_%04d.jpg');
|
||||
@@ -66,7 +96,7 @@ export async function generateThumbnailSprite(
|
||||
'thumbnails.jpg'
|
||||
);
|
||||
|
||||
await Bun.write(vttPath, vttContent);
|
||||
await writeFile(vttPath, vttContent);
|
||||
|
||||
// Clean up temp files
|
||||
for (const file of thumbFiles) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@@ -14,6 +24,15 @@ export interface DashConvertOptions {
|
||||
/** Video quality profiles to generate */
|
||||
profiles?: VideoProfile[];
|
||||
|
||||
/** 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;
|
||||
|
||||
@@ -23,6 +42,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;
|
||||
|
||||
@@ -48,6 +73,9 @@ export interface VideoProfile {
|
||||
|
||||
/** Audio bitrate (e.g., "128k") */
|
||||
audioBitrate: string;
|
||||
|
||||
/** Target FPS for this profile (default: 30) */
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,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[];
|
||||
@@ -103,6 +134,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;
|
||||
|
||||
@@ -111,6 +145,12 @@ export interface DashConvertResult {
|
||||
|
||||
/** Whether NVENC was used */
|
||||
usedNvenc: boolean;
|
||||
|
||||
/** Codec type used for encoding */
|
||||
codecType: CodecType;
|
||||
|
||||
/** Streaming format generated */
|
||||
format: StreamingFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { mkdir, exists } from 'node:fs/promises';
|
||||
import { mkdir, access, constants } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
*/
|
||||
export async function ensureDir(dirPath: string): Promise<void> {
|
||||
if (!await exists(dirPath)) {
|
||||
try {
|
||||
await access(dirPath, constants.F_OK);
|
||||
} catch {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
checkNvenc,
|
||||
checkAV1Support,
|
||||
execFFmpeg,
|
||||
execMP4Box
|
||||
} from './system';
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user