feat: Обновленая реализация CLI
This commit is contained in:
25
.gitignore
vendored
Normal file
25
.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Игнорировать node_modules
|
||||
node_modules/
|
||||
|
||||
# Игнорировать dist
|
||||
dist/
|
||||
|
||||
# Игнорировать временные файлы
|
||||
*.log
|
||||
*.tmp
|
||||
.DS_Store
|
||||
|
||||
# Игнорировать тестовые файлы
|
||||
*.mp4
|
||||
*.mkv
|
||||
*.avi
|
||||
*.mov
|
||||
test-output/
|
||||
|
||||
# Игнорировать IDE файлы
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
/data/
|
||||
32
.npmignore
Normal file
32
.npmignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Source files
|
||||
src/
|
||||
*.ts
|
||||
!*.d.ts
|
||||
|
||||
# Development files
|
||||
app.ts
|
||||
tsconfig.json
|
||||
bun.lock
|
||||
|
||||
# Test files
|
||||
web-test/
|
||||
examples/
|
||||
|
||||
# Documentation
|
||||
FEATURES.md
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 grom13
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
111
README.md
Normal file
111
README.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# DASH Video Converter 🎬
|
||||
|
||||
CLI инструмент для конвертации видео в форматы DASH и HLS с поддержкой аппаратного ускорения (NVENC / Intel QSV / AMD AMF / VAAPI), адаптивным стримингом и автоматической генерацией превью.
|
||||
|
||||
**Возможности:**
|
||||
- ⚡ Аппаратное ускорение: NVENC / Intel QSV / AMD AMF / VAAPI (автовыбор по приоритету)
|
||||
- 🎯 Форматы: DASH и HLS (оба из одних сегментов)
|
||||
- 📊 Профили качества: множественные битрейты и FPS, авто или кастом
|
||||
- 🖼️ Превью: thumbnail спрайты + VTT, постер с первого кадра
|
||||
- ⏱️ Прогресс: CLI прогресс-бары по профилям и общему этапу
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```bash
|
||||
# Использование через npx (без установки)
|
||||
npx @gromlab/create-vod video.mp4
|
||||
|
||||
# Или глобальная установка
|
||||
npm install -g @gromlab/create-vod
|
||||
create-vod video.mp4
|
||||
```
|
||||
|
||||
**Системные требования:**
|
||||
```bash
|
||||
# Arch Linux
|
||||
sudo pacman -S ffmpeg gpac
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg gpac
|
||||
|
||||
# macOS
|
||||
brew install ffmpeg gpac
|
||||
```
|
||||
|
||||
**Результат:** В текущей директории будет создана папка `video/` с сегментами в папках `{profile}-{codec}/`, манифестами DASH и HLS в корне, постером и превью спрайтами.
|
||||
|
||||
## Параметры CLI
|
||||
|
||||
```bash
|
||||
create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]
|
||||
```
|
||||
|
||||
### Основные параметры
|
||||
|
||||
| Параметр | Описание | По умолчанию | Обязательный |
|
||||
|----------|----------|--------------|--------------|
|
||||
| `input-video` | Путь к входному видео файлу | - | ✅ |
|
||||
| `output-dir` | Директория для выходных файлов | `.` (текущая папка) | ❌ |
|
||||
|
||||
### Опциональные ключи
|
||||
|
||||
| Ключ | Описание | Формат | Пример |
|
||||
|------|----------|--------|--------|
|
||||
| `-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` |
|
||||
| `--accel` | Аппаратный ускоритель | `auto`, `nvenc`, `qsv`, `amf`, `cpu` | `--accel nvenc` |
|
||||
|
||||
### Примеры использования
|
||||
|
||||
```bash
|
||||
# Базовая конвертация (DASH + HLS, dual codec, автопрофили)
|
||||
create-vod video.mp4
|
||||
|
||||
# Указать выходную директорию
|
||||
create-vod video.mp4 ./output
|
||||
|
||||
# Только выбранные разрешения
|
||||
create-vod video.mp4 -r 720,1080,1440
|
||||
|
||||
# Высокий FPS для игровых стримов
|
||||
create-vod video.mp4 -r 720@60,1080@60
|
||||
|
||||
# Только DASH формат
|
||||
create-vod video.mp4 -f dash
|
||||
|
||||
# Только HLS для Safari/iOS
|
||||
create-vod video.mp4 -f hls -c h264
|
||||
|
||||
# Постер с 5-й секунды
|
||||
create-vod video.mp4 -p 5
|
||||
|
||||
# Комбинация параметров
|
||||
create-vod 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 сек)
|
||||
- **Постер:** извлекается с начала видео (кадр 0:00:00, можно изменить через `-p`)
|
||||
- **Параллельное кодирование:** включено
|
||||
|
||||
**Требования:** Node.js ≥18.0.0, FFmpeg, MP4Box (gpac), опционально NVIDIA GPU для ускорения
|
||||
85
bin/cli.js
Executable file
85
bin/cli.js
Executable file
File diff suppressed because one or more lines are too long
404
docs/CLI_REFERENCE.md
Normal file
404
docs/CLI_REFERENCE.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# CLI Reference — Справочник команд
|
||||
|
||||
Полное руководство по использованию DASH Video Converter CLI.
|
||||
|
||||
---
|
||||
|
||||
## Синтаксис
|
||||
|
||||
```bash
|
||||
create-vod <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)
|
||||
create-vod video.mp4 -r 360,720,1080
|
||||
|
||||
# С указанием FPS
|
||||
create-vod video.mp4 -r 720@60,1080@60
|
||||
|
||||
# Смешанный формат
|
||||
create-vod 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 (быстрее, больше места)
|
||||
create-vod video.mp4 -c h264
|
||||
|
||||
# Только AV1 (медленнее, меньше места)
|
||||
create-vod video.mp4 -c av1
|
||||
|
||||
# Оба кодека (максимальная совместимость)
|
||||
create-vod 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`
|
||||
|
||||
---
|
||||
|
||||
### `--accel` — Аппаратный ускоритель
|
||||
|
||||
Выбор приоритетного ускорителя. По умолчанию выбирается лучший из доступных.
|
||||
|
||||
**Значения:**
|
||||
- `auto` — автоопределение по приоритету (NVENC → QSV → AMF → CPU)
|
||||
- `nvenc` — NVIDIA NVENC
|
||||
- `qsv` — Intel Quick Sync
|
||||
- `amf` — AMD AMF
|
||||
- `cpu` — принудительно без GPU
|
||||
|
||||
**Примеры:**
|
||||
```bash
|
||||
create-vod video.mp4 --accel nvenc
|
||||
create-vod video.mp4 --accel qsv
|
||||
create-vod video.mp4 --accel cpu # отключить GPU
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `-f, --format` — Формат стриминга
|
||||
|
||||
Выбор формата адаптивного стриминга.
|
||||
|
||||
**Значения:**
|
||||
- `dash` — только DASH (MPEG-DASH)
|
||||
- `hls` — только HLS (HTTP Live Streaming)
|
||||
- `both` — оба формата
|
||||
|
||||
**Примеры:**
|
||||
```bash
|
||||
# Только DASH
|
||||
create-vod video.mp4 -f dash
|
||||
|
||||
# Только HLS (для Safari/iOS)
|
||||
create-vod video.mp4 -f hls
|
||||
|
||||
# Оба формата (максимальная совместимость)
|
||||
create-vod 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 секунд от начала
|
||||
create-vod video.mp4 -p 5
|
||||
|
||||
# 1 минута 30 секунд
|
||||
create-vod video.mp4 -p 00:01:30
|
||||
```
|
||||
|
||||
**По умолчанию:** `00:00:00` (первый кадр)
|
||||
|
||||
---
|
||||
|
||||
## Примеры использования
|
||||
|
||||
### Базовое использование
|
||||
|
||||
```bash
|
||||
# Простейший запуск (оба формата, dual codec, автопрофили)
|
||||
create-vod video.mp4
|
||||
|
||||
# С указанием выходной директории
|
||||
create-vod video.mp4 ./output
|
||||
```
|
||||
|
||||
### Кастомные профили
|
||||
|
||||
```bash
|
||||
# Только 720p и 1080p
|
||||
create-vod video.mp4 -r 720,1080
|
||||
|
||||
# High FPS профили
|
||||
create-vod video.mp4 -r 720@60,1080@60,1440@120
|
||||
|
||||
# Один профиль 4K
|
||||
create-vod video.mp4 -r 2160
|
||||
```
|
||||
|
||||
### Выбор кодека
|
||||
|
||||
```bash
|
||||
# Быстрое кодирование (только H.264)
|
||||
create-vod video.mp4 -c h264
|
||||
|
||||
# Лучшее сжатие (только AV1)
|
||||
create-vod video.mp4 -c av1
|
||||
|
||||
# Максимальная совместимость
|
||||
create-vod video.mp4 -c dual
|
||||
```
|
||||
|
||||
### Выбор формата
|
||||
|
||||
```bash
|
||||
# DASH для современных браузеров
|
||||
create-vod video.mp4 -f dash
|
||||
|
||||
# HLS для Safari/iOS
|
||||
create-vod video.mp4 -f hls -c h264
|
||||
|
||||
# Оба формата для всех устройств
|
||||
create-vod video.mp4 -f both -c dual
|
||||
```
|
||||
|
||||
### Комбинированные примеры
|
||||
|
||||
```bash
|
||||
# Производственная конфигурация
|
||||
create-vod video.mp4 ./cdn/videos -r 360,720,1080 -c dual -f both
|
||||
|
||||
# High-end конфигурация (4K, high FPS)
|
||||
create-vod video.mp4 -r 720@60,1080@60,1440@120,2160@60 -c dual -f both
|
||||
|
||||
# Быстрая конвертация для тестов
|
||||
create-vod video.mp4 -r 720 -c h264 -f dash
|
||||
|
||||
# Mobile-first (низкие разрешения, HLS)
|
||||
create-vod video.mp4 -r 360,480,720 -c h264 -f hls
|
||||
|
||||
# Кастомный постер
|
||||
create-vod 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
|
||||
create-vod video.mp4 -c dual -f both
|
||||
```
|
||||
|
||||
Генерирует:
|
||||
- DASH с H.264 + AV1 (Chrome, Firefox, Edge)
|
||||
- HLS с H.264 (Safari, iOS)
|
||||
- Все современные устройства поддерживаются
|
||||
|
||||
### Для быстрой разработки
|
||||
|
||||
```bash
|
||||
create-vod video.mp4 -r 720 -c h264 -f dash
|
||||
```
|
||||
|
||||
Быстрое кодирование одного профиля.
|
||||
|
||||
### Для продакшена
|
||||
|
||||
```bash
|
||||
create-vod video.mp4 -r 360,480,720,1080,1440 -c dual -f both
|
||||
```
|
||||
|
||||
Широкий диапазон профилей для всех устройств.
|
||||
|
||||
### Для 4K контента
|
||||
|
||||
```bash
|
||||
create-vod 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
|
||||
create-vod video.mp4 -f hls -c h264
|
||||
# или
|
||||
create-vod 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) — Быстрый старт
|
||||
213
docs/FEATURES.md
Normal file
213
docs/FEATURES.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Возможности DASH Video Converter
|
||||
|
||||
## Архитектура
|
||||
|
||||
Конвертация выполняется в два этапа для обеспечения стабильности и максимальной производительности:
|
||||
|
||||
**Этап 1: Кодирование** - тяжелая работа по перекодированию видео во все профили качества с использованием FFmpeg и NVENC.
|
||||
|
||||
**Этап 2: Упаковка DASH** - быстрая упаковка готовых MP4 файлов в DASH формат через MP4Box с генерацией манифеста.
|
||||
|
||||
Преимущества подхода:
|
||||
- Стабильность: MP4Box специализируется на DASH, FFmpeg - на кодирование
|
||||
- Параллелизм: все профили кодируются одновременно на GPU
|
||||
- Надежность: разделение ответственности между инструментами
|
||||
|
||||
## Этап 1: Оптимизация и кодирование
|
||||
|
||||
### Стандартные профили разрешений
|
||||
|
||||
Автоматически создаются профили с частотой 30 FPS:
|
||||
- 360p (640x360) - 800 kbps
|
||||
- 480p (854x480) - 1200 kbps
|
||||
- 720p (1280x720) - 2800 kbps
|
||||
- 1080p (1920x1080) - 5000 kbps
|
||||
|
||||
### Опциональные профили высокого разрешения
|
||||
|
||||
Создаются только если исходное видео имеет соответствующее или более высокое разрешение:
|
||||
- 2K (2560x1440) - если исходное >= 1440p
|
||||
- 4K (3840x2160) - если исходное >= 2160p
|
||||
|
||||
Система автоматически определяет разрешение исходного видео и создает только применимые профили без upscaling.
|
||||
|
||||
### Высокочастотные профили
|
||||
|
||||
Система автоматически определяет частоту кадров исходного видео и создает дополнительные высокочастотные профили только если это поддерживается оригиналом:
|
||||
|
||||
- **Оригинал >= 45 FPS**: создаются профили @ 60 FPS для всех разрешений
|
||||
- **Оригинал >= 75 FPS**: создаются профили @ 90 FPS для всех разрешений
|
||||
- **Оригинал >= 95 FPS**: создаются профили @ 120 FPS для всех разрешений
|
||||
|
||||
Стандартные 30 FPS профили создаются всегда.
|
||||
|
||||
Пример: если исходное видео 60 FPS, будут созданы:
|
||||
- 360p @ 30fps, 360p @ 60fps
|
||||
- 480p @ 30fps, 480p @ 60fps
|
||||
- 720p @ 30fps, 720p @ 60fps
|
||||
- 1080p @ 30fps, 1080p @ 60fps
|
||||
|
||||
Интерполяция кадров не применяется - создаются только те частоты, которые нативно поддерживаются исходным материалом.
|
||||
|
||||
### Технические особенности
|
||||
|
||||
- **NVENC GPU ускорение**: аппаратное кодирование на видеокарте NVIDIA
|
||||
- **GOP size выравнивание**: keyframe каждые N кадров для точной сегментации (N = FPS × segment_duration)
|
||||
- **VBR режим**: переменный битрейт для оптимального качества
|
||||
- **Умное кодирование аудио**: автоматический выбор оптимального битрейта без upscaling
|
||||
- Целевой максимум: 256 kbps AAC стерео
|
||||
- Фактический битрейт: `min(source_bitrate, 256 kbps)`
|
||||
- Округление до стандартных значений: 64k, 96k, 128k, 192k, 256k
|
||||
- Примеры: исходник 64 kbps → выход 64 kbps | исходник 320 kbps → выход 256 kbps
|
||||
|
||||
## Этап 2: Создание DASH
|
||||
|
||||
### Упаковка через MP4Box
|
||||
|
||||
- Создание фрагментированных MP4 сегментов длительностью 2 секунды
|
||||
- Генерация единого MPD манифеста для всех профилей
|
||||
- Выравнивание сегментов по Random Access Points (RAP)
|
||||
|
||||
### Организация файловой структуры
|
||||
|
||||
После упаковки файлы автоматически организуются в подпапки:
|
||||
- Видео сегменты: `{resolution}/`
|
||||
- Аудио сегменты: `audio/`
|
||||
- Манифест: корень директории
|
||||
|
||||
Пути в MPD манифесте обновляются для соответствия структуре подпапок.
|
||||
|
||||
## Множественные аудио дорожки
|
||||
|
||||
### Поддержка озвучек и языков
|
||||
|
||||
Система поддерживает несколько аудио дорожек с различными источниками:
|
||||
|
||||
**Извлечение из видео**:
|
||||
- Автоматическое извлечение всех аудио дорожек из входного файла
|
||||
- Выбор конкретных дорожек по индексу
|
||||
|
||||
**Внешние файлы**:
|
||||
- Добавление аудио из отдельных файлов (MP3, AAC, M4A)
|
||||
- Синхронизация с видео
|
||||
|
||||
### Метаданные аудио дорожек
|
||||
|
||||
Каждая дорожка содержит метаданные для правильного отображения в плеере:
|
||||
|
||||
- **language**: код языка (ru, en, ja)
|
||||
- **label**: название озвучки ("Кубик в кубе", "LostFilm", "Original")
|
||||
- **role**: тип озвучки
|
||||
- `main` - основная
|
||||
- `dub` - дубляж
|
||||
- `commentary` - комментарии
|
||||
|
||||
Пример структуры в MPD:
|
||||
```xml
|
||||
<AdaptationSet lang="ru" label="Кубик в кубе">
|
||||
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="dub"/>
|
||||
<Representation id="audio_kubik" .../>
|
||||
</AdaptationSet>
|
||||
```
|
||||
|
||||
## Генерация постера
|
||||
|
||||
### Автоматический режим
|
||||
|
||||
По умолчанию постер создается из первого кадра видео (00:00:00).
|
||||
|
||||
### Указание таймкода
|
||||
|
||||
Возможно указание конкретного времени для извлечения постера:
|
||||
|
||||
Формат:
|
||||
- `MM:SS` - минуты:секунды (например, `06:32`)
|
||||
- `HH:MM:SS` - часы:минуты:секунды (например, `01:23:45`)
|
||||
|
||||
Команда:
|
||||
```bash
|
||||
--poster-time 06:32
|
||||
```
|
||||
|
||||
Постер сохраняется в формате JPEG с оптимизированным качеством.
|
||||
|
||||
## Превью спрайты
|
||||
|
||||
### Thumbnail спрайты
|
||||
|
||||
Автоматическая генерация спрайта с миниатюрами для навигации по видео:
|
||||
|
||||
- **Интервал**: 1 секунда (по умолчанию)
|
||||
- **Размер миниатюры**: 160x90 пикселей
|
||||
- **Сетка**: 10 колонок, динамическое количество строк
|
||||
- **Формат**: JPEG sprite
|
||||
|
||||
### WebVTT файл
|
||||
|
||||
Генерируется VTT файл с координатами каждой миниатюры:
|
||||
|
||||
```vtt
|
||||
WEBVTT
|
||||
|
||||
00:00:00.000 --> 00:00:01.000
|
||||
thumbnails.jpg#xywh=0,0,160,90
|
||||
|
||||
00:00:01.000 --> 00:00:02.000
|
||||
thumbnails.jpg#xywh=160,0,160,90
|
||||
```
|
||||
|
||||
Плееры используют VTT для отображения превью при наведении на timeline.
|
||||
|
||||
## Выходная структура файлов
|
||||
|
||||
### Организация директорий
|
||||
|
||||
```
|
||||
output/
|
||||
└── video-name/
|
||||
├── manifest.mpd # Главный DASH манифест
|
||||
├── poster.jpg # Постер видео
|
||||
├── thumbnails.jpg # Спрайт превью
|
||||
├── thumbnails.vtt # WebVTT для превью
|
||||
├── audio/ # Аудио дорожки
|
||||
│ ├── audio_init.m4s # Инициализационный сегмент
|
||||
│ ├── audio_1.m4s # Сегмент #1
|
||||
│ └── audio_N.m4s # Сегмент #N
|
||||
├── 1080p/ # Профиль 1080p @ 30fps
|
||||
│ ├── 1080p_init.m4s
|
||||
│ ├── 1080p_1.m4s
|
||||
│ └── 1080p_N.m4s
|
||||
├── 1080p-60/ # Профиль 1080p @ 60fps (если применимо)
|
||||
│ └── ...
|
||||
├── 720p/ # Профиль 720p @ 30fps
|
||||
│ └── ...
|
||||
├── 480p/ # Профиль 480p @ 30fps
|
||||
│ └── ...
|
||||
└── 360p/ # Профиль 360p @ 30fps
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Именование файлов
|
||||
|
||||
**Инициализационные сегменты**: `{profile}_init.m4s` или `{profile}_.mp4`
|
||||
|
||||
**Медиа сегменты**: `{profile}_{number}.m4s`
|
||||
|
||||
**Аудио**: `audio_{number}.m4s` или `audio_{lang}_{number}.m4s` для множественных дорожек
|
||||
|
||||
Имя выходной директории всегда соответствует имени входного видео файла (без расширения).
|
||||
|
||||
## Производительность
|
||||
|
||||
- Параллельное кодирование до 3 профилей одновременно (с NVENC)
|
||||
- GOP size точно соответствует длительности сегмента для быстрой упаковки
|
||||
- Временные файлы в `/tmp/` с автоочисткой
|
||||
- Прогресс-бары в реальном времени для каждого профиля
|
||||
|
||||
## Требования
|
||||
|
||||
- **FFmpeg**: с поддержкой h264_nvenc (опционально), aac, scale
|
||||
- **MP4Box** (GPAC): для DASH упаковки
|
||||
- **NVIDIA GPU**: для NVENC ускорения (опционально, fallback на CPU)
|
||||
- **Bun**: runtime окружение
|
||||
|
||||
135
docs/PUBLISHING.md
Normal file
135
docs/PUBLISHING.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# 📦 Инструкция по публикации в NPM
|
||||
|
||||
## Подготовка к публикации
|
||||
|
||||
### Шаг 1: Авторизация в NPM
|
||||
|
||||
```bash
|
||||
npm login
|
||||
```
|
||||
|
||||
Введите credentials для аккаунта с доступом к организации `@grom13`.
|
||||
|
||||
### Шаг 2: Сборка проекта
|
||||
|
||||
```bash
|
||||
cd /home/gromov/projects/my/create-vod
|
||||
npm run build
|
||||
```
|
||||
|
||||
Эта команда выполнит:
|
||||
- Сборку библиотеки в `dist/`
|
||||
- Генерацию TypeScript деклараций (`.d.ts`)
|
||||
- Сборку CLI бинарника в `bin/cli.js`
|
||||
|
||||
### Шаг 3: Проверка перед публикацией (опционально)
|
||||
|
||||
```bash
|
||||
# Посмотреть какие файлы будут опубликованы
|
||||
npm pack --dry-run
|
||||
|
||||
# Или создать тестовый архив для проверки
|
||||
npm pack
|
||||
# Это создаст файл gromlab-create-vod-0.1.0.tgz
|
||||
```
|
||||
|
||||
## Публикация
|
||||
|
||||
### Шаг 4: Публикация в NPM
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
⚠️ **Важно:** Флаг `--access public` обязателен для scoped пакетов (`@grom13/...`), иначе NPM попытается опубликовать как приватный пакет (требует платную подписку).
|
||||
|
||||
### Шаг 5: Проверка публикации
|
||||
|
||||
```bash
|
||||
# Проверить что пакет доступен
|
||||
npm view @gromlab/create-vod
|
||||
|
||||
# Протестировать установку через npx
|
||||
npx @gromlab/create-vod --help
|
||||
|
||||
# Или установить глобально и протестировать
|
||||
npm install -g @gromlab/create-vod
|
||||
dvc --help
|
||||
```
|
||||
|
||||
## Обновление версии
|
||||
|
||||
Для будущих релизов используйте команды версионирования:
|
||||
|
||||
```bash
|
||||
# Patch версия (0.1.0 → 0.1.1) - исправления багов
|
||||
npm version patch
|
||||
|
||||
# Minor версия (0.1.0 → 0.2.0) - новые функции
|
||||
npm version minor
|
||||
|
||||
# Major версия (0.1.0 → 1.0.0) - breaking changes
|
||||
npm version major
|
||||
```
|
||||
|
||||
После обновления версии:
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
## Откат публикации (если нужно)
|
||||
|
||||
```bash
|
||||
# Удалить конкретную версию (в течение 72 часов)
|
||||
npm unpublish @gromlab/create-vod@0.1.0
|
||||
|
||||
# Удалить весь пакет (использовать осторожно!)
|
||||
npm unpublish @gromlab/create-vod --force
|
||||
```
|
||||
|
||||
⚠️ **Внимание:** После unpublish нельзя повторно опубликовать ту же версию. Нужно увеличить версию.
|
||||
|
||||
## Использование после публикации
|
||||
|
||||
Пакет будет доступен для использования:
|
||||
|
||||
```bash
|
||||
# Через npx (без установки)
|
||||
npx @gromlab/create-vod video.mp4 ./output
|
||||
|
||||
# Глобальная установка
|
||||
npm install -g @gromlab/create-vod
|
||||
dvc video.mp4 ./output
|
||||
|
||||
# Локальная установка в проект
|
||||
npm install @gromlab/create-vod
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Ошибка: "You must sign up for private packages"
|
||||
|
||||
Решение: Добавьте флаг `--access public` при публикации.
|
||||
|
||||
### Ошибка: "You do not have permission to publish"
|
||||
|
||||
Решение: Убедитесь что вы авторизованы (`npm whoami`) и имеете доступ к организации `@grom13`.
|
||||
|
||||
### Ошибка при сборке
|
||||
|
||||
Решение: Убедитесь что установлены все зависимости:
|
||||
```bash
|
||||
npm install
|
||||
# или
|
||||
bun install
|
||||
```
|
||||
|
||||
## Checklist перед публикацией
|
||||
|
||||
- [ ] Обновлена версия в `package.json`
|
||||
- [ ] Обновлен `README.md` с актуальной информацией
|
||||
- [ ] Проект успешно собирается (`npm run build`)
|
||||
- [ ] Протестирован CLI локально
|
||||
- [ ] Авторизованы в NPM (`npm whoami`)
|
||||
- [ ] Проверены файлы для публикации (`npm pack --dry-run`)
|
||||
558
docs/VIDEO_QUALITY_TESTING.md
Normal file
558
docs/VIDEO_QUALITY_TESTING.md
Normal file
@@ -0,0 +1,558 @@
|
||||
# Тестирование качества видео и сравнение кодеков
|
||||
|
||||
Руководство по анализу качества видео, сравнению кодеков и измерению эффективности сжатия.
|
||||
|
||||
## 📋 Содержание
|
||||
|
||||
1. [Метрики качества](#метрики-качества)
|
||||
2. [Протестированные кодеки](#протестированные-кодеки)
|
||||
3. [Результаты тестирования](#результаты-тестирования)
|
||||
4. [Команды для тестирования](#команды-для-тестирования)
|
||||
5. [Интерпретация результатов](#интерпретация-результатов)
|
||||
6. [Рекомендации](#рекомендации)
|
||||
|
||||
---
|
||||
|
||||
## Метрики качества
|
||||
|
||||
### PSNR (Peak Signal-to-Noise Ratio)
|
||||
|
||||
**Что измеряет:** Отношение сигнал/шум в децибелах (dB). Показывает математическое отличие между оригиналом и сжатым видео.
|
||||
|
||||
**Интерпретация:**
|
||||
- `> 45 dB` - Отличное качество (практически неразличимо)
|
||||
- `40-45 dB` - Очень хорошее качество
|
||||
- `35-40 dB` - Хорошее качество
|
||||
- `30-35 dB` - Приемлемое качество
|
||||
- `< 30 dB` - Плохое качество (видимые артефакты)
|
||||
|
||||
**Формула расчета:**
|
||||
```
|
||||
PSNR = 10 × log₁₀(MAX²/MSE)
|
||||
```
|
||||
где MAX - максимальное значение пикселя (255 для 8-bit), MSE - средняя квадратичная ошибка.
|
||||
|
||||
### SSIM (Structural Similarity Index)
|
||||
|
||||
**Что измеряет:** Структурное сходство изображений. Более точно отражает восприятие человеческим глазом.
|
||||
|
||||
**Интерпретация:**
|
||||
- `1.0` - Идентичные изображения (100%)
|
||||
- `> 0.99` - Отличное качество (99%+ схожести)
|
||||
- `0.95-0.99` - Хорошее качество
|
||||
- `0.90-0.95` - Приемлемое качество
|
||||
- `< 0.90` - Заметная потеря качества
|
||||
|
||||
**Преимущества SSIM:**
|
||||
- Учитывает яркость, контраст и структуру
|
||||
- Лучше коррелирует с субъективной оценкой качества
|
||||
- Более устойчива к локальным искажениям
|
||||
|
||||
---
|
||||
|
||||
## Протестированные кодеки
|
||||
|
||||
### 1. H.264 / AVC
|
||||
|
||||
**Описание:** Широко распространенный кодек, поддерживается всеми устройствами.
|
||||
|
||||
**Энкодеры:**
|
||||
- `libx264` - CPU энкодер (отличное качество/размер)
|
||||
- `h264_nvenc` - NVIDIA GPU энкодер (быстрее, но менее эффективен)
|
||||
|
||||
**Параметры качества:**
|
||||
- CRF: 0-51 (меньше = лучше качество)
|
||||
- Рекомендуемый диапазон: 18-28
|
||||
- Пресеты: ultrafast, fast, medium, slow, slower, veryslow
|
||||
|
||||
### 2. VP9
|
||||
|
||||
**Описание:** Открытый кодек от Google, часть WebM. На 20-50% эффективнее H.264.
|
||||
|
||||
**Энкодеры:**
|
||||
- `libvpx-vp9` - CPU энкодер
|
||||
- `vp9_vaapi` - аппаратное ускорение (Intel/AMD)
|
||||
|
||||
**Параметры качества:**
|
||||
- CRF: 0-63 (меньше = лучше качество)
|
||||
- Рекомендуемый диапазон: 28-35
|
||||
- cpu-used: 0-5 (меньше = лучше качество, медленнее)
|
||||
|
||||
### 3. AV1
|
||||
|
||||
**Описание:** Современный кодек, следующее поколение после VP9. На 30-50% эффективнее H.264.
|
||||
|
||||
**Энкодеры:**
|
||||
- `libsvtav1` - CPU энкодер (быстрый, хорошее качество)
|
||||
- `libaom-av1` - CPU энкодер (лучшее качество, очень медленный)
|
||||
- `av1_nvenc` - NVIDIA GPU энкодер (быстро, но менее эффективен)
|
||||
- `av1_amf` - AMD GPU энкодер
|
||||
- `av1_qsv` - Intel GPU энкодер
|
||||
|
||||
**Параметры качества:**
|
||||
- CRF (libsvtav1): 0-63 (меньше = лучше качество)
|
||||
- CQ (av1_nvenc): 0-51 (меньше = лучше качество)
|
||||
- Рекомендуемый диапазон: 30-40
|
||||
|
||||
---
|
||||
|
||||
## Результаты тестирования
|
||||
|
||||
### Тестовое видео
|
||||
|
||||
**Параметры:**
|
||||
- Файл: `tenexia.mp4`
|
||||
- Разрешение: 1920×1080 (Full HD)
|
||||
- FPS: 25
|
||||
- Длительность: 135 секунд (2:15)
|
||||
- Оригинальный размер: 167 MB
|
||||
- Оригинальный кодек: H.264 (битрейт ~10 Mbps)
|
||||
|
||||
### Сводная таблица результатов
|
||||
|
||||
| Кодек | Энкодер | Параметр | Размер | PSNR | SSIM | Сжатие | Скорость |
|
||||
|-------|---------|----------|--------|------|------|--------|----------|
|
||||
| **Оригинал** | - | - | 167 MB | - | - | 1.0x | - |
|
||||
| **VP9** | libvpx-vp9 | CRF 32 | 13 MB | 47.42 dB | 0.9917 | 12.8x | ~5-10 мин |
|
||||
| **AV1** | libsvtav1 | CRF 35 | 9.5 MB | 48.01 dB | 0.9921 | 17.6x | ~10-15 мин |
|
||||
| **AV1** | av1_nvenc | CQ 32 | 20 MB | N/A | N/A | 8.3x | ~10 сек |
|
||||
| **AV1** | av1_nvenc | CQ 40 | 9.3 MB | 47.13 dB | 0.9914 | 18.0x | ~10 сек |
|
||||
| **AV1** | av1_nvenc | CQ 45 | 7.1 MB | 45.49 dB | 0.9899 | 23.5x | ~10 сек |
|
||||
| **H.264** | libx264 | CRF 28 | 9.7 MB | 44.85 dB | 0.9904 | 17.2x | ~3-5 мин |
|
||||
| **H.264** | h264_nvenc | CQ 28 | 20 MB | 47.88 dB | 0.9922 | 8.4x | ~10 сек |
|
||||
| **H.264** | h264_nvenc | CQ 32 | 12 MB | N/A | N/A | 14.0x | ~10 сек |
|
||||
| **H.264** | h264_nvenc | CQ 35 | 7.9 MB | 44.48 dB | 0.9891 | 21.1x | ~10 сек |
|
||||
|
||||
### Победители по категориям
|
||||
|
||||
🥇 **Лучшее качество:** AV1 CPU (CRF 35) - PSNR 48.01 dB, SSIM 0.9921
|
||||
|
||||
🥇 **Лучшее сжатие при сохранении качества:** AV1 GPU (CQ 40) - 9.3 MB, PSNR 47.13 dB
|
||||
|
||||
🥇 **Максимальное сжатие:** AV1 GPU (CQ 45) - 7.1 MB, PSNR 45.49 dB (всё ещё отличное)
|
||||
|
||||
⚡ **Лучший баланс скорость/качество:** AV1 GPU (CQ 40) - быстро + малый размер + хорошее качество
|
||||
|
||||
---
|
||||
|
||||
## Команды для тестирования
|
||||
|
||||
### 1. Анализ исходного видео
|
||||
|
||||
#### Получить метаданные с помощью ffprobe
|
||||
|
||||
```bash
|
||||
# Полная информация в JSON формате
|
||||
ffprobe -v error -show_format -show_streams -print_format json input.mp4
|
||||
|
||||
# Краткая информация о видео
|
||||
ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,r_frame_rate,bit_rate -of default=noprint_wrappers=1 input.mp4
|
||||
|
||||
# Информация об аудио
|
||||
ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,bit_rate,channels -of default=noprint_wrappers=1 input.mp4
|
||||
|
||||
# Размер файла и битрейт
|
||||
ffprobe -v error -show_entries format=size,duration,bit_rate -of default=noprint_wrappers=1:nokey=1 input.mp4
|
||||
```
|
||||
|
||||
### 2. Конвертация видео
|
||||
|
||||
#### VP9 (CPU)
|
||||
|
||||
```bash
|
||||
# Базовая конвертация
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v libvpx-vp9 \
|
||||
-crf 32 \
|
||||
-b:v 0 \
|
||||
-row-mt 1 \
|
||||
-cpu-used 2 \
|
||||
-c:a libopus \
|
||||
-b:a 128k \
|
||||
output_vp9.webm
|
||||
|
||||
# Параметры:
|
||||
# -crf 32 - качество (18-40, меньше = лучше)
|
||||
# -b:v 0 - режим постоянного качества
|
||||
# -row-mt 1 - многопоточность
|
||||
# -cpu-used 2 - скорость кодирования (0-5)
|
||||
```
|
||||
|
||||
#### AV1 (CPU) - libsvtav1
|
||||
|
||||
```bash
|
||||
# Рекомендуемая конфигурация
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v libsvtav1 \
|
||||
-crf 35 \
|
||||
-preset 6 \
|
||||
-svtav1-params tune=0 \
|
||||
-c:a libopus \
|
||||
-b:a 128k \
|
||||
output_av1_cpu.mp4
|
||||
|
||||
# Параметры:
|
||||
# -crf 35 - качество (0-63, меньше = лучше)
|
||||
# -preset 6 - скорость (0-13, 6 = средняя)
|
||||
# -svtav1-params tune=0 - оптимизация под PSNR
|
||||
```
|
||||
|
||||
#### AV1 (GPU) - NVIDIA
|
||||
|
||||
```bash
|
||||
# Оптимальный баланс качество/размер
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v av1_nvenc \
|
||||
-preset p7 \
|
||||
-cq 40 \
|
||||
-b:v 0 \
|
||||
-c:a libopus \
|
||||
-b:a 128k \
|
||||
output_av1_gpu.mp4
|
||||
|
||||
# Параметры:
|
||||
# -preset p7 - качество пресета (p1-p7, p7 = лучшее)
|
||||
# -cq 40 - constant quality (0-51, меньше = лучше)
|
||||
# -b:v 0 - без ограничения битрейта
|
||||
|
||||
# Максимальное сжатие (хорошее качество)
|
||||
ffmpeg -i input.mp4 -c:v av1_nvenc -preset p7 -cq 45 -b:v 0 -c:a libopus -b:a 128k output_av1_small.mp4
|
||||
```
|
||||
|
||||
#### AV1 (GPU) - AMD
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v av1_amf \
|
||||
-quality quality \
|
||||
-qp_i 40 -qp_p 40 \
|
||||
-c:a libopus \
|
||||
-b:a 128k \
|
||||
output_av1_amd.mp4
|
||||
```
|
||||
|
||||
#### AV1 (GPU) - Intel
|
||||
|
||||
```bash
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v av1_qsv \
|
||||
-preset veryslow \
|
||||
-global_quality 40 \
|
||||
-c:a libopus \
|
||||
-b:a 128k \
|
||||
output_av1_intel.mp4
|
||||
```
|
||||
|
||||
#### H.264 (CPU)
|
||||
|
||||
```bash
|
||||
# Лучшее качество/размер
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v libx264 \
|
||||
-crf 28 \
|
||||
-preset slow \
|
||||
-c:a aac \
|
||||
-b:a 128k \
|
||||
output_h264_cpu.mp4
|
||||
|
||||
# Параметры:
|
||||
# -crf 28 - качество (18-28, меньше = лучше)
|
||||
# -preset slow - компромисс скорость/качество
|
||||
# (ultrafast, fast, medium, slow, slower, veryslow)
|
||||
```
|
||||
|
||||
#### H.264 (GPU) - NVIDIA
|
||||
|
||||
```bash
|
||||
# Баланс качество/размер
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v h264_nvenc \
|
||||
-preset p7 \
|
||||
-cq 33 \
|
||||
-b:v 0 \
|
||||
-c:a aac \
|
||||
-b:a 128k \
|
||||
output_h264_gpu.mp4
|
||||
|
||||
# Параметры:
|
||||
# -preset p7 - качество (p1-p7)
|
||||
# -cq 33 - constant quality (0-51)
|
||||
```
|
||||
|
||||
### 3. Измерение качества
|
||||
|
||||
#### PSNR (Peak Signal-to-Noise Ratio)
|
||||
|
||||
```bash
|
||||
# Базовый расчет PSNR
|
||||
ffmpeg -i encoded.mp4 -i original.mp4 \
|
||||
-lavfi "[0:v][1:v]psnr" \
|
||||
-f null - 2>&1 | grep "PSNR"
|
||||
|
||||
# С сохранением детальной статистики в файл
|
||||
ffmpeg -i encoded.mp4 -i original.mp4 \
|
||||
-lavfi "[0:v][1:v]psnr=stats_file=psnr_stats.log" \
|
||||
-f null -
|
||||
|
||||
# Просмотр статистики
|
||||
head -5 psnr_stats.log && echo "..." && tail -5 psnr_stats.log
|
||||
```
|
||||
|
||||
**Формат вывода:**
|
||||
```
|
||||
PSNR y:46.02 u:53.92 v:53.54 average:47.42 min:41.20 max:52.27
|
||||
```
|
||||
|
||||
- `y` - яркость (luminance)
|
||||
- `u`, `v` - цветовые каналы (chrominance)
|
||||
- `average` - средний PSNR
|
||||
- `min`, `max` - минимальный и максимальный PSNR по кадрам
|
||||
|
||||
#### SSIM (Structural Similarity Index)
|
||||
|
||||
```bash
|
||||
# Базовый расчет SSIM
|
||||
ffmpeg -i encoded.mp4 -i original.mp4 \
|
||||
-lavfi "[0:v][1:v]ssim" \
|
||||
-f null - 2>&1 | grep "SSIM"
|
||||
|
||||
# С сохранением детальной статистики
|
||||
ffmpeg -i encoded.mp4 -i original.mp4 \
|
||||
-lavfi "[0:v][1:v]ssim=stats_file=ssim_stats.log" \
|
||||
-f null -
|
||||
|
||||
# Просмотр статистики
|
||||
head -5 ssim_stats.log && echo "..." && tail -5 ssim_stats.log
|
||||
```
|
||||
|
||||
**Формат вывода:**
|
||||
```
|
||||
SSIM Y:0.9887 (19.46 dB) U:0.9979 (26.70 dB) V:0.9979 (26.75 dB) All:0.9917 (20.83 dB)
|
||||
```
|
||||
|
||||
- Значения 0.0-1.0 (1.0 = идентичные изображения)
|
||||
- dB - SSIM в децибелах (для удобства сравнения)
|
||||
|
||||
#### VMAF (Video Multimethod Assessment Fusion)
|
||||
|
||||
VMAF - современная метрика от Netflix, лучше всего коррелирует с человеческим восприятием.
|
||||
|
||||
```bash
|
||||
# Установка модели VMAF (один раз)
|
||||
# Скачать модель с https://github.com/Netflix/vmaf/tree/master/model
|
||||
|
||||
# Расчет VMAF
|
||||
ffmpeg -i encoded.mp4 -i original.mp4 \
|
||||
-lavfi "[0:v][1:v]libvmaf=model_path=/path/to/vmaf_v0.6.1.json:log_path=vmaf.json" \
|
||||
-f null -
|
||||
|
||||
# Интерпретация VMAF:
|
||||
# 90-100 - Отличное качество
|
||||
# 75-90 - Хорошее качество
|
||||
# 50-75 - Приемлемое качество
|
||||
# < 50 - Плохое качество
|
||||
```
|
||||
|
||||
### 4. Полный скрипт для тестирования
|
||||
|
||||
Создайте файл `test_codec.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
|
||||
# Использование: ./test_codec.sh input.mp4 encoded.mp4 output_dir
|
||||
|
||||
INPUT="$1"
|
||||
ENCODED="$2"
|
||||
OUTPUT_DIR="${3:-.}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
echo "=== Анализ размеров файлов ==="
|
||||
echo "Оригинал:"
|
||||
ls -lh "$INPUT" | awk '{print $5, $9}'
|
||||
echo "Сжатый:"
|
||||
ls -lh "$ENCODED" | awk '{print $5, $9}'
|
||||
echo ""
|
||||
|
||||
# Рассчет сжатия
|
||||
ORIG_SIZE=$(stat -f%z "$INPUT" 2>/dev/null || stat -c%s "$INPUT")
|
||||
ENC_SIZE=$(stat -f%z "$ENCODED" 2>/dev/null || stat -c%s "$ENCODED")
|
||||
RATIO=$(echo "scale=2; $ORIG_SIZE / $ENC_SIZE" | bc)
|
||||
echo "Сжатие: ${RATIO}x"
|
||||
echo ""
|
||||
|
||||
echo "=== Метаданные закодированного видео ==="
|
||||
ffprobe -v error -show_format -show_streams -print_format json "$ENCODED" | \
|
||||
grep -E "(codec_name|width|height|bit_rate|size)" | head -10
|
||||
echo ""
|
||||
|
||||
echo "=== Расчет PSNR ==="
|
||||
ffmpeg -i "$ENCODED" -i "$INPUT" \
|
||||
-lavfi "[0:v][1:v]psnr=stats_file=$OUTPUT_DIR/psnr_stats.log" \
|
||||
-f null - 2>&1 | grep "PSNR"
|
||||
echo ""
|
||||
|
||||
echo "=== Расчет SSIM ==="
|
||||
ffmpeg -i "$ENCODED" -i "$INPUT" \
|
||||
-lavfi "[0:v][1:v]ssim=stats_file=$OUTPUT_DIR/ssim_stats.log" \
|
||||
-f null - 2>&1 | grep "SSIM"
|
||||
echo ""
|
||||
|
||||
echo "Детальная статистика сохранена в $OUTPUT_DIR/"
|
||||
```
|
||||
|
||||
**Использование:**
|
||||
|
||||
```bash
|
||||
chmod +x test_codec.sh
|
||||
./test_codec.sh original.mp4 encoded.mp4 ./test_results
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Интерпретация результатов
|
||||
|
||||
### Матрица принятия решений
|
||||
|
||||
| Сценарий | Рекомендуемый кодек | Параметры | Ожидаемый результат |
|
||||
|----------|---------------------|-----------|---------------------|
|
||||
| Максимальное качество | AV1 CPU | CRF 30-35 | Лучшее качество, малый размер |
|
||||
| Быстрое кодирование | AV1 GPU | CQ 38-42 | Быстро, хорошее качество |
|
||||
| Совместимость | H.264 CPU | CRF 23-28 | Работает везде |
|
||||
| Веб-стриминг | VP9 | CRF 30-35 | Хороший баланс |
|
||||
| Архивирование | AV1 CPU | CRF 25-30 | Лучшее качество |
|
||||
|
||||
### Соответствие метрик и визуального качества
|
||||
|
||||
| PSNR | SSIM | Визуальная оценка |
|
||||
|------|------|-------------------|
|
||||
| > 45 dB | > 0.99 | Практически неотличимо от оригинала |
|
||||
| 40-45 dB | 0.98-0.99 | Отличное качество, артефакты незаметны |
|
||||
| 35-40 dB | 0.95-0.98 | Хорошее качество, артефакты видны при внимательном просмотре |
|
||||
| 30-35 dB | 0.90-0.95 | Приемлемое качество, видимые артефакты |
|
||||
| < 30 dB | < 0.90 | Плохое качество, явные артефакты |
|
||||
|
||||
### Факторы, влияющие на результаты
|
||||
|
||||
1. **Контент видео:**
|
||||
- Статичные сцены сжимаются лучше
|
||||
- Быстрое движение требует больше битрейта
|
||||
- Детализированные текстуры сложнее сжать
|
||||
- Темные сцены могут показывать бандинг
|
||||
|
||||
2. **Разрешение:**
|
||||
- Высокие разрешения требуют более высокого битрейта
|
||||
- При одинаковом CRF, 4K будет весить больше чем 1080p
|
||||
|
||||
3. **Частота кадров:**
|
||||
- 60 FPS требует ~1.5-2x больше битрейта чем 30 FPS
|
||||
- Высокий FPS важнее для игрового контента
|
||||
|
||||
4. **Цветовое пространство:**
|
||||
- HDR (10-bit) требует ~20-30% больше битрейта
|
||||
- Широкий цветовой охват увеличивает размер
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации
|
||||
|
||||
### Для продакшена
|
||||
|
||||
**Adaptive Streaming (DASH/HLS):**
|
||||
```bash
|
||||
# Используйте несколько профилей качества
|
||||
# 360p, 480p, 720p, 1080p, 1440p, 2160p
|
||||
|
||||
# Dual codec для максимальной совместимости:
|
||||
# - AV1 для современных браузеров
|
||||
# - H.264 для старых устройств (iOS < 14)
|
||||
|
||||
# Пример: create-vod уже реализует это
|
||||
create-vod input.mp4 output/ -c dual -f both -r 360,720,1080
|
||||
```
|
||||
|
||||
### Для архивирования
|
||||
|
||||
```bash
|
||||
# Используйте AV1 CPU с низким CRF
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v libsvtav1 -crf 25 -preset 4 \
|
||||
-c:a libopus -b:a 192k \
|
||||
archive.mp4
|
||||
```
|
||||
|
||||
### Для быстрой обработки
|
||||
|
||||
```bash
|
||||
# Используйте GPU кодирование
|
||||
ffmpeg -i input.mp4 \
|
||||
-c:v av1_nvenc -preset p7 -cq 35 \
|
||||
-c:a aac -b:a 128k \
|
||||
quick_encode.mp4
|
||||
```
|
||||
|
||||
### Подбор оптимального CRF/CQ
|
||||
|
||||
**Метод бинарного поиска:**
|
||||
|
||||
1. Начните с среднего значения (CRF 28 для H.264, 32 для VP9, 35 для AV1)
|
||||
2. Закодируйте короткий фрагмент (30-60 сек)
|
||||
3. Проверьте качество (PSNR > 45, SSIM > 0.99 для отличного качества)
|
||||
4. Если качество избыточное - увеличьте CRF на 2-3
|
||||
5. Если качество недостаточное - уменьшите CRF на 2-3
|
||||
6. Повторяйте до достижения баланса
|
||||
|
||||
**Быстрый тест:**
|
||||
|
||||
```bash
|
||||
# Извлечь 60 секунд с 30-й секунды
|
||||
ffmpeg -ss 30 -i input.mp4 -t 60 -c copy sample.mp4
|
||||
|
||||
# Протестировать разные CRF
|
||||
for crf in 30 32 35 38 40; do
|
||||
ffmpeg -i sample.mp4 -c:v libsvtav1 -crf $crf -preset 8 -c:a copy test_crf${crf}.mp4
|
||||
|
||||
# Измерить качество
|
||||
ffmpeg -i test_crf${crf}.mp4 -i sample.mp4 -lavfi "[0:v][1:v]psnr" -f null - 2>&1 | grep "PSNR"
|
||||
|
||||
# Размер файла
|
||||
ls -lh test_crf${crf}.mp4
|
||||
done
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Дополнительные ресурсы
|
||||
|
||||
### Инструменты
|
||||
|
||||
- **FFmpeg** - https://ffmpeg.org/
|
||||
- **ffprobe** - анализ медиа файлов
|
||||
- **MediaInfo** - GUI инструмент для анализа
|
||||
- **Handbrake** - GUI для кодирования
|
||||
- **ab-av1** - инструмент для подбора оптимальных параметров AV1
|
||||
|
||||
### Документация
|
||||
|
||||
- [FFmpeg Encoding Guide](https://trac.ffmpeg.org/wiki/Encode)
|
||||
- [x264 Settings](https://trac.ffmpeg.org/wiki/Encode/H.264)
|
||||
- [VP9 Encoding Guide](https://trac.ffmpeg.org/wiki/Encode/VP9)
|
||||
- [SVT-AV1 Documentation](https://gitlab.com/AOMediaCodec/SVT-AV1)
|
||||
- [NVIDIA Video Codec SDK](https://developer.nvidia.com/video-codec-sdk)
|
||||
|
||||
### Научные статьи
|
||||
|
||||
- "The Netflix Tech Blog: Per-Title Encode Optimization"
|
||||
- "VMAF: The Journey Continues" - Netflix
|
||||
- "AV1 Performance vs x265 and libvpx" - Facebook Engineering
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
**2025-11-12** - Создан документ на основе реальных тестов
|
||||
- Протестированы кодеки: VP9, AV1 (CPU/GPU), H.264 (CPU/GPU)
|
||||
- Добавлены все команды для тестирования
|
||||
- Добавлены результаты сравнения на видео 1920×1080, 135 сек
|
||||
|
||||
64
package.json
Normal file
64
package.json
Normal file
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@gromlab/create-vod",
|
||||
"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": {
|
||||
"create-vod": "./bin/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "npm run build:lib && npm run build:cli",
|
||||
"build:lib": "bun build src/index.ts --outdir dist --target node && tsc --emitDeclarationOnly",
|
||||
"build:cli": "bun build src/cli.ts --outfile bin/cli.js --target node --minify",
|
||||
"prepublishOnly": "npm run build",
|
||||
"create-vod": "bun run src/cli.ts",
|
||||
"test": "bun run src/cli.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"dash",
|
||||
"video",
|
||||
"converter",
|
||||
"ffmpeg",
|
||||
"nvenc",
|
||||
"streaming",
|
||||
"cli",
|
||||
"video-processing",
|
||||
"adaptive-streaming",
|
||||
"thumbnails"
|
||||
],
|
||||
"author": "grom13",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://gromlab.ru/gromov/create-vod.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://gromlab.ru/gromov/create-vod/issues"
|
||||
},
|
||||
"homepage": "https://gromlab.ru/gromov/create-vod#readme",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"cli-progress": "^3.12.0"
|
||||
}
|
||||
}
|
||||
372
src/cli.ts
Normal file
372
src/cli.ts
Normal file
@@ -0,0 +1,372 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* DASH Video Converter CLI
|
||||
*
|
||||
* Usage:
|
||||
* create-vod <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
|
||||
*
|
||||
* Example:
|
||||
* create-vod ./video.mp4 ./output -r 720,1080
|
||||
*/
|
||||
|
||||
import { convertToDash, checkFFmpeg, checkMP4Box, getVideoMetadata, detectHardwareEncoders } from './index';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { statSync } from 'node:fs';
|
||||
import { basename, extname } from 'node:path';
|
||||
import type { CodecType, StreamingFormat, QualitySettings, HardwareAccelerationOption } from './types';
|
||||
import { selectProfiles, createProfilesFromStrings } from './config/profiles';
|
||||
|
||||
// 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[] = [];
|
||||
|
||||
// Quality settings
|
||||
let h264CQ: number | undefined;
|
||||
let h264CRF: number | undefined;
|
||||
let av1CQ: number | undefined;
|
||||
let av1CRF: number | undefined;
|
||||
let accelerator: HardwareAccelerationOption | undefined;
|
||||
|
||||
// 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] === '--h264-cq') {
|
||||
h264CQ = parseInt(args[i + 1]);
|
||||
if (isNaN(h264CQ) || h264CQ < 0 || h264CQ > 51) {
|
||||
console.error(`❌ Invalid H.264 CQ value: ${args[i + 1]}. Must be 0-51`);
|
||||
process.exit(1);
|
||||
}
|
||||
i++; // Skip next arg
|
||||
} else if (args[i] === '--h264-crf') {
|
||||
h264CRF = parseInt(args[i + 1]);
|
||||
if (isNaN(h264CRF) || h264CRF < 0 || h264CRF > 51) {
|
||||
console.error(`❌ Invalid H.264 CRF value: ${args[i + 1]}. Must be 0-51`);
|
||||
process.exit(1);
|
||||
}
|
||||
i++; // Skip next arg
|
||||
} else if (args[i] === '--av1-cq') {
|
||||
av1CQ = parseInt(args[i + 1]);
|
||||
if (isNaN(av1CQ) || av1CQ < 0 || av1CQ > 51) {
|
||||
console.error(`❌ Invalid AV1 CQ value: ${args[i + 1]}. Must be 0-51`);
|
||||
process.exit(1);
|
||||
}
|
||||
i++; // Skip next arg
|
||||
} else if (args[i] === '--av1-crf') {
|
||||
av1CRF = parseInt(args[i + 1]);
|
||||
if (isNaN(av1CRF) || av1CRF < 0 || av1CRF > 63) {
|
||||
console.error(`❌ Invalid AV1 CRF value: ${args[i + 1]}. Must be 0-63`);
|
||||
process.exit(1);
|
||||
}
|
||||
i++; // Skip next arg
|
||||
} else if (args[i] === '--accel' || args[i] === '--hardware') {
|
||||
const acc = args[i + 1];
|
||||
const allowed: HardwareAccelerationOption[] = ['auto', 'nvenc', 'qsv', 'amf', 'cpu'];
|
||||
if (!allowed.includes(acc as HardwareAccelerationOption)) {
|
||||
console.error(`❌ Invalid accelerator: ${acc}. Valid: auto, nvenc, qsv, amf, cpu`);
|
||||
process.exit(1);
|
||||
}
|
||||
accelerator = acc as HardwareAccelerationOption;
|
||||
i++;
|
||||
} 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: create-vod <input-video> [output-dir] [options]');
|
||||
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: both)');
|
||||
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
|
||||
console.error(' --accel <type> Hardware accelerator: auto|nvenc|qsv|amf|cpu (default: auto)');
|
||||
console.error('\nQuality Options (override defaults):');
|
||||
console.error(' --h264-cq <value> H.264 GPU CQ value (0-51, lower = better, default: auto)');
|
||||
console.error(' --h264-crf <value> H.264 CPU CRF value (0-51, lower = better, default: auto)');
|
||||
console.error(' --av1-cq <value> AV1 GPU CQ value (0-51, lower = better, default: auto)');
|
||||
console.error(' --av1-crf <value> AV1 CPU CRF value (0-63, lower = better, default: auto)');
|
||||
console.error('\nExamples:');
|
||||
console.error(' create-vod video.mp4');
|
||||
console.error(' create-vod video.mp4 ./output');
|
||||
console.error(' create-vod video.mp4 -r 360,480,720');
|
||||
console.error(' create-vod video.mp4 -c av1 --av1-cq 40');
|
||||
console.error(' create-vod video.mp4 -c dual --h264-cq 30 --av1-cq 39');
|
||||
console.error(' create-vod video.mp4 -f hls');
|
||||
console.error(' create-vod video.mp4 -c dual -f both');
|
||||
console.error(' create-vod video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash');
|
||||
console.error(' create-vod video.mp4 -p 00:00:05');
|
||||
console.error(' create-vod video.mp4 ./output -r 720,1080 -c dual -f both -p 10 --h264-cq 28 --av1-cq 37');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('🔍 Checking system...\n');
|
||||
|
||||
const hasFFmpeg = await checkFFmpeg();
|
||||
const hasMP4Box = await checkMP4Box();
|
||||
const hwEncoders = await detectHardwareEncoders();
|
||||
|
||||
const accelPriority: Record<string, number> = {
|
||||
nvenc: 100,
|
||||
qsv: 90,
|
||||
amf: 80,
|
||||
vaapi: 70,
|
||||
videotoolbox: 65,
|
||||
v4l2: 60
|
||||
};
|
||||
|
||||
const bestAccel = hwEncoders
|
||||
.slice()
|
||||
.sort((a, b) => (accelPriority[b.accelerator] || 0) - (accelPriority[a.accelerator] || 0))[0];
|
||||
|
||||
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
|
||||
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}`);
|
||||
const accelList = Array.from(new Set(hwEncoders.map(e => e.accelerator.toUpperCase())));
|
||||
const bestAccelName = bestAccel ? bestAccel.accelerator.toUpperCase() : undefined;
|
||||
const accelRest = accelList.filter(name => name !== bestAccelName);
|
||||
const accelLabel = bestAccelName
|
||||
? `✅ ${bestAccelName}${accelRest.length > 0 ? ` (${accelRest.join(', ')})` : ''}`
|
||||
: '❌';
|
||||
console.log(`Hardware: ${accelLabel}`);
|
||||
console.log('');
|
||||
|
||||
if (!hasFFmpeg) {
|
||||
console.error('❌ FFmpeg not found. Please install FFmpeg first.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!hasMP4Box) {
|
||||
console.error('❌ MP4Box not found. Please install: sudo pacman -S gpac');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate codec selection
|
||||
const hasAv1Hardware = hwEncoders.some(item => item.av1Encoder);
|
||||
|
||||
if ((codecType === 'av1' || codecType === 'dual') && !hasAv1Hardware) {
|
||||
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);
|
||||
const fileStats = statSync(input);
|
||||
const fileSizeMB = (fileStats.size / (1024 * 1024)).toFixed(2);
|
||||
|
||||
console.log('📹 Video Information:');
|
||||
console.log(` File: ${input}`);
|
||||
console.log(` Size: ${fileSizeMB} MB`);
|
||||
console.log(` Resolution: ${metadata.width}x${metadata.height}`);
|
||||
console.log(` FPS: ${metadata.fps.toFixed(2)}`);
|
||||
console.log(` Duration: ${Math.floor(metadata.duration / 60)}m ${Math.floor(metadata.duration % 60)}s`);
|
||||
console.log(` Codec: ${metadata.codec}`);
|
||||
if (metadata.videoBitrate) {
|
||||
console.log(` Video Bitrate: ${(metadata.videoBitrate / 1000).toFixed(2)} Mbps`);
|
||||
}
|
||||
if (metadata.audioBitrate) {
|
||||
console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`);
|
||||
}
|
||||
|
||||
// Pre-calc profiles for display (match internal selection logic)
|
||||
let displayProfiles: string[] = [];
|
||||
if (customProfiles && customProfiles.length > 0) {
|
||||
const profileResult = createProfilesFromStrings(
|
||||
customProfiles,
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
metadata.fps,
|
||||
metadata.videoBitrate
|
||||
);
|
||||
|
||||
if (profileResult.errors.length > 0) {
|
||||
console.error('\n❌ Profile errors:');
|
||||
profileResult.errors.forEach(err => console.error(` - ${err}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (profileResult.warnings.length > 0) {
|
||||
console.warn('\n⚠️ Profile warnings:');
|
||||
profileResult.warnings.forEach(warn => console.warn(` - ${warn}`));
|
||||
}
|
||||
|
||||
displayProfiles = profileResult.profiles.map(p => p.name);
|
||||
} else {
|
||||
const autoProfiles = selectProfiles(
|
||||
metadata.width,
|
||||
metadata.height,
|
||||
metadata.fps,
|
||||
metadata.videoBitrate
|
||||
);
|
||||
displayProfiles = autoProfiles.map(p => p.name);
|
||||
}
|
||||
|
||||
const manifestDesc =
|
||||
formatType === 'both' ? 'DASH (manifest.mpd), HLS (master.m3u8)' :
|
||||
formatType === 'dash' ? 'DASH (manifest.mpd)' : 'HLS (master.m3u8)';
|
||||
|
||||
const thumbnailsPlanned = true;
|
||||
const posterPlanned = posterTimecode || '00:00:00';
|
||||
|
||||
console.log('\n📦 Parameters:');
|
||||
console.log(` Input: ${input}`);
|
||||
console.log(` Output: ${outputDir}`);
|
||||
console.log(` Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
|
||||
console.log(` Profiles: ${displayProfiles.join(', ')}`);
|
||||
console.log(` Manifests: ${manifestDesc}`);
|
||||
console.log(` Poster: ${posterPlanned} (will be generated)`);
|
||||
console.log(` Thumbnails: ${thumbnailsPlanned ? 'yes (with VTT)' : 'no'}`);
|
||||
console.log(` Accelerator: ${bestAccel ? bestAccel.accelerator.toUpperCase() : 'CPU'}`);
|
||||
|
||||
// Build quality settings if any are specified
|
||||
let quality: QualitySettings | undefined;
|
||||
if (h264CQ !== undefined || h264CRF !== undefined || av1CQ !== undefined || av1CRF !== undefined) {
|
||||
quality = {};
|
||||
|
||||
if (h264CQ !== undefined || h264CRF !== undefined) {
|
||||
quality.h264 = {};
|
||||
if (h264CQ !== undefined) quality.h264.cq = h264CQ;
|
||||
if (h264CRF !== undefined) quality.h264.crf = h264CRF;
|
||||
console.log(`🎚️ H.264 Quality: ${h264CQ !== undefined ? `CQ ${h264CQ}` : ''}${h264CRF !== undefined ? ` CRF ${h264CRF}` : ''}`);
|
||||
}
|
||||
|
||||
if (av1CQ !== undefined || av1CRF !== undefined) {
|
||||
quality.av1 = {};
|
||||
if (av1CQ !== undefined) quality.av1.cq = av1CQ;
|
||||
if (av1CRF !== undefined) quality.av1.crf = av1CRF;
|
||||
console.log(`🎚️ AV1 Quality: ${av1CQ !== undefined ? `CQ ${av1CQ}` : ''}${av1CRF !== undefined ? ` CRF ${av1CRF}` : ''}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n🚀 Starting conversion...\n');
|
||||
|
||||
// Create multibar container
|
||||
const multibar = new cliProgress.MultiBar({
|
||||
format: '{stage} | {bar} | {percentage}% | {name}',
|
||||
barCompleteChar: '█',
|
||||
barIncompleteChar: '░',
|
||||
hideCursor: true,
|
||||
clearOnComplete: false,
|
||||
stopOnComplete: true
|
||||
}, cliProgress.Presets.shades_classic);
|
||||
|
||||
// Track progress bars for each profile
|
||||
const bars: Record<string, any> = {};
|
||||
let overallBar: any = null;
|
||||
|
||||
try {
|
||||
const result = await convertToDash({
|
||||
input,
|
||||
outputDir,
|
||||
customProfiles,
|
||||
posterTimecode,
|
||||
codec: codecType,
|
||||
format: formatType,
|
||||
segmentDuration: 2,
|
||||
hardwareAccelerator: accelerator,
|
||||
quality,
|
||||
generateThumbnails: true,
|
||||
generatePoster: true,
|
||||
parallel: true,
|
||||
onProgress: (progress) => {
|
||||
const stageName = progress.stage === 'encoding' ? 'Encoding' :
|
||||
progress.stage === 'thumbnails' ? 'Thumbnails' :
|
||||
progress.stage === 'manifest' ? 'Manifest' :
|
||||
progress.stage === 'analyzing' ? 'Analyzing' : 'Complete';
|
||||
|
||||
// Stage 1: Encoding - show individual profile bars
|
||||
if (progress.stage === 'encoding' && progress.currentProfile) {
|
||||
if (!bars[progress.currentProfile]) {
|
||||
bars[progress.currentProfile] = multibar.create(100, 0, {
|
||||
stage: 'Encode',
|
||||
name: progress.currentProfile
|
||||
});
|
||||
}
|
||||
// Use profilePercent (0-100) for individual bars, not overall percent
|
||||
const profileProgress = progress.profilePercent ?? progress.percent;
|
||||
bars[progress.currentProfile].update(profileProgress, {
|
||||
stage: 'Encode',
|
||||
name: progress.currentProfile
|
||||
});
|
||||
}
|
||||
|
||||
// Overall progress bar
|
||||
if (!overallBar) {
|
||||
overallBar = multibar.create(100, 0, {
|
||||
stage: stageName,
|
||||
name: 'Overall'
|
||||
});
|
||||
}
|
||||
|
||||
overallBar.update(progress.percent, {
|
||||
stage: stageName,
|
||||
name: progress.message || 'Overall'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
multibar.stop();
|
||||
|
||||
console.log('\n✅ Conversion completed successfully!\n');
|
||||
|
||||
} catch (error) {
|
||||
multibar.stop();
|
||||
console.error('\n\n❌ Error during conversion:');
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
}
|
||||
274
src/config/profiles.ts
Normal file
274
src/config/profiles.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { VideoProfile } from '../types';
|
||||
|
||||
/**
|
||||
* Get optimal BPP (Bits Per Pixel) based on resolution
|
||||
* Lower resolutions need higher BPP for good quality
|
||||
* Higher resolutions can use lower BPP due to more pixels
|
||||
*/
|
||||
function getBPP(width: number, height: number): number {
|
||||
const pixels = width * height;
|
||||
|
||||
if (pixels <= 640 * 360) return 0.08; // 360p - higher quality needed
|
||||
if (pixels <= 854 * 480) return 0.075; // 480p
|
||||
if (pixels <= 1280 * 720) return 0.07; // 720p
|
||||
if (pixels <= 1920 * 1080) return 0.065; // 1080p
|
||||
if (pixels <= 2560 * 1440) return 0.06; // 1440p (2K)
|
||||
return 0.055; // 4K - lower BPP but still quality
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate optimal video bitrate based on resolution and FPS
|
||||
* Formula: width × height × fps × bpp
|
||||
*/
|
||||
function calculateBitrate(
|
||||
width: number,
|
||||
height: number,
|
||||
fps: number = 30,
|
||||
maxBitrate?: number
|
||||
): string {
|
||||
const bpp = getBPP(width, height);
|
||||
let bitrate = Math.round((width * height * fps * bpp) / 1000);
|
||||
|
||||
// Don't exceed source bitrate (no point in upscaling quality)
|
||||
if (maxBitrate && bitrate > maxBitrate) {
|
||||
bitrate = maxBitrate;
|
||||
}
|
||||
|
||||
return `${bitrate}k`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default video quality profiles for 30 FPS
|
||||
*/
|
||||
export const DEFAULT_PROFILES: VideoProfile[] = [
|
||||
{
|
||||
name: '360p',
|
||||
width: 640,
|
||||
height: 360,
|
||||
videoBitrate: calculateBitrate(640, 360, 30),
|
||||
audioBitrate: '192k'
|
||||
},
|
||||
{
|
||||
name: '480p',
|
||||
width: 854,
|
||||
height: 480,
|
||||
videoBitrate: calculateBitrate(854, 480, 30),
|
||||
audioBitrate: '192k'
|
||||
},
|
||||
{
|
||||
name: '720p',
|
||||
width: 1280,
|
||||
height: 720,
|
||||
videoBitrate: calculateBitrate(1280, 720, 30),
|
||||
audioBitrate: '192k'
|
||||
},
|
||||
{
|
||||
name: '1080p',
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
videoBitrate: calculateBitrate(1920, 1080, 30),
|
||||
audioBitrate: '256k'
|
||||
},
|
||||
{
|
||||
name: '1440p',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
videoBitrate: calculateBitrate(2560, 1440, 30),
|
||||
audioBitrate: '256k'
|
||||
},
|
||||
{
|
||||
name: '2160p',
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
videoBitrate: calculateBitrate(3840, 2160, 30),
|
||||
audioBitrate: '256k'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Select appropriate profiles based on input video resolution
|
||||
* Only creates profiles that are equal to or smaller than input resolution
|
||||
* Always generates 30 FPS profiles by default
|
||||
* For high FPS (>30), user must explicitly specify in customProfiles
|
||||
*/
|
||||
export function selectProfiles(
|
||||
inputWidth: number,
|
||||
inputHeight: number,
|
||||
inputFPS: number = 30,
|
||||
sourceBitrate?: number
|
||||
): VideoProfile[] {
|
||||
const profiles: VideoProfile[] = [];
|
||||
|
||||
// Standard 30 FPS profiles (always created)
|
||||
const baseProfiles = DEFAULT_PROFILES.filter(profile => {
|
||||
return profile.width <= inputWidth && profile.height <= inputHeight;
|
||||
});
|
||||
|
||||
// Add standard 30fps profiles with bitrate limit
|
||||
for (const profile of baseProfiles) {
|
||||
profiles.push({
|
||||
...profile,
|
||||
videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate),
|
||||
fps: 30
|
||||
});
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create high FPS profile variant
|
||||
* Used for creating 60fps, 90fps, 120fps versions
|
||||
*/
|
||||
export function createHighFPSProfile(
|
||||
baseProfile: VideoProfile,
|
||||
fps: number,
|
||||
maxBitrate?: number
|
||||
): VideoProfile {
|
||||
return {
|
||||
...baseProfile,
|
||||
name: `${baseProfile.name}-${fps}`,
|
||||
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 };
|
||||
}
|
||||
|
||||
527
src/core/converter.ts
Normal file
527
src/core/converter.ts
Normal file
@@ -0,0 +1,527 @@
|
||||
import { join, basename, extname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import type {
|
||||
DashConvertOptions,
|
||||
DashConvertResult,
|
||||
VideoProfile,
|
||||
ThumbnailConfig,
|
||||
ConversionProgress,
|
||||
CodecType,
|
||||
StreamingFormat,
|
||||
HardwareAccelerationOption,
|
||||
HardwareAccelerator,
|
||||
HardwareEncoderInfo
|
||||
} from '../types';
|
||||
import {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
getVideoMetadata,
|
||||
ensureDir,
|
||||
setLogFile,
|
||||
detectHardwareEncoders
|
||||
} from '../utils';
|
||||
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
|
||||
import { generateThumbnailSprite, generatePoster } from './thumbnails';
|
||||
import { encodeProfilesToMP4 } from './encoding';
|
||||
import { packageToFormats } from './packaging';
|
||||
|
||||
/**
|
||||
* Convert video to DASH format with NVENC acceleration
|
||||
* Two-stage approach: FFmpeg encoding → MP4Box packaging
|
||||
*/
|
||||
export async function convertToDash(
|
||||
options: DashConvertOptions
|
||||
): Promise<DashConvertResult> {
|
||||
const {
|
||||
input,
|
||||
outputDir,
|
||||
segmentDuration = 2,
|
||||
profiles: userProfiles,
|
||||
customProfiles,
|
||||
codec = 'dual',
|
||||
format = 'both',
|
||||
useNvenc,
|
||||
hardwareAccelerator,
|
||||
quality,
|
||||
generateThumbnails = true,
|
||||
thumbnailConfig = {},
|
||||
generatePoster: shouldGeneratePoster = true,
|
||||
posterTimecode = '00:00:00',
|
||||
parallel = true,
|
||||
onProgress
|
||||
} = options;
|
||||
|
||||
// Create unique temp directory
|
||||
const tempDir = join('/tmp', `dash-converter-${randomUUID()}`);
|
||||
await ensureDir(tempDir);
|
||||
|
||||
// Create video output directory and initialize logging
|
||||
const videoName = basename(input, extname(input));
|
||||
const videoOutputDir = join(outputDir, videoName);
|
||||
await ensureDir(videoOutputDir);
|
||||
|
||||
// Initialize log file
|
||||
const logFile = join(videoOutputDir, 'conversion.log');
|
||||
setLogFile(logFile);
|
||||
|
||||
// Write log header
|
||||
const { writeFile } = await import('node:fs/promises');
|
||||
const header = `===========================================
|
||||
DASH Conversion Log
|
||||
Started: ${new Date().toISOString()}
|
||||
Input: ${input}
|
||||
Output: ${videoOutputDir}
|
||||
Codec: ${codec}
|
||||
Format: ${format}
|
||||
===========================================\n`;
|
||||
await writeFile(logFile, header, 'utf-8');
|
||||
|
||||
try {
|
||||
return await convertToDashInternal(
|
||||
input,
|
||||
outputDir,
|
||||
tempDir,
|
||||
segmentDuration,
|
||||
userProfiles,
|
||||
customProfiles,
|
||||
codec,
|
||||
format,
|
||||
useNvenc,
|
||||
hardwareAccelerator,
|
||||
quality,
|
||||
generateThumbnails,
|
||||
thumbnailConfig,
|
||||
shouldGeneratePoster,
|
||||
posterTimecode,
|
||||
parallel,
|
||||
onProgress
|
||||
);
|
||||
} finally {
|
||||
// Write completion to log
|
||||
const { appendFile } = await import('node:fs/promises');
|
||||
try {
|
||||
await appendFile(logFile, `\nCompleted: ${new Date().toISOString()}\n`, 'utf-8');
|
||||
} catch (err) {
|
||||
// Ignore log write errors
|
||||
}
|
||||
|
||||
// Cleanup temp directory
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Failed to cleanup temp directory: ${tempDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal conversion logic
|
||||
*/
|
||||
async function convertToDashInternal(
|
||||
input: string,
|
||||
outputDir: string,
|
||||
tempDir: string,
|
||||
segmentDuration: number,
|
||||
userProfiles: VideoProfile[] | undefined,
|
||||
customProfiles: string[] | undefined,
|
||||
codec: CodecType,
|
||||
format: StreamingFormat,
|
||||
useNvenc: boolean | undefined,
|
||||
hardwareAccelerator: HardwareAccelerationOption | undefined,
|
||||
quality: DashConvertOptions['quality'],
|
||||
generateThumbnails: boolean,
|
||||
thumbnailConfig: ThumbnailConfig,
|
||||
generatePosterFlag: boolean,
|
||||
posterTimecode: string,
|
||||
parallel: boolean,
|
||||
onProgress?: (progress: ConversionProgress) => void
|
||||
): Promise<DashConvertResult> {
|
||||
|
||||
// Validate dependencies
|
||||
if (!await checkFFmpeg()) {
|
||||
throw new Error('FFmpeg is not installed or not in PATH');
|
||||
}
|
||||
|
||||
if (!await checkMP4Box()) {
|
||||
throw new Error('MP4Box is not installed or not in PATH. Install gpac package.');
|
||||
}
|
||||
|
||||
// Report progress
|
||||
const reportProgress = (stage: ConversionProgress['stage'], percent: number, message?: string, currentProfile?: string) => {
|
||||
if (onProgress) {
|
||||
onProgress({ stage, percent, message, currentProfile });
|
||||
}
|
||||
};
|
||||
|
||||
reportProgress('analyzing', 0, 'Analyzing input video...');
|
||||
|
||||
// Get video metadata
|
||||
const metadata = await getVideoMetadata(input);
|
||||
const hasAudio = metadata.hasAudio;
|
||||
|
||||
// Determine hardware accelerator (auto by default)
|
||||
const preferredAccelerator: HardwareAccelerationOption =
|
||||
hardwareAccelerator && hardwareAccelerator !== 'auto'
|
||||
? hardwareAccelerator
|
||||
: useNvenc === true
|
||||
? 'nvenc'
|
||||
: useNvenc === false
|
||||
? 'cpu'
|
||||
: 'auto';
|
||||
|
||||
const hardwareEncoders = await detectHardwareEncoders();
|
||||
|
||||
const { selected, h264Encoder, av1Encoder, warnings: accelWarnings } = selectHardwareEncoders(
|
||||
hardwareEncoders,
|
||||
preferredAccelerator,
|
||||
codec
|
||||
);
|
||||
|
||||
if (accelWarnings.length > 0) {
|
||||
for (const warn of accelWarnings) {
|
||||
console.warn(`⚠️ ${warn}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Select profiles
|
||||
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');
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// 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 = h264Encoder || 'libx264';
|
||||
const h264Preset = resolvePresetForEncoder(h264Codec, 'h264');
|
||||
codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset });
|
||||
}
|
||||
|
||||
if (codec === 'av1' || codec === 'dual') {
|
||||
const av1Codec = av1Encoder || 'libsvtav1';
|
||||
const av1Preset = resolvePresetForEncoder(av1Codec, 'av1');
|
||||
codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset });
|
||||
}
|
||||
|
||||
const codecNames = codecs.map(c => c.type.toUpperCase()).join(' + ');
|
||||
const accelLabel = selected === 'cpu' ? 'CPU' : selected.toUpperCase();
|
||||
reportProgress('analyzing', 20, `Using ${codecNames} encoding (${accelLabel})`, undefined);
|
||||
|
||||
const maxConcurrent = selected === 'cpu' ? 2 : 3;
|
||||
|
||||
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
|
||||
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
|
||||
|
||||
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;
|
||||
|
||||
reportProgress('encoding', 25 + codecProgress * 40, `Stage 1: Encoding ${type.toUpperCase()} (${profiles.length} profiles)...`);
|
||||
|
||||
// Get quality settings for this codec
|
||||
const codecQuality = type === 'h264' ? quality?.h264 : quality?.av1;
|
||||
|
||||
const tempMP4Paths = await encodeProfilesToMP4(
|
||||
input,
|
||||
tempDir,
|
||||
profiles,
|
||||
videoCodec,
|
||||
codecPreset,
|
||||
metadata.duration,
|
||||
segmentDuration,
|
||||
metadata.audioBitrate,
|
||||
parallel,
|
||||
maxConcurrent,
|
||||
type, // Pass codec type to differentiate output files
|
||||
codecQuality, // Pass quality settings (CQ/CRF)
|
||||
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}...`
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
codecMP4Paths.set(type, tempMP4Paths);
|
||||
}
|
||||
|
||||
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
|
||||
|
||||
// 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,
|
||||
codec,
|
||||
format,
|
||||
hasAudio
|
||||
);
|
||||
|
||||
// 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: All formats packaged');
|
||||
|
||||
// Generate thumbnails
|
||||
let thumbnailSpritePath: string | undefined;
|
||||
let thumbnailVttPath: string | undefined;
|
||||
|
||||
if (generateThumbnails) {
|
||||
reportProgress('thumbnails', 80, 'Generating thumbnail sprites...');
|
||||
|
||||
const thumbConfig: Required<ThumbnailConfig> = {
|
||||
width: thumbnailConfig.width || 160,
|
||||
height: thumbnailConfig.height || 90,
|
||||
interval: thumbnailConfig.interval || 1, // 1 секунда по умолчанию
|
||||
columns: thumbnailConfig.columns || 10
|
||||
};
|
||||
|
||||
const thumbResult = await generateThumbnailSprite(
|
||||
input,
|
||||
videoOutputDir,
|
||||
metadata.duration,
|
||||
thumbConfig
|
||||
);
|
||||
|
||||
thumbnailSpritePath = thumbResult.spritePath;
|
||||
thumbnailVttPath = thumbResult.vttPath;
|
||||
|
||||
reportProgress('thumbnails', 90, 'Thumbnails generated');
|
||||
}
|
||||
|
||||
// Generate poster
|
||||
let posterPath: string | undefined;
|
||||
|
||||
if (generatePosterFlag) {
|
||||
reportProgress('thumbnails', 92, 'Generating poster image...');
|
||||
|
||||
posterPath = await generatePoster(
|
||||
input,
|
||||
videoOutputDir,
|
||||
posterTimecode
|
||||
);
|
||||
|
||||
reportProgress('thumbnails', 95, 'Poster generated');
|
||||
}
|
||||
|
||||
// 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: codecs.some(c => c.codec.includes('nvenc')),
|
||||
selectedAccelerator: selected,
|
||||
codecType: codec,
|
||||
format
|
||||
};
|
||||
}
|
||||
|
||||
const ACCEL_PRIORITY: Record<HardwareAccelerator, number> = {
|
||||
nvenc: 100,
|
||||
qsv: 90,
|
||||
amf: 80,
|
||||
vaapi: 70,
|
||||
videotoolbox: 65,
|
||||
v4l2: 60,
|
||||
cpu: 1
|
||||
};
|
||||
|
||||
function selectHardwareEncoders(
|
||||
available: HardwareEncoderInfo[],
|
||||
preferred: HardwareAccelerationOption,
|
||||
codec: CodecType
|
||||
): {
|
||||
selected: HardwareAccelerator;
|
||||
h264Encoder?: string;
|
||||
av1Encoder?: string;
|
||||
warnings: string[];
|
||||
} {
|
||||
const needsH264 = codec === 'h264' || codec === 'dual';
|
||||
const needsAV1 = codec === 'av1' || codec === 'dual';
|
||||
const warnings: string[] = [];
|
||||
|
||||
const supportedForAuto = new Set<HardwareAccelerator>(['nvenc', 'qsv', 'amf']);
|
||||
const relevant = available.filter(info =>
|
||||
(needsH264 && info.h264Encoder) || (needsAV1 && info.av1Encoder)
|
||||
);
|
||||
const autoRelevant = relevant.filter(info => supportedForAuto.has(info.accelerator));
|
||||
|
||||
const pickByAccel = (acc: HardwareAccelerator) =>
|
||||
relevant.find(item => item.accelerator === acc);
|
||||
|
||||
let base: HardwareEncoderInfo | undefined;
|
||||
|
||||
if (preferred !== 'auto') {
|
||||
if (preferred === 'cpu') {
|
||||
base = undefined;
|
||||
} else if (!supportedForAuto.has(preferred)) {
|
||||
warnings.push(`Ускоритель "${preferred}" пока не поддерживается, использую CPU`);
|
||||
} else {
|
||||
base = pickByAccel(preferred);
|
||||
if (!base) {
|
||||
throw new Error(`Аппаратный ускоритель "${preferred}" недоступен в системе`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pool = autoRelevant.length > 0 ? autoRelevant : [];
|
||||
base = pool.sort(
|
||||
(a, b) => (ACCEL_PRIORITY[b.accelerator] || 0) - (ACCEL_PRIORITY[a.accelerator] || 0)
|
||||
)[0];
|
||||
|
||||
if (!base && relevant.length > 0) {
|
||||
warnings.push('Доступен аппаратный ускоритель, но он пока не поддерживается пайплайном, использую CPU');
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackPool = autoRelevant.length > 0 ? autoRelevant : [];
|
||||
const fallbackList = fallbackPool.sort(
|
||||
(a, b) => (ACCEL_PRIORITY[b.accelerator] || 0) - (ACCEL_PRIORITY[a.accelerator] || 0)
|
||||
);
|
||||
|
||||
const pickEncoder = (codecType: 'h264' | 'av1') => {
|
||||
const direct = codecType === 'h264' ? base?.h264Encoder : base?.av1Encoder;
|
||||
if (direct) return { encoder: direct, accel: base?.accelerator };
|
||||
|
||||
const alt = fallbackList.find(info => (codecType === 'h264' ? info.h264Encoder : info.av1Encoder));
|
||||
if (alt) {
|
||||
if (preferred !== 'auto' && base) {
|
||||
warnings.push(
|
||||
`Выбранный ускоритель "${base.accelerator}" не поддерживает ${codecType.toUpperCase()}, использую ${alt.accelerator}`
|
||||
);
|
||||
}
|
||||
return {
|
||||
encoder: codecType === 'h264' ? alt.h264Encoder! : alt.av1Encoder!,
|
||||
accel: alt.accelerator
|
||||
};
|
||||
}
|
||||
|
||||
if (preferred !== 'auto' && preferred !== 'cpu') {
|
||||
warnings.push(
|
||||
`Ускоритель "${preferred}" не поддерживает ${codecType.toUpperCase()}, использую CPU`
|
||||
);
|
||||
}
|
||||
|
||||
return { encoder: undefined, accel: 'cpu' as HardwareAccelerator };
|
||||
};
|
||||
|
||||
const h264Result = needsH264 ? pickEncoder('h264') : { encoder: undefined, accel: base?.accelerator };
|
||||
const av1Result = needsAV1 ? pickEncoder('av1') : { encoder: undefined, accel: base?.accelerator };
|
||||
|
||||
const selectedAccel = (base?.accelerator || h264Result.accel || av1Result.accel || 'cpu') as HardwareAccelerator;
|
||||
|
||||
return {
|
||||
selected: selectedAccel,
|
||||
h264Encoder: h264Result.encoder,
|
||||
av1Encoder: av1Result.encoder,
|
||||
warnings
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePresetForEncoder(encoder: string, codecType: 'h264' | 'av1'): string {
|
||||
if (encoder.includes('nvenc')) return 'p4';
|
||||
if (encoder.includes('qsv')) return 'medium';
|
||||
if (encoder.includes('amf')) return 'balanced';
|
||||
if (encoder.includes('vaapi')) return '5';
|
||||
if (encoder.includes('videotoolbox')) return 'medium';
|
||||
if (encoder.includes('v4l2')) return 'medium';
|
||||
|
||||
// CPU fallback presets
|
||||
if (encoder === 'libsvtav1') return '8';
|
||||
if (encoder === 'libx264') return 'medium';
|
||||
|
||||
// Default safe preset
|
||||
return codecType === 'av1' ? '8' : 'medium';
|
||||
}
|
||||
261
src/core/encoding.ts
Normal file
261
src/core/encoding.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { join } from 'node:path';
|
||||
import { execFFmpeg, selectAudioBitrate } from '../utils';
|
||||
import type { VideoProfile, VideoOptimizations, CodecQualitySettings } from '../types';
|
||||
|
||||
/**
|
||||
* Get default CQ/CRF value based on resolution and codec
|
||||
*/
|
||||
function getDefaultQuality(height: number, codecType: 'h264' | 'av1', isGPU: boolean): number {
|
||||
if (isGPU) {
|
||||
// GPU encoders use CQ - ФИКСИРОВАННЫЕ ЗНАЧЕНИЯ ДЛЯ ТЕСТИРОВАНИЯ
|
||||
if (codecType === 'h264') {
|
||||
// H.264 NVENC CQ = 32 (для всех разрешений)
|
||||
return 32;
|
||||
} else {
|
||||
// AV1 NVENC CQ = 42 (для всех разрешений)
|
||||
return 42;
|
||||
}
|
||||
} else {
|
||||
// CPU encoders use CRF
|
||||
if (codecType === 'h264') {
|
||||
// libx264 CRF (на ~3-5 ниже чем NVENC CQ)
|
||||
if (height <= 360) return 25;
|
||||
if (height <= 480) return 24;
|
||||
if (height <= 720) return 23;
|
||||
if (height <= 1080) return 22;
|
||||
if (height <= 1440) return 21;
|
||||
return 20; // 4K
|
||||
} else {
|
||||
// libsvtav1 CRF (шкала 0-63, на ~20% выше чем NVENC CQ)
|
||||
if (height <= 360) return 40;
|
||||
if (height <= 480) return 38;
|
||||
if (height <= 720) return 35;
|
||||
if (height <= 1080) return 32;
|
||||
if (height <= 1440) return 30;
|
||||
return 28; // 4K
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode single profile to MP4
|
||||
* Stage 1: Heavy work - video encoding with optional optimizations
|
||||
*/
|
||||
export async function encodeProfileToMP4(
|
||||
input: string,
|
||||
tempDir: string,
|
||||
profile: VideoProfile,
|
||||
videoCodec: string,
|
||||
preset: string,
|
||||
duration: number,
|
||||
segmentDuration: number,
|
||||
sourceAudioBitrate: number | undefined,
|
||||
codecType: 'h264' | 'av1',
|
||||
qualitySettings?: CodecQualitySettings,
|
||||
optimizations?: VideoOptimizations,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<string> {
|
||||
const outputPath = join(tempDir, `video_${codecType}_${profile.name}.mp4`);
|
||||
|
||||
const args = [
|
||||
'-y',
|
||||
'-i', input,
|
||||
'-c:v', videoCodec
|
||||
];
|
||||
|
||||
// Determine if using GPU or CPU encoder
|
||||
const isGPU = videoCodec.includes('nvenc') || videoCodec.includes('qsv') || videoCodec.includes('amf') || videoCodec.includes('vaapi') || videoCodec.includes('videotoolbox') || videoCodec.includes('v4l2');
|
||||
|
||||
// Determine quality value (CQ for GPU, CRF for CPU)
|
||||
let qualityValue: number;
|
||||
if (isGPU && qualitySettings?.cq !== undefined) {
|
||||
qualityValue = qualitySettings.cq;
|
||||
} else if (!isGPU && qualitySettings?.crf !== undefined) {
|
||||
qualityValue = qualitySettings.crf;
|
||||
} else {
|
||||
// Use default quality based on resolution
|
||||
qualityValue = getDefaultQuality(profile.height, codecType, isGPU);
|
||||
}
|
||||
|
||||
// Add codec-specific options with CQ/CRF
|
||||
if (videoCodec === 'h264_nvenc') {
|
||||
// NVIDIA H.264 with CQ
|
||||
args.push('-rc:v', 'vbr');
|
||||
args.push('-cq', String(qualityValue));
|
||||
args.push('-preset', preset);
|
||||
args.push('-2pass', '0');
|
||||
} else if (videoCodec === 'av1_nvenc') {
|
||||
// NVIDIA AV1 with CQ
|
||||
args.push('-rc:v', 'vbr');
|
||||
args.push('-cq', String(qualityValue));
|
||||
args.push('-preset', preset);
|
||||
args.push('-2pass', '0');
|
||||
} else if (videoCodec === 'av1_qsv') {
|
||||
// Intel QSV AV1
|
||||
args.push('-preset', preset);
|
||||
args.push('-global_quality', String(qualityValue));
|
||||
} else if (videoCodec === 'h264_qsv') {
|
||||
// Intel QSV H.264
|
||||
args.push('-preset', preset);
|
||||
args.push('-global_quality', String(qualityValue));
|
||||
} else if (videoCodec === 'av1_amf') {
|
||||
// AMD AMF AV1
|
||||
args.push('-quality', 'balanced');
|
||||
args.push('-rc', 'cqp');
|
||||
args.push('-qp_i', String(qualityValue));
|
||||
args.push('-qp_p', String(qualityValue));
|
||||
} else if (videoCodec === 'h264_amf') {
|
||||
// AMD AMF H.264
|
||||
args.push('-quality', 'balanced');
|
||||
args.push('-rc', 'cqp');
|
||||
args.push('-qp_i', String(qualityValue));
|
||||
args.push('-qp_p', String(qualityValue));
|
||||
} else if (videoCodec === 'libsvtav1') {
|
||||
// CPU-based SVT-AV1 with CRF
|
||||
args.push('-crf', String(qualityValue));
|
||||
args.push('-preset', preset); // 0-13, 8 is medium speed
|
||||
args.push('-svtav1-params', 'tune=0:enable-overlays=1');
|
||||
} else if (videoCodec === 'libx264') {
|
||||
// CPU-based x264 with CRF
|
||||
args.push('-crf', String(qualityValue));
|
||||
args.push('-preset', preset);
|
||||
} else {
|
||||
// Default fallback
|
||||
args.push('-preset', preset);
|
||||
}
|
||||
|
||||
// Add maxrate as safety limit (optional but recommended for streaming)
|
||||
// This prevents extreme bitrate spikes on complex scenes
|
||||
const bitrateMultiplier = codecType === 'av1' ? 0.6 : 1.0;
|
||||
const maxBitrate = Math.round(parseInt(profile.videoBitrate) * bitrateMultiplier * 1.5); // +50% headroom
|
||||
args.push('-maxrate', `${maxBitrate}k`);
|
||||
args.push('-bufsize', `${maxBitrate * 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)
|
||||
'-keyint_min', String(gopSize), // Minimum interval between keyframes
|
||||
'-sc_threshold', '0' // Disable scene change detection (keeps GOP consistent)
|
||||
);
|
||||
|
||||
// Build video filter chain
|
||||
const filters: string[] = [`scale=${profile.width}:${profile.height}`];
|
||||
|
||||
// Apply optimizations (for future use)
|
||||
if (optimizations) {
|
||||
if (optimizations.deinterlace) {
|
||||
filters.push('yadif');
|
||||
}
|
||||
if (optimizations.denoise) {
|
||||
filters.push('hqdn3d');
|
||||
}
|
||||
if (optimizations.customFilters) {
|
||||
filters.push(...optimizations.customFilters);
|
||||
}
|
||||
}
|
||||
|
||||
args.push('-vf', filters.join(','));
|
||||
|
||||
// Audio encoding
|
||||
// Select optimal bitrate based on source (don't upscale)
|
||||
const targetAudioBitrate = parseInt(profile.audioBitrate) || 256;
|
||||
const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate);
|
||||
args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate);
|
||||
|
||||
// Audio optimizations
|
||||
if (optimizations?.audioNormalize) {
|
||||
args.push('-af', 'loudnorm');
|
||||
}
|
||||
|
||||
// Output
|
||||
args.push('-f', 'mp4', outputPath);
|
||||
|
||||
await execFFmpeg(args, onProgress, duration);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode all profiles to MP4 (parallel or sequential)
|
||||
* Stage 1: Main encoding work
|
||||
*/
|
||||
export async function encodeProfilesToMP4(
|
||||
input: string,
|
||||
tempDir: string,
|
||||
profiles: VideoProfile[],
|
||||
videoCodec: string,
|
||||
preset: string,
|
||||
duration: number,
|
||||
segmentDuration: number,
|
||||
sourceAudioBitrate: number | undefined,
|
||||
parallel: boolean,
|
||||
maxConcurrent: number,
|
||||
codecType: 'h264' | 'av1',
|
||||
qualitySettings?: CodecQualitySettings,
|
||||
optimizations?: VideoOptimizations,
|
||||
onProgress?: (profileName: string, percent: number) => void
|
||||
): Promise<Map<string, string>> {
|
||||
const mp4Files = new Map<string, string>();
|
||||
|
||||
if (parallel && profiles.length > 1) {
|
||||
// Parallel encoding with batching
|
||||
for (let i = 0; i < profiles.length; i += maxConcurrent) {
|
||||
const batch = profiles.slice(i, i + maxConcurrent);
|
||||
const batchPromises = batch.map((profile) =>
|
||||
encodeProfileToMP4(
|
||||
input,
|
||||
tempDir,
|
||||
profile,
|
||||
videoCodec,
|
||||
preset,
|
||||
duration,
|
||||
segmentDuration,
|
||||
sourceAudioBitrate,
|
||||
codecType,
|
||||
qualitySettings,
|
||||
optimizations,
|
||||
(percent) => {
|
||||
if (onProgress) {
|
||||
onProgress(profile.name, percent);
|
||||
}
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const batchResults = await Promise.all(batchPromises);
|
||||
batchResults.forEach((mp4Path, idx) => {
|
||||
const profile = batch[idx];
|
||||
mp4Files.set(profile.name, mp4Path);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Sequential encoding
|
||||
for (const profile of profiles) {
|
||||
const mp4Path = await encodeProfileToMP4(
|
||||
input,
|
||||
tempDir,
|
||||
profile,
|
||||
videoCodec,
|
||||
preset,
|
||||
duration,
|
||||
segmentDuration,
|
||||
sourceAudioBitrate,
|
||||
codecType,
|
||||
qualitySettings,
|
||||
optimizations,
|
||||
(percent) => {
|
||||
if (onProgress) {
|
||||
onProgress(profile.name, percent);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
mp4Files.set(profile.name, mp4Path);
|
||||
}
|
||||
}
|
||||
|
||||
return mp4Files;
|
||||
}
|
||||
256
src/core/manifest.ts
Normal file
256
src/core/manifest.ts
Normal file
@@ -0,0 +1,256 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import type { VideoProfile, CodecType } from '../types';
|
||||
|
||||
/**
|
||||
* DASH MPD Manifest Generator
|
||||
* Handles creation and manipulation of MPEG-DASH manifests
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validate and fix MPD manifest XML structure
|
||||
* Ensures all Representation tags are properly closed
|
||||
*/
|
||||
export async function validateAndFixManifest(manifestPath: string): Promise<void> {
|
||||
let mpd = await readFile(manifestPath, 'utf-8');
|
||||
|
||||
// Fix 1: Remove double slashes in self-closing tags: "//> → "/>
|
||||
mpd = mpd.replace(/\/\/>/g, '/>');
|
||||
|
||||
// Fix 2: Fix malformed self-closing tags with extra space: "/ /> → "/>
|
||||
mpd = mpd.replace(/\/\s+\/>/g, '/>');
|
||||
|
||||
// Fix 3: Normalize Representation self-closing tags - remove extra spaces before />
|
||||
mpd = mpd.replace(/(<Representation[^>]+)\s+\/>/g, '$1/>');
|
||||
|
||||
// Fix 4: Remove orphaned closing tags after self-closing Representation tags
|
||||
mpd = mpd.replace(/<Representation\s+([^>]+)\/>\s*<\/Representation>/g, '<Representation $1/>');
|
||||
|
||||
// Fix 5: Convert self-closing Representation tags that have child elements to properly opened tags
|
||||
mpd = mpd.replace(
|
||||
/<Representation\s+([^>]+)\/>\s*(<AudioChannelConfiguration[^>]*\/>)/g,
|
||||
'<Representation $1>\n $2\n </Representation>'
|
||||
);
|
||||
|
||||
// Fix 6: Convert unclosed Representation tags to self-closing (if no children)
|
||||
mpd = mpd.replace(
|
||||
/<Representation\s+([^>]+)>\s*(?=<(?:Representation|\/AdaptationSet))/g,
|
||||
'<Representation $1/>\n'
|
||||
);
|
||||
|
||||
await writeFile(manifestPath, mpd, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update MPD manifest paths to reflect subdirectory structure
|
||||
* Changes: $RepresentationID$_$Number$ → $RepresentationID$/$RepresentationID$_$Number$
|
||||
*/
|
||||
export async function updateManifestPaths(
|
||||
manifestPath: string,
|
||||
profiles: VideoProfile[],
|
||||
codecType: CodecType
|
||||
): Promise<void> {
|
||||
let mpd = await readFile(manifestPath, 'utf-8');
|
||||
|
||||
// MP4Box uses $RepresentationID$ template variable
|
||||
// Replace: media="$RepresentationID$_$Number$.m4s"
|
||||
// With: media="$RepresentationID$/$RepresentationID$_$Number$.m4s"
|
||||
|
||||
mpd = mpd.replace(
|
||||
/media="\$RepresentationID\$_\$Number\$\.m4s"/g,
|
||||
'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"'
|
||||
);
|
||||
|
||||
// Replace: initialization="$RepresentationID$_.mp4"
|
||||
// With: initialization="$RepresentationID$/$RepresentationID$_.mp4"
|
||||
|
||||
mpd = mpd.replace(
|
||||
/initialization="\$RepresentationID\$_\.mp4"/g,
|
||||
'initialization="$RepresentationID$/$RepresentationID$_.mp4"'
|
||||
);
|
||||
|
||||
await writeFile(manifestPath, mpd, 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Separate H.264 and AV1 representations into different AdaptationSets
|
||||
* This allows DASH players to prefer AV1 when supported, with H.264 fallback
|
||||
*/
|
||||
export async function separateCodecAdaptationSets(manifestPath: string): Promise<void> {
|
||||
let mpd = await readFile(manifestPath, 'utf-8');
|
||||
|
||||
// Simple string-based approach: look for mixed codec patterns
|
||||
// Find patterns like: <Representation id="XXX-h264"... followed by <Representation id="YYY-av1"...
|
||||
|
||||
const lines = mpd.split('\n');
|
||||
const result: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Check if this is an AdaptationSet opening tag with video content
|
||||
if (line.includes('<AdaptationSet') && line.includes('maxWidth')) {
|
||||
// Start collecting this AdaptationSet
|
||||
const adaptationSetStart = i;
|
||||
let adaptationSetLines: string[] = [line];
|
||||
let segmentTemplateLines: string[] = [];
|
||||
let h264Reps: string[] = [];
|
||||
let av1Reps: string[] = [];
|
||||
let inSegmentTemplate = false;
|
||||
|
||||
i++;
|
||||
|
||||
// Collect all lines until closing </AdaptationSet>
|
||||
while (i < lines.length && !lines[i].includes('</AdaptationSet>')) {
|
||||
const currentLine = lines[i];
|
||||
|
||||
if (currentLine.includes('<SegmentTemplate')) {
|
||||
inSegmentTemplate = true;
|
||||
}
|
||||
|
||||
if (inSegmentTemplate) {
|
||||
segmentTemplateLines.push(currentLine);
|
||||
if (currentLine.includes('</SegmentTemplate>')) {
|
||||
inSegmentTemplate = false;
|
||||
}
|
||||
} else if (currentLine.includes('<Representation') && currentLine.includes('-h264')) {
|
||||
h264Reps.push(currentLine);
|
||||
} else if (currentLine.includes('<Representation') && currentLine.includes('-av1')) {
|
||||
av1Reps.push(currentLine);
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
// Check if we have both codecs
|
||||
if (h264Reps.length > 0 && av1Reps.length > 0) {
|
||||
// Split into two AdaptationSets
|
||||
|
||||
// H.264 AdaptationSet
|
||||
result.push(line); // Opening tag
|
||||
segmentTemplateLines.forEach(l => result.push(l));
|
||||
h264Reps.forEach(l => result.push(l));
|
||||
result.push(' </AdaptationSet>');
|
||||
|
||||
// AV1 AdaptationSet
|
||||
result.push(line); // Same opening tag
|
||||
segmentTemplateLines.forEach(l => result.push(l));
|
||||
av1Reps.forEach(l => result.push(l));
|
||||
result.push(' </AdaptationSet>');
|
||||
} else {
|
||||
// No mixed codecs, keep original
|
||||
result.push(line);
|
||||
for (let j = adaptationSetStart + 1; j < i; j++) {
|
||||
result.push(lines[j]);
|
||||
}
|
||||
result.push(lines[i]); // closing tag
|
||||
}
|
||||
|
||||
i++;
|
||||
} else {
|
||||
result.push(line);
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
await writeFile(manifestPath, result.join('\n'), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate MPD manifest from scratch (alternative to MP4Box)
|
||||
* TODO: Implement full MPD generation without external tools
|
||||
*/
|
||||
export async function generateMPDManifest(
|
||||
profiles: VideoProfile[],
|
||||
codecType: CodecType,
|
||||
duration: number,
|
||||
segmentDuration: number
|
||||
): Promise<string> {
|
||||
// TODO: Implement manual MPD generation
|
||||
// This will be used when we want full control over manifest
|
||||
throw new Error('Manual MPD generation not yet implemented. Use Bento4 or MP4Box for now.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update HLS master manifest to reflect subdirectory structure
|
||||
*/
|
||||
export async function updateHLSManifestPaths(
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS media playlist content
|
||||
*/
|
||||
export function generateHLSMediaPlaylist(
|
||||
segmentFiles: string[],
|
||||
initFile: string,
|
||||
segmentDuration: number
|
||||
): string {
|
||||
let content = '#EXTM3U\n';
|
||||
content += `#EXT-X-VERSION:6\n`;
|
||||
content += `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}\n`;
|
||||
content += `#EXT-X-MEDIA-SEQUENCE:1\n`;
|
||||
content += `#EXT-X-INDEPENDENT-SEGMENTS\n`;
|
||||
content += `#EXT-X-MAP:URI="${initFile}"\n`;
|
||||
|
||||
for (const segmentFile of segmentFiles) {
|
||||
content += `#EXTINF:${segmentDuration},\n`;
|
||||
content += `${segmentFile}\n`;
|
||||
}
|
||||
|
||||
content += `#EXT-X-ENDLIST\n`;
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS master playlist content
|
||||
*/
|
||||
export function generateHLSMasterPlaylist(
|
||||
variants: Array<{ path: string; bandwidth: number; resolution: string; fps: number }>,
|
||||
hasAudio: boolean
|
||||
): string {
|
||||
let content = '#EXTM3U\n';
|
||||
content += '#EXT-X-VERSION:6\n';
|
||||
content += '#EXT-X-INDEPENDENT-SEGMENTS\n\n';
|
||||
|
||||
// Add audio reference
|
||||
if (hasAudio) {
|
||||
content += `#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) {
|
||||
const codecs = hasAudio ? 'avc1.4D4020,mp4a.40.2' : 'avc1.4D4020';
|
||||
content += `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},CODECS="${codecs}",RESOLUTION=${variant.resolution},FRAME-RATE=${variant.fps}`;
|
||||
if (hasAudio) {
|
||||
content += `,AUDIO="audio"`;
|
||||
}
|
||||
content += `\n`;
|
||||
content += `${variant.path}\n\n`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
395
src/core/packaging.ts
Normal file
395
src/core/packaging.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { join } from 'node:path';
|
||||
import { execMP4Box } from '../utils';
|
||||
import type { VideoProfile, CodecType, StreamingFormat } from '../types';
|
||||
import { readdir, rename, mkdir, writeFile } from 'node:fs/promises';
|
||||
import {
|
||||
validateAndFixManifest,
|
||||
updateManifestPaths,
|
||||
separateCodecAdaptationSets,
|
||||
updateHLSManifestPaths,
|
||||
generateHLSMediaPlaylist,
|
||||
generateHLSMasterPlaylist
|
||||
} from './manifest';
|
||||
|
||||
/**
|
||||
* Package MP4 files into DASH format using MP4Box
|
||||
* Stage 2: Light work - just packaging, no encoding
|
||||
* Creates one master MPD manifest with all profiles and codecs
|
||||
*/
|
||||
export async function packageToDash(
|
||||
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
|
||||
outputDir: string,
|
||||
profiles: VideoProfile[],
|
||||
segmentDuration: number,
|
||||
codecType: CodecType,
|
||||
hasAudio: boolean
|
||||
): Promise<string> {
|
||||
const manifestPath = join(outputDir, 'manifest.mpd');
|
||||
|
||||
// Build MP4Box command
|
||||
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$',
|
||||
'-out', manifestPath
|
||||
];
|
||||
|
||||
// Add all MP4 files for each codec
|
||||
let firstFile = true;
|
||||
|
||||
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 && hasAudio) {
|
||||
args.push(`${mp4Path}#audio:id=audio`);
|
||||
firstFile = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute MP4Box
|
||||
// Note: We separate codecs into different AdaptationSets manually via separateCodecAdaptationSets()
|
||||
await execMP4Box(args);
|
||||
|
||||
// MP4Box creates files in the same directory as output MPD
|
||||
// Move segment files to profile subdirectories for clean structure
|
||||
await organizeSegments(outputDir, profiles, codecType, hasAudio);
|
||||
|
||||
// Update MPD to reflect new file structure with subdirectories
|
||||
await updateManifestPaths(manifestPath, profiles, codecType);
|
||||
|
||||
// For dual-codec mode, separate H.264 and AV1 into different AdaptationSets
|
||||
if (codecType === 'dual') {
|
||||
await separateCodecAdaptationSets(manifestPath);
|
||||
}
|
||||
|
||||
// Validate and fix XML structure (ensure all tags are properly closed)
|
||||
await validateAndFixManifest(manifestPath);
|
||||
|
||||
return manifestPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organize segments into profile subdirectories
|
||||
* MP4Box creates all files in one directory, we organize them
|
||||
*/
|
||||
async function organizeSegments(
|
||||
outputDir: string,
|
||||
profiles: VideoProfile[],
|
||||
codecType: CodecType,
|
||||
hasAudio: boolean
|
||||
): Promise<void> {
|
||||
const { readdir, rename, mkdir } = await import('node:fs/promises');
|
||||
|
||||
// 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
|
||||
const audioDir = join(outputDir, 'audio');
|
||||
if (hasAudio) {
|
||||
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 === 'manifest.mpd') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Move audio files to audio/ directory
|
||||
if (hasAudio && (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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 updateHLSManifestPaths(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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
hasAudio: boolean
|
||||
): Promise<{ manifestPath?: string; hlsManifestPath?: string }> {
|
||||
|
||||
let manifestPath: string | undefined;
|
||||
let hlsManifestPath: string | undefined;
|
||||
|
||||
// Step 1: Generate DASH segments and manifest using MP4Box
|
||||
if (format === 'dash' || format === 'both') {
|
||||
manifestPath = await packageToDash(codecMP4Files, outputDir, profiles, segmentDuration, codec, hasAudio);
|
||||
}
|
||||
|
||||
// Step 2: Generate HLS playlists from existing segments
|
||||
if (format === 'hls' || format === 'both') {
|
||||
// HLS generation from segments
|
||||
hlsManifestPath = await generateHLSPlaylists(
|
||||
outputDir,
|
||||
profiles,
|
||||
segmentDuration,
|
||||
codec,
|
||||
hasAudio
|
||||
);
|
||||
}
|
||||
|
||||
return { manifestPath, hlsManifestPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate HLS playlists (media playlists in folders + master in root)
|
||||
*/
|
||||
async function generateHLSPlaylists(
|
||||
outputDir: string,
|
||||
profiles: VideoProfile[],
|
||||
segmentDuration: number,
|
||||
codecType: CodecType,
|
||||
hasAudio: boolean
|
||||
): 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 using manifest module
|
||||
const playlistContent = generateHLSMediaPlaylist(segmentFiles, initFile, segmentDuration);
|
||||
|
||||
// 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 (only if source has audio)
|
||||
let audioInit: string | undefined;
|
||||
let audioSegments: string[] = [];
|
||||
|
||||
if (hasAudio) {
|
||||
const audioDir = join(outputDir, 'audio');
|
||||
let audioFiles: string[] = [];
|
||||
|
||||
try {
|
||||
audioFiles = await readdir(audioDir);
|
||||
} catch {
|
||||
audioFiles = [];
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
audioInit = audioFiles.find(f => f.endsWith('_.mp4'));
|
||||
|
||||
if (audioInit && audioSegments.length > 0) {
|
||||
const audioPlaylistContent = generateHLSMediaPlaylist(audioSegments, audioInit, segmentDuration);
|
||||
await writeFile(join(audioDir, 'playlist.m3u8'), audioPlaylistContent, 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
// Generate master playlist using manifest module
|
||||
const masterContent = generateHLSMasterPlaylist(
|
||||
variants,
|
||||
hasAudio && audioInit !== undefined && audioSegments.length > 0
|
||||
);
|
||||
await writeFile(masterPlaylistPath, masterContent, 'utf-8');
|
||||
|
||||
return masterPlaylistPath;
|
||||
}
|
||||
139
src/core/thumbnails.ts
Normal file
139
src/core/thumbnails.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { join } from 'node:path';
|
||||
import type { ThumbnailConfig } from '../types';
|
||||
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:00'
|
||||
): 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
|
||||
*/
|
||||
export async function generateThumbnailSprite(
|
||||
inputPath: string,
|
||||
outputDir: string,
|
||||
duration: number,
|
||||
config: Required<ThumbnailConfig>
|
||||
): Promise<{ spritePath: string; vttPath: string }> {
|
||||
const { width, height, interval, columns } = config;
|
||||
|
||||
// Create temp directory for individual thumbnails
|
||||
const tempDir = join(outputDir, '.thumbnails_temp');
|
||||
await ensureDir(tempDir);
|
||||
await writeFile(join(tempDir, '.keep'), '');
|
||||
|
||||
// Generate individual thumbnails
|
||||
const thumbnailPattern = join(tempDir, 'thumb_%04d.jpg');
|
||||
|
||||
await execFFmpeg([
|
||||
'-i', inputPath,
|
||||
'-vf', `fps=1/${interval},scale=${width}:${height}`,
|
||||
'-q:v', '5',
|
||||
thumbnailPattern
|
||||
]);
|
||||
|
||||
// Get list of generated thumbnails
|
||||
const files = await readdir(tempDir);
|
||||
const thumbFiles = files
|
||||
.filter(f => f.startsWith('thumb_') && f.endsWith('.jpg'))
|
||||
.sort();
|
||||
|
||||
if (thumbFiles.length === 0) {
|
||||
throw new Error('No thumbnails generated');
|
||||
}
|
||||
|
||||
// Calculate grid dimensions
|
||||
const totalThumbs = thumbFiles.length;
|
||||
const rows = Math.ceil(totalThumbs / columns);
|
||||
|
||||
// Create sprite sheet using FFmpeg
|
||||
const spritePath = join(outputDir, 'thumbnails.jpg');
|
||||
|
||||
// Use pattern input for tile filter (not multiple -i inputs)
|
||||
const tileFilter = `tile=${columns}x${rows}`;
|
||||
|
||||
await execFFmpeg([
|
||||
'-i', thumbnailPattern, // Use pattern, not individual files
|
||||
'-filter_complex', tileFilter,
|
||||
'-q:v', '5',
|
||||
spritePath
|
||||
]);
|
||||
|
||||
// Generate VTT file
|
||||
const vttPath = join(outputDir, 'thumbnails.vtt');
|
||||
const vttContent = generateVttContent(
|
||||
totalThumbs,
|
||||
interval,
|
||||
width,
|
||||
height,
|
||||
columns,
|
||||
'thumbnails.jpg'
|
||||
);
|
||||
|
||||
await writeFile(vttPath, vttContent);
|
||||
|
||||
// Clean up temp files
|
||||
for (const file of thumbFiles) {
|
||||
await unlink(join(tempDir, file));
|
||||
}
|
||||
await unlink(join(tempDir, '.keep'));
|
||||
await rmdir(tempDir); // Remove directory
|
||||
|
||||
return { spritePath, vttPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate VTT file content
|
||||
*/
|
||||
function generateVttContent(
|
||||
totalThumbs: number,
|
||||
interval: number,
|
||||
thumbWidth: number,
|
||||
thumbHeight: number,
|
||||
columns: number,
|
||||
spriteFilename: string
|
||||
): string {
|
||||
let vtt = 'WEBVTT\n\n';
|
||||
|
||||
for (let i = 0; i < totalThumbs; i++) {
|
||||
const startTime = i * interval;
|
||||
const endTime = (i + 1) * interval;
|
||||
|
||||
const row = Math.floor(i / columns);
|
||||
const col = i % columns;
|
||||
|
||||
const x = col * thumbWidth;
|
||||
const y = row * thumbHeight;
|
||||
|
||||
vtt += `${formatVttTime(startTime)} --> ${formatVttTime(endTime)}\n`;
|
||||
vtt += `${spriteFilename}#xywh=${x},${y},${thumbWidth},${thumbHeight}\n\n`;
|
||||
}
|
||||
|
||||
return vtt;
|
||||
}
|
||||
31
src/index.ts
Normal file
31
src/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Main exports
|
||||
export { convertToDash } from './core/converter';
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
DashConvertOptions,
|
||||
DashConvertResult,
|
||||
VideoProfile,
|
||||
ThumbnailConfig,
|
||||
ConversionProgress,
|
||||
VideoMetadata,
|
||||
VideoOptimizations,
|
||||
CodecType,
|
||||
HardwareAccelerator,
|
||||
HardwareAccelerationOption,
|
||||
HardwareEncoderInfo
|
||||
} from './types';
|
||||
|
||||
// Utility exports
|
||||
export {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
checkNvenc,
|
||||
checkAV1Support,
|
||||
getVideoMetadata,
|
||||
selectAudioBitrate,
|
||||
detectHardwareEncoders
|
||||
} from './utils';
|
||||
|
||||
// Profile exports
|
||||
export { DEFAULT_PROFILES, selectProfiles } from './config/profiles';
|
||||
238
src/types/index.ts
Normal file
238
src/types/index.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* Video codec type for encoding
|
||||
*/
|
||||
export type CodecType = 'av1' | 'h264' | 'dual';
|
||||
|
||||
/**
|
||||
* Streaming format type
|
||||
*/
|
||||
export type StreamingFormat = 'dash' | 'hls' | 'both';
|
||||
|
||||
/**
|
||||
* Тип аппаратного ускорителя
|
||||
*/
|
||||
export type HardwareAccelerator = 'nvenc' | 'qsv' | 'amf' | 'vaapi' | 'videotoolbox' | 'v4l2' | 'cpu';
|
||||
|
||||
/**
|
||||
* Опция выбора ускорителя (конкретный или auto)
|
||||
*/
|
||||
export type HardwareAccelerationOption = HardwareAccelerator | 'auto';
|
||||
|
||||
/**
|
||||
* Набор доступных энкодеров для конкретного ускорителя
|
||||
*/
|
||||
export interface HardwareEncoderInfo {
|
||||
accelerator: HardwareAccelerator;
|
||||
h264Encoder?: string;
|
||||
av1Encoder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality settings for a codec
|
||||
*/
|
||||
export interface CodecQualitySettings {
|
||||
/** CQ (Constant Quality) for GPU encoders (0-51, lower = better quality) */
|
||||
cq?: number;
|
||||
|
||||
/** CRF (Constant Rate Factor) for CPU encoders (0-51 for h264, 0-63 for av1, lower = better quality) */
|
||||
crf?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Quality settings for video encoding
|
||||
*/
|
||||
export interface QualitySettings {
|
||||
/** Quality settings for H.264 codec */
|
||||
h264?: CodecQualitySettings;
|
||||
|
||||
/** Quality settings for AV1 codec */
|
||||
av1?: CodecQualitySettings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration options for DASH conversion
|
||||
*/
|
||||
export interface DashConvertOptions {
|
||||
/** Input video file path */
|
||||
input: string;
|
||||
|
||||
/** Output directory path */
|
||||
outputDir: string;
|
||||
|
||||
/** Segment duration in seconds (default: 2) */
|
||||
segmentDuration?: number;
|
||||
|
||||
/** 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) — устарело, используйте hardwareAccelerator */
|
||||
useNvenc?: boolean;
|
||||
|
||||
/** Предпочитаемый аппаратный ускоритель (auto по умолчанию) */
|
||||
hardwareAccelerator?: HardwareAccelerationOption;
|
||||
|
||||
/** Quality settings for video encoding (CQ/CRF values) */
|
||||
quality?: QualitySettings;
|
||||
|
||||
/** Generate thumbnail sprite (default: true) */
|
||||
generateThumbnails?: boolean;
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Callback for progress updates */
|
||||
onProgress?: (progress: ConversionProgress) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video quality profile
|
||||
*/
|
||||
export interface VideoProfile {
|
||||
/** Profile name (e.g., "1080p", "720p") */
|
||||
name: string;
|
||||
|
||||
/** Video width in pixels */
|
||||
width: number;
|
||||
|
||||
/** Video height in pixels */
|
||||
height: number;
|
||||
|
||||
/** Video bitrate (e.g., "5000k") */
|
||||
videoBitrate: string;
|
||||
|
||||
/** Audio bitrate (e.g., "128k") */
|
||||
audioBitrate: string;
|
||||
|
||||
/** Target FPS for this profile (default: 30) */
|
||||
fps?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail sprite configuration
|
||||
*/
|
||||
export interface ThumbnailConfig {
|
||||
/** Width of each thumbnail (default: 160) */
|
||||
width?: number;
|
||||
|
||||
/** Height of each thumbnail (default: 90) */
|
||||
height?: number;
|
||||
|
||||
/** Interval between thumbnails in seconds (default: 1) */
|
||||
interval?: number;
|
||||
|
||||
/** Number of thumbnails per row (default: 10) */
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Conversion progress information
|
||||
*/
|
||||
export interface ConversionProgress {
|
||||
/** Current stage of conversion */
|
||||
stage: 'analyzing' | 'encoding' | 'thumbnails' | 'manifest' | 'complete';
|
||||
|
||||
/** Progress percentage (0-100) - overall progress */
|
||||
percent: number;
|
||||
|
||||
/** Current profile being processed */
|
||||
currentProfile?: string;
|
||||
|
||||
/** Progress percentage for current profile (0-100) */
|
||||
profilePercent?: number;
|
||||
|
||||
/** Additional message */
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of DASH conversion
|
||||
*/
|
||||
export interface DashConvertResult {
|
||||
/** 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[];
|
||||
|
||||
/** Path to thumbnail sprite (if generated) */
|
||||
thumbnailSpritePath?: string;
|
||||
|
||||
/** Path to thumbnail VTT file (if generated) */
|
||||
thumbnailVttPath?: string;
|
||||
|
||||
/** Path to poster image (if generated) */
|
||||
posterPath?: string;
|
||||
|
||||
/** Video duration in seconds */
|
||||
duration: number;
|
||||
|
||||
/** Generated profiles */
|
||||
profiles: VideoProfile[];
|
||||
|
||||
/** Whether NVENC was used */
|
||||
usedNvenc: boolean;
|
||||
|
||||
/** Выбранный аппаратный ускоритель */
|
||||
selectedAccelerator: HardwareAccelerator;
|
||||
|
||||
/** Codec type used for encoding */
|
||||
codecType: CodecType;
|
||||
|
||||
/** Streaming format generated */
|
||||
format: StreamingFormat;
|
||||
}
|
||||
|
||||
/**
|
||||
* Video metadata
|
||||
*/
|
||||
export interface VideoMetadata {
|
||||
width: number;
|
||||
height: number;
|
||||
duration: number;
|
||||
fps: number;
|
||||
codec: string;
|
||||
hasAudio: boolean; // Есть ли аудиодорожка
|
||||
audioBitrate?: number; // Битрейт аудио в kbps
|
||||
videoBitrate?: number; // Битрейт видео в kbps
|
||||
}
|
||||
|
||||
/**
|
||||
* Video optimizations (for future use)
|
||||
*/
|
||||
export interface VideoOptimizations {
|
||||
/** Apply deinterlacing */
|
||||
deinterlace?: boolean;
|
||||
|
||||
/** Apply denoising filter */
|
||||
denoise?: boolean;
|
||||
|
||||
/** Color correction / LUT file path */
|
||||
colorCorrection?: string;
|
||||
|
||||
/** Audio normalization */
|
||||
audioNormalize?: boolean;
|
||||
|
||||
/** Custom FFmpeg filters */
|
||||
customFilters?: string[];
|
||||
}
|
||||
13
src/utils/fs.ts
Normal file
13
src/utils/fs.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { mkdir, access, constants } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
*/
|
||||
export async function ensureDir(dirPath: string): Promise<void> {
|
||||
try {
|
||||
await access(dirPath, constants.F_OK);
|
||||
} catch {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
23
src/utils/index.ts
Normal file
23
src/utils/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// System utilities
|
||||
export {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
checkNvenc,
|
||||
checkAV1Support,
|
||||
detectHardwareEncoders,
|
||||
execFFmpeg,
|
||||
execMP4Box,
|
||||
setLogFile
|
||||
} from './system';
|
||||
|
||||
// Video utilities
|
||||
export {
|
||||
getVideoMetadata,
|
||||
selectAudioBitrate,
|
||||
formatVttTime
|
||||
} from './video';
|
||||
|
||||
// File system utilities
|
||||
export {
|
||||
ensureDir
|
||||
} from './fs';
|
||||
261
src/utils/system.ts
Normal file
261
src/utils/system.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
import type { HardwareAccelerator, HardwareEncoderInfo } from '../types';
|
||||
|
||||
// Global variable for log file path
|
||||
let currentLogFile: string | null = null;
|
||||
|
||||
/**
|
||||
* Set log file path for FFmpeg and MP4Box output
|
||||
*/
|
||||
export function setLogFile(logPath: string): void {
|
||||
currentLogFile = logPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Append log entry to file
|
||||
*/
|
||||
async function appendLog(entry: string): Promise<void> {
|
||||
if (currentLogFile) {
|
||||
try {
|
||||
await appendFile(currentLogFile, entry, 'utf-8');
|
||||
} catch (err) {
|
||||
// Silently ignore log errors to not break conversion
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if FFmpeg is available
|
||||
*/
|
||||
export async function checkFFmpeg(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffmpeg', ['-version']);
|
||||
proc.on('error', () => resolve(false));
|
||||
proc.on('close', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MP4Box is available
|
||||
*/
|
||||
export async function checkMP4Box(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('MP4Box', ['-version']);
|
||||
proc.on('error', () => resolve(false));
|
||||
proc.on('close', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Bento4 mp4dash is available
|
||||
*/
|
||||
export async function checkBento4(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('mp4dash', ['--version']);
|
||||
proc.on('error', () => resolve(false));
|
||||
proc.on('close', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if NVENC is available
|
||||
*/
|
||||
export async function checkNvenc(): Promise<boolean> {
|
||||
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(false));
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(output.includes('h264_nvenc') || output.includes('hevc_nvenc'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Получить список доступных аппаратных энкодеров (по выводу ffmpeg -encoders)
|
||||
*/
|
||||
export async function detectHardwareEncoders(): Promise<HardwareEncoderInfo[]> {
|
||||
const encodersOutput: string = await new Promise((resolve) => {
|
||||
const proc = spawn('ffmpeg', ['-hide_banner', '-encoders']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', () => resolve(''));
|
||||
proc.on('close', () => resolve(output));
|
||||
});
|
||||
|
||||
const has = (name: string) => encodersOutput.includes(name);
|
||||
|
||||
const detected: HardwareEncoderInfo[] = [];
|
||||
|
||||
const accelerators: Array<{ acc: HardwareAccelerator; h264?: string; av1?: string }> = [
|
||||
{ acc: 'nvenc', h264: has('h264_nvenc') ? 'h264_nvenc' : undefined, av1: has('av1_nvenc') ? 'av1_nvenc' : undefined },
|
||||
{ acc: 'qsv', h264: has('h264_qsv') ? 'h264_qsv' : undefined, av1: has('av1_qsv') ? 'av1_qsv' : undefined },
|
||||
{ acc: 'amf', h264: has('h264_amf') ? 'h264_amf' : undefined, av1: has('av1_amf') ? 'av1_amf' : undefined },
|
||||
{ acc: 'vaapi', h264: has('h264_vaapi') ? 'h264_vaapi' : undefined, av1: has('av1_vaapi') ? 'av1_vaapi' : undefined },
|
||||
{ acc: 'videotoolbox', h264: has('h264_videotoolbox') ? 'h264_videotoolbox' : undefined, av1: has('av1_videotoolbox') ? 'av1_videotoolbox' : undefined },
|
||||
{ acc: 'v4l2', h264: has('h264_v4l2m2m') ? 'h264_v4l2m2m' : undefined, av1: has('av1_v4l2m2m') ? 'av1_v4l2m2m' : undefined }
|
||||
];
|
||||
|
||||
for (const item of accelerators) {
|
||||
if (item.h264 || item.av1) {
|
||||
detected.push({
|
||||
accelerator: item.acc,
|
||||
h264Encoder: item.h264,
|
||||
av1Encoder: item.av1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return detected;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute FFmpeg command with progress tracking
|
||||
*/
|
||||
export async function execFFmpeg(
|
||||
args: string[],
|
||||
onProgress?: (percent: number) => void,
|
||||
duration?: number
|
||||
): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const commandLog = `\n=== FFmpeg Command [${timestamp}] ===\nffmpeg ${args.join(' ')}\n`;
|
||||
await appendLog(commandLog);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('ffmpeg', args);
|
||||
|
||||
let stderrData = '';
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
stderrData += text;
|
||||
|
||||
if (onProgress && duration) {
|
||||
// Parse time from FFmpeg output: time=00:01:23.45
|
||||
const timeMatch = text.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
|
||||
if (timeMatch) {
|
||||
const hours = parseInt(timeMatch[1]);
|
||||
const minutes = parseInt(timeMatch[2]);
|
||||
const seconds = parseFloat(timeMatch[3]);
|
||||
const currentTime = hours * 3600 + minutes * 60 + seconds;
|
||||
const percent = Math.min(100, (currentTime / duration) * 100);
|
||||
onProgress(percent);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
appendLog(`ERROR: ${err.message}\n`);
|
||||
reject(new Error(`FFmpeg error: ${err.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Log last 10 lines of output for successful runs
|
||||
const lines = stderrData.split('\n').filter(l => l.trim());
|
||||
const lastLines = lines.slice(-10).join('\n');
|
||||
appendLog(`SUCCESS: Exit code ${code}\n--- Last 10 lines of output ---\n${lastLines}\n`);
|
||||
resolve();
|
||||
} else {
|
||||
// Log full output on failure
|
||||
appendLog(`FAILED: Exit code ${code}\n--- Full error output ---\n${stderrData}\n`);
|
||||
reject(new Error(`FFmpeg failed with exit code ${code}\n${stderrData}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute MP4Box command
|
||||
*/
|
||||
export async function execMP4Box(args: string[]): Promise<void> {
|
||||
const timestamp = new Date().toISOString();
|
||||
const commandLog = `\n=== MP4Box Command [${timestamp}] ===\nMP4Box ${args.join(' ')}\n`;
|
||||
await appendLog(commandLog);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('MP4Box', args);
|
||||
|
||||
let stdoutData = '';
|
||||
let stderrData = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdoutData += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderrData += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
appendLog(`ERROR: ${err.message}\n`);
|
||||
reject(new Error(`MP4Box error: ${err.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
// Log output summary for successful runs
|
||||
const output = stdoutData || stderrData;
|
||||
const lines = output.split('\n').filter(l => l.trim());
|
||||
const lastLines = lines.slice(-10).join('\n');
|
||||
appendLog(`SUCCESS: Exit code ${code}\n--- Last 10 lines of output ---\n${lastLines}\n`);
|
||||
resolve();
|
||||
} else {
|
||||
// Log full output on failure
|
||||
const output = stderrData || stdoutData;
|
||||
appendLog(`FAILED: Exit code ${code}\n--- Full error output ---\n${output}\n`);
|
||||
reject(new Error(`MP4Box failed with exit code ${code}\n${output}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
118
src/utils/video.ts
Normal file
118
src/utils/video.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { VideoMetadata } from '../types';
|
||||
|
||||
/**
|
||||
* Get video metadata using ffprobe
|
||||
*/
|
||||
export async function getVideoMetadata(inputPath: string): Promise<VideoMetadata> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('ffprobe', [
|
||||
'-v', 'error',
|
||||
'-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name,codec_type,bit_rate',
|
||||
'-show_entries', 'format=duration',
|
||||
'-of', 'json',
|
||||
inputPath
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new Error(`ffprobe error: ${err.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffprobe failed with exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(output);
|
||||
|
||||
const videoStream = data.streams.find((s: any) => s.codec_type === 'video');
|
||||
const audioStream = data.streams.find((s: any) => s.codec_type === 'audio');
|
||||
const format = data.format;
|
||||
|
||||
if (!videoStream) {
|
||||
reject(new Error('No video stream found in input file'));
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse frame rate (handle missing or malformed r_frame_rate)
|
||||
let fps = 30; // default fallback
|
||||
if (videoStream.r_frame_rate) {
|
||||
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
|
||||
if (num && den && den !== 0) {
|
||||
fps = num / den;
|
||||
}
|
||||
}
|
||||
|
||||
// Get duration from stream or format
|
||||
const duration = parseFloat(videoStream.duration || format.duration || '0');
|
||||
|
||||
// Get audio bitrate in kbps
|
||||
const audioBitrateSource = data.streams.find((s: any) => s.codec_type === 'audio' && s.bit_rate);
|
||||
const audioBitrate = audioBitrateSource?.bit_rate
|
||||
? Math.round(parseInt(audioBitrateSource.bit_rate) / 1000)
|
||||
: undefined;
|
||||
|
||||
// Get video bitrate in kbps
|
||||
const videoBitrate = videoStream.bit_rate
|
||||
? Math.round(parseInt(videoStream.bit_rate) / 1000)
|
||||
: undefined;
|
||||
|
||||
resolve({
|
||||
width: videoStream.width,
|
||||
height: videoStream.height,
|
||||
duration,
|
||||
fps,
|
||||
codec: videoStream.codec_name,
|
||||
hasAudio: Boolean(audioStream),
|
||||
audioBitrate,
|
||||
videoBitrate
|
||||
});
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse ffprobe output: ${err}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Select optimal audio bitrate based on source
|
||||
* Don't upscale audio quality - use min of source and target
|
||||
*/
|
||||
export function selectAudioBitrate(
|
||||
sourceAudioBitrate: number | undefined,
|
||||
targetBitrate: number = 256
|
||||
): string {
|
||||
if (!sourceAudioBitrate) {
|
||||
// If we can't detect source bitrate, use target
|
||||
return `${targetBitrate}k`;
|
||||
}
|
||||
|
||||
// Use minimum of source and target (no upscaling)
|
||||
const optimalBitrate = Math.min(sourceAudioBitrate, targetBitrate);
|
||||
|
||||
// Round to common bitrate values for consistency
|
||||
if (optimalBitrate <= 64) return '64k';
|
||||
if (optimalBitrate <= 96) return '96k';
|
||||
if (optimalBitrate <= 128) return '128k';
|
||||
if (optimalBitrate <= 192) return '192k';
|
||||
return '256k';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time for VTT file (HH:MM:SS.mmm)
|
||||
*/
|
||||
export function formatVttTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const secs = seconds % 60;
|
||||
|
||||
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${secs.toFixed(3).padStart(6, '0')}`;
|
||||
}
|
||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"lib": ["ESNext"],
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["bun-types"],
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": false,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
38
web-test/index.html
Normal file
38
web-test/index.html
Normal file
@@ -0,0 +1,38 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
|
||||
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
|
||||
<!-- <script src="https://cdnjs.cloudflare.com/polyfill/v2/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent,Object.entries,Object.values,URL"></script> -->
|
||||
<script src="https://unpkg.com/plyr@3"></script>
|
||||
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||
|
||||
<style>
|
||||
.container {
|
||||
margin: 50px auto;
|
||||
max-width: 1500px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<video controls playsinline></video>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const source = 'https://bitmovin-a.akamaihd.net/content/sintel/sintel.mpd';
|
||||
const dash = dashjs.MediaPlayer().create();
|
||||
const video = document.querySelector('video');
|
||||
dash.initialize(video, source, true);
|
||||
|
||||
const player = new Plyr(video, {captions: {active: true, update: true}});
|
||||
window.player = player;
|
||||
window.dash = dash;
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user