add: Фоллбек совместимость HLS
This commit is contained in:
23
README.md
23
README.md
@@ -1,8 +1,8 @@
|
|||||||
# DASH Video Converter 🎬
|
# DASH Video Converter 🎬
|
||||||
|
|
||||||
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
CLI инструмент для конвертации видео в форматы DASH и HLS с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
||||||
|
|
||||||
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты • 🖼️ Thumbnail спрайты • 📸 Генерация постера • 📊 Прогресс в реальном времени
|
**Возможности:** ⚡ NVENC ускорение • 🎯 DASH + HLS форматы • 📊 Множественные битрейты • 🖼️ Thumbnail спрайты • 📸 Генерация постера • ⏱️ Прогресс в реальном времени
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|
||||||
@@ -27,12 +27,12 @@ sudo apt install ffmpeg gpac
|
|||||||
brew install ffmpeg gpac
|
brew install ffmpeg gpac
|
||||||
```
|
```
|
||||||
|
|
||||||
**Результат:** В текущей директории будет создана папка `video/` с файлами `manifest.mpd`, видео сегментами, постером и превью спрайтами.
|
**Результат:** В текущей директории будет создана папка `video/` с сегментами в папках `{profile}-{codec}/`, манифестами DASH и HLS в корне, постером и превью спрайтами.
|
||||||
|
|
||||||
## Параметры CLI
|
## Параметры CLI
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
|
dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]
|
||||||
```
|
```
|
||||||
|
|
||||||
### Основные параметры
|
### Основные параметры
|
||||||
@@ -47,12 +47,14 @@ dvc-cli <input-video> [output-dir] [-r resolutions] [-p poster-timecode]
|
|||||||
| Ключ | Описание | Формат | Пример |
|
| Ключ | Описание | Формат | Пример |
|
||||||
|------|----------|--------|--------|
|
|------|----------|--------|--------|
|
||||||
| `-r, --resolutions` | Выбор профилей качества | `360`, `720@60`, `1080-60` | `-r 720,1080,1440@60` |
|
| `-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` |
|
| `-p, --poster` | Таймкод для постера | `HH:MM:SS` или секунды | `-p 00:00:05` или `-p 10` |
|
||||||
|
|
||||||
### Примеры использования
|
### Примеры использования
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Базовая конвертация (результат в текущей папке)
|
# Базовая конвертация (DASH + HLS, dual codec, автопрофили)
|
||||||
dvc-cli video.mp4
|
dvc-cli video.mp4
|
||||||
|
|
||||||
# Указать выходную директорию
|
# Указать выходную директорию
|
||||||
@@ -64,14 +66,17 @@ dvc-cli video.mp4 -r 720,1080,1440
|
|||||||
# Высокий FPS для игровых стримов
|
# Высокий FPS для игровых стримов
|
||||||
dvc-cli video.mp4 -r 720@60,1080@60
|
dvc-cli video.mp4 -r 720@60,1080@60
|
||||||
|
|
||||||
|
# Только DASH формат
|
||||||
|
dvc-cli video.mp4 -f dash
|
||||||
|
|
||||||
|
# Только HLS для Safari/iOS
|
||||||
|
dvc-cli video.mp4 -f hls -c h264
|
||||||
|
|
||||||
# Постер с 5-й секунды
|
# Постер с 5-й секунды
|
||||||
dvc-cli video.mp4 -p 5
|
dvc-cli video.mp4 -p 5
|
||||||
|
|
||||||
# Постер в формате времени
|
|
||||||
dvc-cli video.mp4 -p 00:01:30
|
|
||||||
|
|
||||||
# Комбинация параметров
|
# Комбинация параметров
|
||||||
dvc-cli video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10
|
dvc-cli video.mp4 ./output -r 720,1080@60,1440@60 -c dual -f both -p 00:00:10
|
||||||
```
|
```
|
||||||
|
|
||||||
### Поддерживаемые разрешения
|
### Поддерживаемые разрешения
|
||||||
|
|||||||
385
docs/CLI_REFERENCE.md
Normal file
385
docs/CLI_REFERENCE.md
Normal file
@@ -0,0 +1,385 @@
|
|||||||
|
# CLI Reference — Справочник команд
|
||||||
|
|
||||||
|
Полное руководство по использованию DASH Video Converter CLI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Синтаксис
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dvc-cli <input-video> [output-dir] [options]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Позиционные аргументы
|
||||||
|
|
||||||
|
| Аргумент | Описание | По умолчанию |
|
||||||
|
|----------|----------|--------------|
|
||||||
|
| `input-video` | Путь к входному видео файлу | **Обязательный** |
|
||||||
|
| `output-dir` | Директория для сохранения результата | `.` (текущая папка) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Опции
|
||||||
|
|
||||||
|
### `-r, --resolutions` — Профили разрешений
|
||||||
|
|
||||||
|
Задает список профилей для генерации. Можно указывать разрешение и FPS.
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
- `<resolution>` — только разрешение (FPS = 30)
|
||||||
|
- `<resolution>@<fps>` — разрешение с FPS (разделитель `@`)
|
||||||
|
- `<resolution>-<fps>` — разрешение с FPS (разделитель `-`)
|
||||||
|
|
||||||
|
**Поддерживаемые разрешения:**
|
||||||
|
- `360p` (640×360)
|
||||||
|
- `480p` (854×480)
|
||||||
|
- `720p` (1280×720)
|
||||||
|
- `1080p` (1920×1080)
|
||||||
|
- `1440p` (2560×1440)
|
||||||
|
- `2160p` (3840×2160)
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
```bash
|
||||||
|
# Базовые разрешения (30 FPS)
|
||||||
|
dvc-cli video.mp4 -r 360,720,1080
|
||||||
|
|
||||||
|
# С указанием FPS
|
||||||
|
dvc-cli video.mp4 -r 720@60,1080@60
|
||||||
|
|
||||||
|
# Смешанный формат
|
||||||
|
dvc-cli video.mp4 -r 360 720@60 1080 1440@120
|
||||||
|
```
|
||||||
|
|
||||||
|
**Автоматическая коррекция FPS:**
|
||||||
|
- Если запрошенный FPS > FPS источника → используется FPS источника
|
||||||
|
- Максимальный FPS: **120** (ограничение системы)
|
||||||
|
- Система выведет предупреждение при коррекции
|
||||||
|
|
||||||
|
**По умолчанию:** Автоматический выбор всех подходящих разрешений (≤ разрешения источника) с 30 FPS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `-c, --codec` — Видео кодек
|
||||||
|
|
||||||
|
Выбор видео кодека для кодирования.
|
||||||
|
|
||||||
|
**Значения:**
|
||||||
|
- `h264` — только H.264 (максимальная совместимость)
|
||||||
|
- `av1` — только AV1 (лучшее сжатие, новые браузеры)
|
||||||
|
- `dual` — оба кодека (рекомендуется)
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
```bash
|
||||||
|
# Только H.264 (быстрее, больше места)
|
||||||
|
dvc-cli video.mp4 -c h264
|
||||||
|
|
||||||
|
# Только AV1 (медленнее, меньше места)
|
||||||
|
dvc-cli video.mp4 -c av1
|
||||||
|
|
||||||
|
# Оба кодека (максимальная совместимость)
|
||||||
|
dvc-cli video.mp4 -c dual
|
||||||
|
```
|
||||||
|
|
||||||
|
**GPU ускорение:**
|
||||||
|
- H.264: `h264_nvenc` (NVIDIA), fallback → `libx264` (CPU)
|
||||||
|
- AV1: `av1_nvenc` (NVIDIA), `av1_qsv` (Intel), `av1_amf` (AMD), fallback → `libsvtav1` (CPU)
|
||||||
|
|
||||||
|
**По умолчанию:** `dual`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `-f, --format` — Формат стриминга
|
||||||
|
|
||||||
|
Выбор формата адаптивного стриминга.
|
||||||
|
|
||||||
|
**Значения:**
|
||||||
|
- `dash` — только DASH (MPEG-DASH)
|
||||||
|
- `hls` — только HLS (HTTP Live Streaming)
|
||||||
|
- `both` — оба формата
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
```bash
|
||||||
|
# Только DASH
|
||||||
|
dvc-cli video.mp4 -f dash
|
||||||
|
|
||||||
|
# Только HLS (для Safari/iOS)
|
||||||
|
dvc-cli video.mp4 -f hls
|
||||||
|
|
||||||
|
# Оба формата (максимальная совместимость)
|
||||||
|
dvc-cli video.mp4 -f both
|
||||||
|
```
|
||||||
|
|
||||||
|
**Особенности:**
|
||||||
|
|
||||||
|
| Формат | Кодеки | Совместимость | Примечание |
|
||||||
|
|--------|--------|---------------|------------|
|
||||||
|
| DASH | H.264 + AV1 | Chrome, Firefox, Edge, Safari (с dash.js) | Стандарт индустрии |
|
||||||
|
| HLS | H.264 только | Safari, iOS, все браузеры | Требует H.264 |
|
||||||
|
| both | H.264 + AV1 (DASH), H.264 (HLS) | Максимальная | Рекомендуется |
|
||||||
|
|
||||||
|
**Ограничения:**
|
||||||
|
- HLS требует `--codec h264` или `--codec dual`
|
||||||
|
- AV1 не поддерживается в HLS (Safari не поддерживает AV1)
|
||||||
|
|
||||||
|
**Файловая структура:**
|
||||||
|
```
|
||||||
|
output/
|
||||||
|
└── video_name/
|
||||||
|
├── 720p-h264/ ← Сегменты H.264 720p
|
||||||
|
│ ├── 720p-h264_.mp4
|
||||||
|
│ ├── 720p-h264_1.m4s
|
||||||
|
│ └── playlist.m3u8 ← HLS медиа плейлист
|
||||||
|
├── 720p-av1/ ← Сегменты AV1 720p (только для DASH)
|
||||||
|
│ ├── 720p-av1_.mp4
|
||||||
|
│ └── 720p-av1_1.m4s
|
||||||
|
├── audio/ ← Аудио сегменты
|
||||||
|
│ ├── audio_.mp4
|
||||||
|
│ ├── audio_1.m4s
|
||||||
|
│ └── playlist.m3u8
|
||||||
|
├── manifest.mpd ← DASH манифест (корень)
|
||||||
|
├── master.m3u8 ← HLS мастер плейлист (корень)
|
||||||
|
├── poster.jpg ← Общие файлы
|
||||||
|
├── thumbnails.jpg
|
||||||
|
└── thumbnails.vtt
|
||||||
|
```
|
||||||
|
|
||||||
|
**Преимущества структуры:**
|
||||||
|
- Сегменты хранятся один раз (нет дублирования)
|
||||||
|
- DASH и HLS используют одни и те же .m4s файлы
|
||||||
|
- Экономия 50% места при `format=both`
|
||||||
|
|
||||||
|
**По умолчанию:** `both` (максимальная совместимость)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `-p, --poster` — Тайм-код постера
|
||||||
|
|
||||||
|
Время, с которого извлечь кадр для постера.
|
||||||
|
|
||||||
|
**Формат:**
|
||||||
|
- Секунды: `5`, `10.5`
|
||||||
|
- Тайм-код: `00:00:05`, `00:01:30`
|
||||||
|
|
||||||
|
**Примеры:**
|
||||||
|
```bash
|
||||||
|
# 5 секунд от начала
|
||||||
|
dvc-cli video.mp4 -p 5
|
||||||
|
|
||||||
|
# 1 минута 30 секунд
|
||||||
|
dvc-cli video.mp4 -p 00:01:30
|
||||||
|
```
|
||||||
|
|
||||||
|
**По умолчанию:** `00:00:01` (1 секунда от начала)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Базовое использование
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Простейший запуск (оба формата, dual codec, автопрофили)
|
||||||
|
dvc-cli video.mp4
|
||||||
|
|
||||||
|
# С указанием выходной директории
|
||||||
|
dvc-cli video.mp4 ./output
|
||||||
|
```
|
||||||
|
|
||||||
|
### Кастомные профили
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Только 720p и 1080p
|
||||||
|
dvc-cli video.mp4 -r 720,1080
|
||||||
|
|
||||||
|
# High FPS профили
|
||||||
|
dvc-cli video.mp4 -r 720@60,1080@60,1440@120
|
||||||
|
|
||||||
|
# Один профиль 4K
|
||||||
|
dvc-cli video.mp4 -r 2160
|
||||||
|
```
|
||||||
|
|
||||||
|
### Выбор кодека
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Быстрое кодирование (только H.264)
|
||||||
|
dvc-cli video.mp4 -c h264
|
||||||
|
|
||||||
|
# Лучшее сжатие (только AV1)
|
||||||
|
dvc-cli video.mp4 -c av1
|
||||||
|
|
||||||
|
# Максимальная совместимость
|
||||||
|
dvc-cli video.mp4 -c dual
|
||||||
|
```
|
||||||
|
|
||||||
|
### Выбор формата
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# DASH для современных браузеров
|
||||||
|
dvc-cli video.mp4 -f dash
|
||||||
|
|
||||||
|
# HLS для Safari/iOS
|
||||||
|
dvc-cli video.mp4 -f hls -c h264
|
||||||
|
|
||||||
|
# Оба формата для всех устройств
|
||||||
|
dvc-cli video.mp4 -f both -c dual
|
||||||
|
```
|
||||||
|
|
||||||
|
### Комбинированные примеры
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Производственная конфигурация
|
||||||
|
dvc-cli video.mp4 ./cdn/videos -r 360,720,1080 -c dual -f both
|
||||||
|
|
||||||
|
# High-end конфигурация (4K, high FPS)
|
||||||
|
dvc-cli video.mp4 -r 720@60,1080@60,1440@120,2160@60 -c dual -f both
|
||||||
|
|
||||||
|
# Быстрая конвертация для тестов
|
||||||
|
dvc-cli video.mp4 -r 720 -c h264 -f dash
|
||||||
|
|
||||||
|
# Mobile-first (низкие разрешения, HLS)
|
||||||
|
dvc-cli video.mp4 -r 360,480,720 -c h264 -f hls
|
||||||
|
|
||||||
|
# Кастомный постер
|
||||||
|
dvc-cli video.mp4 -r 720,1080 -p 00:02:30
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Системные требования
|
||||||
|
|
||||||
|
### Обязательные зависимости
|
||||||
|
|
||||||
|
- **FFmpeg** — кодирование видео
|
||||||
|
- **MP4Box (GPAC)** — упаковка DASH/HLS
|
||||||
|
|
||||||
|
Установка:
|
||||||
|
```bash
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S ffmpeg gpac
|
||||||
|
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install ffmpeg gpac
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install ffmpeg gpac
|
||||||
|
```
|
||||||
|
|
||||||
|
### Опциональные (для GPU ускорения)
|
||||||
|
|
||||||
|
- **NVIDIA GPU** — для H.264/AV1 кодирования через NVENC
|
||||||
|
- **Intel GPU** — для AV1 через QSV
|
||||||
|
- **AMD GPU** — для AV1 через AMF
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
### CPU vs GPU
|
||||||
|
|
||||||
|
| Кодек | CPU | GPU (NVENC) | Ускорение |
|
||||||
|
|-------|-----|-------------|-----------|
|
||||||
|
| H.264 | libx264 | h264_nvenc | ~10-20x |
|
||||||
|
| AV1 | libsvtav1 | av1_nvenc | ~15-30x |
|
||||||
|
|
||||||
|
### Параллельное кодирование
|
||||||
|
|
||||||
|
- **GPU**: до 3 профилей одновременно
|
||||||
|
- **CPU**: до 2 профилей одновременно
|
||||||
|
|
||||||
|
### Время конвертации (примерные данные)
|
||||||
|
|
||||||
|
Видео 4K, 10 секунд, dual codec, 3 профиля:
|
||||||
|
|
||||||
|
| Конфигурация | Время |
|
||||||
|
|--------------|-------|
|
||||||
|
| CPU (libx264 + libsvtav1) | ~5-10 минут |
|
||||||
|
| GPU (NVENC) | ~30-60 секунд |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### Для максимальной совместимости
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dvc-cli video.mp4 -c dual -f both
|
||||||
|
```
|
||||||
|
|
||||||
|
Генерирует:
|
||||||
|
- DASH с H.264 + AV1 (Chrome, Firefox, Edge)
|
||||||
|
- HLS с H.264 (Safari, iOS)
|
||||||
|
- Все современные устройства поддерживаются
|
||||||
|
|
||||||
|
### Для быстрой разработки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dvc-cli video.mp4 -r 720 -c h264 -f dash
|
||||||
|
```
|
||||||
|
|
||||||
|
Быстрое кодирование одного профиля.
|
||||||
|
|
||||||
|
### Для продакшена
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dvc-cli video.mp4 -r 360,480,720,1080,1440 -c dual -f both
|
||||||
|
```
|
||||||
|
|
||||||
|
Широкий диапазон профилей для всех устройств.
|
||||||
|
|
||||||
|
### Для 4K контента
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dvc-cli video.mp4 -r 720,1080,1440,2160 -c dual -f both
|
||||||
|
```
|
||||||
|
|
||||||
|
От HD до 4K для премиум контента.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Устранение проблем
|
||||||
|
|
||||||
|
### HLS требует H.264
|
||||||
|
|
||||||
|
**Ошибка:**
|
||||||
|
```
|
||||||
|
❌ Error: HLS format requires H.264 codec
|
||||||
|
```
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
```bash
|
||||||
|
# Используйте h264 или dual
|
||||||
|
dvc-cli video.mp4 -f hls -c h264
|
||||||
|
# или
|
||||||
|
dvc-cli video.mp4 -f hls -c dual
|
||||||
|
```
|
||||||
|
|
||||||
|
### FPS источника ниже запрошенного
|
||||||
|
|
||||||
|
**Предупреждение:**
|
||||||
|
```
|
||||||
|
⚠️ Requested 120 FPS, but source is 60 FPS. Using 60 FPS instead
|
||||||
|
```
|
||||||
|
|
||||||
|
Это нормально! Система автоматически ограничивает FPS до максимума источника.
|
||||||
|
|
||||||
|
### MP4Box не найден
|
||||||
|
|
||||||
|
**Ошибка:**
|
||||||
|
```
|
||||||
|
❌ MP4Box not found
|
||||||
|
```
|
||||||
|
|
||||||
|
**Решение:**
|
||||||
|
```bash
|
||||||
|
sudo pacman -S gpac # Arch
|
||||||
|
sudo apt install gpac # Ubuntu
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## См. также
|
||||||
|
|
||||||
|
- [FEATURES.md](./FEATURES.md) — Возможности и технические детали
|
||||||
|
- [PUBLISHING.md](./PUBLISHING.md) — Публикация пакета в npm
|
||||||
|
- [README.md](../README.md) — Быстрый старт
|
||||||
|
|
||||||
46
src/cli.ts
46
src/cli.ts
@@ -13,13 +13,14 @@
|
|||||||
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, checkAV1Support, getVideoMetadata } from './index';
|
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, checkAV1Support, getVideoMetadata } from './index';
|
||||||
import cliProgress from 'cli-progress';
|
import cliProgress from 'cli-progress';
|
||||||
import { statSync } from 'node:fs';
|
import { statSync } from 'node:fs';
|
||||||
import type { CodecType } from './types';
|
import type { CodecType, StreamingFormat } from './types';
|
||||||
|
|
||||||
// Parse arguments
|
// Parse arguments
|
||||||
const args = process.argv.slice(2);
|
const args = process.argv.slice(2);
|
||||||
let customProfiles: string[] | undefined;
|
let customProfiles: string[] | undefined;
|
||||||
let posterTimecode: string | undefined;
|
let posterTimecode: string | undefined;
|
||||||
let codecType: CodecType = 'dual'; // Default to dual codec
|
let codecType: CodecType = 'dual'; // Default to dual codec
|
||||||
|
let formatType: StreamingFormat = 'both'; // Default to both formats (DASH + HLS)
|
||||||
const positionalArgs: string[] = [];
|
const positionalArgs: string[] = [];
|
||||||
|
|
||||||
// First pass: extract flags and their values
|
// First pass: extract flags and their values
|
||||||
@@ -54,6 +55,15 @@ for (let i = 0; i < args.length; i++) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
i++; // Skip next arg
|
i++; // Skip next arg
|
||||||
|
} else if (args[i] === '-f' || args[i] === '--format') {
|
||||||
|
const format = args[i + 1];
|
||||||
|
if (format === 'dash' || format === 'hls' || format === 'both') {
|
||||||
|
formatType = format;
|
||||||
|
} else {
|
||||||
|
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls, both`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
i++; // Skip next arg
|
||||||
} else if (!args[i].startsWith('-')) {
|
} else if (!args[i].startsWith('-')) {
|
||||||
// Positional argument
|
// Positional argument
|
||||||
positionalArgs.push(args[i]);
|
positionalArgs.push(args[i]);
|
||||||
@@ -65,21 +75,23 @@ const input = positionalArgs[0];
|
|||||||
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-p poster-timecode]');
|
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]');
|
||||||
console.error('\nOptions:');
|
console.error('\nOptions:');
|
||||||
console.error(' -r, --resolutions Video resolutions (e.g., 360,480,720 or 720@60,1080@60)');
|
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(' -c, --codec Video codec: av1, h264, or dual (default: dual)');
|
||||||
|
console.error(' -f, --format Streaming format: dash, hls, or both (default: dash)');
|
||||||
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
|
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
|
||||||
console.error('\nExamples:');
|
console.error('\nExamples:');
|
||||||
console.error(' dvc-cli video.mp4');
|
console.error(' dvc-cli video.mp4');
|
||||||
console.error(' dvc-cli video.mp4 ./output');
|
console.error(' dvc-cli video.mp4 ./output');
|
||||||
console.error(' dvc-cli video.mp4 -r 360,480,720');
|
console.error(' dvc-cli video.mp4 -r 360,480,720');
|
||||||
console.error(' dvc-cli video.mp4 -c av1');
|
console.error(' dvc-cli video.mp4 -c av1');
|
||||||
console.error(' dvc-cli video.mp4 -c h264');
|
console.error(' dvc-cli video.mp4 -f hls');
|
||||||
console.error(' dvc-cli video.mp4 -c dual');
|
console.error(' dvc-cli video.mp4 -f both');
|
||||||
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1');
|
console.error(' dvc-cli video.mp4 -c dual -f both');
|
||||||
|
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash');
|
||||||
console.error(' dvc-cli video.mp4 -p 00:00:05');
|
console.error(' dvc-cli video.mp4 -p 00:00:05');
|
||||||
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -p 10');
|
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -f both -p 10');
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,6 +128,13 @@ if ((codecType === 'av1' || codecType === 'dual') && !av1Support.available) {
|
|||||||
console.error(` Consider using --codec h264 for faster encoding.\n`);
|
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
|
// Get video metadata and file size
|
||||||
console.log('📊 Analyzing video...\n');
|
console.log('📊 Analyzing video...\n');
|
||||||
const metadata = await getVideoMetadata(input);
|
const metadata = await getVideoMetadata(input);
|
||||||
@@ -137,6 +156,7 @@ if (metadata.audioBitrate) {
|
|||||||
}
|
}
|
||||||
console.log(`\n📁 Output: ${outputDir}`);
|
console.log(`\n📁 Output: ${outputDir}`);
|
||||||
console.log(`🎬 Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264 for maximum compatibility)' : ''}`);
|
console.log(`🎬 Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264 for maximum compatibility)' : ''}`);
|
||||||
|
console.log(`📺 Format: ${formatType}${formatType === 'both' ? ' (DASH + HLS for maximum compatibility)' : formatType === 'hls' ? ' (H.264 only for Safari/iOS)' : ''}`);
|
||||||
if (customProfiles) {
|
if (customProfiles) {
|
||||||
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
|
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
|
||||||
}
|
}
|
||||||
@@ -166,6 +186,7 @@ try {
|
|||||||
customProfiles,
|
customProfiles,
|
||||||
posterTimecode,
|
posterTimecode,
|
||||||
codec: codecType,
|
codec: codecType,
|
||||||
|
format: formatType,
|
||||||
segmentDuration: 2,
|
segmentDuration: 2,
|
||||||
useNvenc: hasNvenc,
|
useNvenc: hasNvenc,
|
||||||
generateThumbnails: true,
|
generateThumbnails: true,
|
||||||
@@ -212,9 +233,18 @@ try {
|
|||||||
|
|
||||||
console.log('\n✅ Conversion completed successfully!\n');
|
console.log('\n✅ Conversion completed successfully!\n');
|
||||||
console.log('📊 Results:');
|
console.log('📊 Results:');
|
||||||
console.log(` Manifest: ${result.manifestPath}`);
|
|
||||||
|
if (result.manifestPath) {
|
||||||
|
console.log(` DASH Manifest: ${result.manifestPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.hlsManifestPath) {
|
||||||
|
console.log(` HLS Manifest: ${result.hlsManifestPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
console.log(` Duration: ${result.duration.toFixed(2)}s`);
|
console.log(` Duration: ${result.duration.toFixed(2)}s`);
|
||||||
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
|
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
|
||||||
|
console.log(` Format: ${result.format}`);
|
||||||
console.log(` Codec: ${result.codecType}${result.codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
|
console.log(` Codec: ${result.codecType}${result.codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
|
||||||
console.log(` Encoder: ${result.usedNvenc ? '⚡ GPU accelerated' : '🔧 CPU'}`);
|
console.log(` Encoder: ${result.usedNvenc ? '⚡ GPU accelerated' : '🔧 CPU'}`);
|
||||||
|
|
||||||
@@ -227,7 +257,7 @@ try {
|
|||||||
console.log(` VTT file: ${result.thumbnailVttPath}`);
|
console.log(` VTT file: ${result.thumbnailVttPath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('\n🎉 Done! You can now use the manifest file in your video player.');
|
console.log('\n🎉 Done! You can now use the manifest file(s) in your video player.');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
multibar.stop();
|
multibar.stop();
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ import type {
|
|||||||
VideoProfile,
|
VideoProfile,
|
||||||
ThumbnailConfig,
|
ThumbnailConfig,
|
||||||
ConversionProgress,
|
ConversionProgress,
|
||||||
CodecType
|
CodecType,
|
||||||
|
StreamingFormat
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
checkFFmpeg,
|
checkFFmpeg,
|
||||||
@@ -20,7 +21,7 @@ import {
|
|||||||
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
|
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
|
||||||
import { generateThumbnailSprite, generatePoster } from './thumbnails';
|
import { generateThumbnailSprite, generatePoster } from './thumbnails';
|
||||||
import { encodeProfilesToMP4 } from './encoding';
|
import { encodeProfilesToMP4 } from './encoding';
|
||||||
import { packageToDash } from './packaging';
|
import { packageToFormats } from './packaging';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Convert video to DASH format with NVENC acceleration
|
* Convert video to DASH format with NVENC acceleration
|
||||||
@@ -36,6 +37,7 @@ export async function convertToDash(
|
|||||||
profiles: userProfiles,
|
profiles: userProfiles,
|
||||||
customProfiles,
|
customProfiles,
|
||||||
codec = 'dual',
|
codec = 'dual',
|
||||||
|
format = 'both',
|
||||||
useNvenc,
|
useNvenc,
|
||||||
generateThumbnails = true,
|
generateThumbnails = true,
|
||||||
thumbnailConfig = {},
|
thumbnailConfig = {},
|
||||||
@@ -58,6 +60,7 @@ export async function convertToDash(
|
|||||||
userProfiles,
|
userProfiles,
|
||||||
customProfiles,
|
customProfiles,
|
||||||
codec,
|
codec,
|
||||||
|
format,
|
||||||
useNvenc,
|
useNvenc,
|
||||||
generateThumbnails,
|
generateThumbnails,
|
||||||
thumbnailConfig,
|
thumbnailConfig,
|
||||||
@@ -87,6 +90,7 @@ async function convertToDashInternal(
|
|||||||
userProfiles: VideoProfile[] | undefined,
|
userProfiles: VideoProfile[] | undefined,
|
||||||
customProfiles: string[] | undefined,
|
customProfiles: string[] | undefined,
|
||||||
codec: CodecType,
|
codec: CodecType,
|
||||||
|
format: StreamingFormat,
|
||||||
useNvenc: boolean | undefined,
|
useNvenc: boolean | undefined,
|
||||||
generateThumbnails: boolean,
|
generateThumbnails: boolean,
|
||||||
thumbnailConfig: ThumbnailConfig,
|
thumbnailConfig: ThumbnailConfig,
|
||||||
@@ -260,15 +264,16 @@ async function convertToDashInternal(
|
|||||||
|
|
||||||
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
|
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
|
||||||
|
|
||||||
// STAGE 2: Package to DASH using MP4Box (light work, fast)
|
// STAGE 2: Package to segments and manifests (unified, no duplication)
|
||||||
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
|
reportProgress('encoding', 70, `Stage 2: Creating segments and manifests...`);
|
||||||
|
|
||||||
const manifestPath = await packageToDash(
|
const { manifestPath, hlsManifestPath } = await packageToFormats(
|
||||||
codecMP4Paths,
|
codecMP4Paths,
|
||||||
videoOutputDir,
|
videoOutputDir,
|
||||||
profiles,
|
profiles,
|
||||||
segmentDuration,
|
segmentDuration,
|
||||||
codec
|
codec,
|
||||||
|
format
|
||||||
);
|
);
|
||||||
|
|
||||||
// Collect all video paths from all codecs
|
// Collect all video paths from all codecs
|
||||||
@@ -277,7 +282,7 @@ async function convertToDashInternal(
|
|||||||
videoPaths.push(...Array.from(mp4Paths.values()));
|
videoPaths.push(...Array.from(mp4Paths.values()));
|
||||||
}
|
}
|
||||||
|
|
||||||
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
|
reportProgress('encoding', 80, 'Stage 2 complete: All formats packaged');
|
||||||
|
|
||||||
// Generate thumbnails
|
// Generate thumbnails
|
||||||
let thumbnailSpritePath: string | undefined;
|
let thumbnailSpritePath: string | undefined;
|
||||||
@@ -321,16 +326,17 @@ async function convertToDashInternal(
|
|||||||
reportProgress('thumbnails', 95, 'Poster generated');
|
reportProgress('thumbnails', 95, 'Poster generated');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate MPD manifest
|
// Finalize
|
||||||
reportProgress('manifest', 95, 'Finalizing manifest...');
|
reportProgress('manifest', 95, 'Finalizing...');
|
||||||
|
|
||||||
// Note: manifestPath is already created by MP4Box in packageToDash
|
// Note: manifestPath/hlsManifestPath are already created by MP4Box in packageToDash/packageToHLS
|
||||||
// No need for separate generateManifest function
|
// No need for separate generateManifest function
|
||||||
|
|
||||||
reportProgress('complete', 100, 'Conversion complete!');
|
reportProgress('complete', 100, 'Conversion complete!');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
manifestPath,
|
manifestPath,
|
||||||
|
hlsManifestPath,
|
||||||
videoPaths,
|
videoPaths,
|
||||||
thumbnailSpritePath,
|
thumbnailSpritePath,
|
||||||
thumbnailVttPath,
|
thumbnailVttPath,
|
||||||
@@ -338,7 +344,8 @@ async function convertToDashInternal(
|
|||||||
duration: metadata.duration,
|
duration: metadata.duration,
|
||||||
profiles,
|
profiles,
|
||||||
usedNvenc: willUseNvenc,
|
usedNvenc: willUseNvenc,
|
||||||
codecType: codec
|
codecType: codec,
|
||||||
|
format
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { execMP4Box } from '../utils';
|
import { execMP4Box } from '../utils';
|
||||||
import type { VideoProfile, CodecType } from '../types';
|
import type { VideoProfile, CodecType, StreamingFormat } from '../types';
|
||||||
|
import { readFile, writeFile, readdir, rename, mkdir } from 'node:fs/promises';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Package MP4 files into DASH format using MP4Box
|
* Package MP4 files into DASH format using MP4Box
|
||||||
@@ -158,3 +159,430 @@ async function updateManifestPaths(
|
|||||||
await writeFile(manifestPath, mpd, 'utf-8');
|
await writeFile(manifestPath, mpd, 'utf-8');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package MP4 files into HLS format using MP4Box
|
||||||
|
* Stage 2: Light work - just packaging, no encoding
|
||||||
|
* Creates master.m3u8 playlist with H.264 profiles only (for Safari/iOS compatibility)
|
||||||
|
*/
|
||||||
|
export async function packageToHLS(
|
||||||
|
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
segmentDuration: number,
|
||||||
|
codecType: CodecType
|
||||||
|
): Promise<string> {
|
||||||
|
const manifestPath = join(outputDir, 'master.m3u8');
|
||||||
|
|
||||||
|
// Build MP4Box command for HLS
|
||||||
|
const args = [
|
||||||
|
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
|
||||||
|
'-frag', String(segmentDuration * 1000),
|
||||||
|
'-rap', // Force segments to start with random access points
|
||||||
|
'-segment-timeline', // Use SegmentTimeline for accurate segment durations
|
||||||
|
'-segment-name', '$RepresentationID$_$Number$',
|
||||||
|
'-profile', 'live', // HLS mode instead of DASH
|
||||||
|
'-out', manifestPath
|
||||||
|
];
|
||||||
|
|
||||||
|
// For HLS, use only H.264 codec (Safari/iOS compatibility)
|
||||||
|
const h264Files = codecMP4Files.get('h264');
|
||||||
|
|
||||||
|
if (!h264Files) {
|
||||||
|
throw new Error('H.264 codec files not found. HLS requires H.264 for Safari/iOS compatibility.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let firstFile = true;
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const mp4Path = h264Files.get(profile.name);
|
||||||
|
if (!mp4Path) {
|
||||||
|
throw new Error(`MP4 file not found for profile: ${profile.name}, codec: h264`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Representation ID for HLS (no codec suffix since we only use H.264)
|
||||||
|
const representationId = profile.name;
|
||||||
|
|
||||||
|
// Add video track with representation ID
|
||||||
|
args.push(`${mp4Path}#video:id=${representationId}`);
|
||||||
|
|
||||||
|
// Add audio track only once (shared across all profiles)
|
||||||
|
if (firstFile) {
|
||||||
|
args.push(`${mp4Path}#audio:id=audio`);
|
||||||
|
firstFile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute MP4Box
|
||||||
|
await execMP4Box(args);
|
||||||
|
|
||||||
|
// MP4Box creates files in the same directory as output manifest
|
||||||
|
// Move segment files to profile subdirectories for clean structure
|
||||||
|
await organizeSegmentsHLS(outputDir, profiles);
|
||||||
|
|
||||||
|
// Update manifest to reflect new file structure with subdirectories
|
||||||
|
await updateManifestPathsHLS(manifestPath, profiles);
|
||||||
|
|
||||||
|
return manifestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organize HLS segments into profile subdirectories
|
||||||
|
* HLS only uses H.264, so no codec suffix in directory names
|
||||||
|
*/
|
||||||
|
async function organizeSegmentsHLS(
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[]
|
||||||
|
): Promise<void> {
|
||||||
|
const representationIds: string[] = [];
|
||||||
|
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const repId = profile.name; // Just profile name, no codec
|
||||||
|
representationIds.push(repId);
|
||||||
|
|
||||||
|
const profileDir = join(outputDir, repId);
|
||||||
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audio subdirectory
|
||||||
|
const audioDir = join(outputDir, 'audio');
|
||||||
|
await mkdir(audioDir, { recursive: true });
|
||||||
|
|
||||||
|
// Get all files in output directory
|
||||||
|
const files = await readdir(outputDir);
|
||||||
|
|
||||||
|
// Move segment files to their respective directories
|
||||||
|
for (const file of files) {
|
||||||
|
// Skip manifest
|
||||||
|
if (file === 'master.m3u8') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move audio files to audio/ directory
|
||||||
|
if (file.startsWith('audio_') || file === 'audio_init.m4s') {
|
||||||
|
const oldPath = join(outputDir, file);
|
||||||
|
const newPath = join(audioDir, file);
|
||||||
|
await rename(oldPath, newPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move video segment files to their representation directories
|
||||||
|
for (const repId of representationIds) {
|
||||||
|
if (file.startsWith(`${repId}_`)) {
|
||||||
|
const oldPath = join(outputDir, file);
|
||||||
|
const newPath = join(outputDir, repId, file);
|
||||||
|
await rename(oldPath, newPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update HLS master manifest to reflect subdirectory structure
|
||||||
|
*/
|
||||||
|
async function updateManifestPathsHLS(
|
||||||
|
manifestPath: string,
|
||||||
|
profiles: VideoProfile[]
|
||||||
|
): Promise<void> {
|
||||||
|
let m3u8 = await readFile(manifestPath, 'utf-8');
|
||||||
|
|
||||||
|
// MP4Box uses $RepresentationID$ template variable
|
||||||
|
// Replace: media="$RepresentationID$_$Number$.m4s"
|
||||||
|
// With: media="$RepresentationID$/$RepresentationID$_$Number$.m4s"
|
||||||
|
|
||||||
|
m3u8 = m3u8.replace(
|
||||||
|
/media="\$RepresentationID\$_\$Number\$\.m4s"/g,
|
||||||
|
'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Replace: initialization="$RepresentationID$_.mp4"
|
||||||
|
// With: initialization="$RepresentationID$/$RepresentationID$_.mp4"
|
||||||
|
|
||||||
|
m3u8 = m3u8.replace(
|
||||||
|
/initialization="\$RepresentationID\$_\.mp4"/g,
|
||||||
|
'initialization="$RepresentationID$/$RepresentationID$_.mp4"'
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(manifestPath, m3u8, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified packaging: creates segments once and generates both DASH and HLS manifests
|
||||||
|
* No duplication - segments stored in {profile}-{codec}/ folders
|
||||||
|
*/
|
||||||
|
export async function packageToFormats(
|
||||||
|
codecMP4Files: Map<'h264' | 'av1', Map<string, string>>,
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
segmentDuration: number,
|
||||||
|
codec: CodecType,
|
||||||
|
format: StreamingFormat
|
||||||
|
): Promise<{ manifestPath?: string; hlsManifestPath?: string }> {
|
||||||
|
|
||||||
|
// Step 1: Generate segments using MP4Box (DASH mode)
|
||||||
|
const tempManifestPath = join(outputDir, '.temp_manifest.mpd');
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-dash', String(segmentDuration * 1000),
|
||||||
|
'-frag', String(segmentDuration * 1000),
|
||||||
|
'-rap',
|
||||||
|
'-segment-timeline',
|
||||||
|
'-segment-name', '$RepresentationID$_$Number$',
|
||||||
|
'-out', tempManifestPath
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add all MP4 files
|
||||||
|
let firstFile = true;
|
||||||
|
|
||||||
|
for (const [codecType, mp4Files] of codecMP4Files.entries()) {
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const mp4Path = mp4Files.get(profile.name);
|
||||||
|
if (!mp4Path) {
|
||||||
|
throw new Error(`MP4 file not found for profile: ${profile.name}, codec: ${codecType}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const representationId = codec === 'dual' ? `${profile.name}-${codecType}` : profile.name;
|
||||||
|
|
||||||
|
args.push(`${mp4Path}#video:id=${representationId}`);
|
||||||
|
|
||||||
|
if (firstFile) {
|
||||||
|
args.push(`${mp4Path}#audio:id=audio`);
|
||||||
|
firstFile = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute MP4Box to create segments
|
||||||
|
await execMP4Box(args);
|
||||||
|
|
||||||
|
// Step 2: Organize segments into {profile}-{codec}/ folders
|
||||||
|
await organizeSegmentsUnified(outputDir, profiles, codec);
|
||||||
|
|
||||||
|
// Step 3: Generate manifests based on format
|
||||||
|
let manifestPath: string | undefined;
|
||||||
|
let hlsManifestPath: string | undefined;
|
||||||
|
|
||||||
|
if (format === 'dash' || format === 'both') {
|
||||||
|
// Move and update DASH manifest
|
||||||
|
manifestPath = join(outputDir, 'manifest.mpd');
|
||||||
|
await rename(tempManifestPath, manifestPath);
|
||||||
|
await updateDashManifestPaths(manifestPath, profiles, codec);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (format === 'hls' || format === 'both') {
|
||||||
|
// Generate HLS playlists
|
||||||
|
hlsManifestPath = await generateHLSPlaylists(
|
||||||
|
outputDir,
|
||||||
|
profiles,
|
||||||
|
segmentDuration,
|
||||||
|
codec
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp manifest if not used
|
||||||
|
try {
|
||||||
|
const { unlink } = await import('node:fs/promises');
|
||||||
|
await unlink(tempManifestPath);
|
||||||
|
} catch {
|
||||||
|
// Already moved or doesn't exist
|
||||||
|
}
|
||||||
|
|
||||||
|
return { manifestPath, hlsManifestPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organize segments into unified structure: {profile}-{codec}/
|
||||||
|
*/
|
||||||
|
async function organizeSegmentsUnified(
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
codecType: CodecType
|
||||||
|
): Promise<void> {
|
||||||
|
const representationIds: string[] = [];
|
||||||
|
|
||||||
|
// Determine which codecs are used
|
||||||
|
const codecs: Array<'h264' | 'av1'> = [];
|
||||||
|
if (codecType === 'h264' || codecType === 'dual') codecs.push('h264');
|
||||||
|
if (codecType === 'av1' || codecType === 'dual') codecs.push('av1');
|
||||||
|
|
||||||
|
// Create directories for each profile-codec combination
|
||||||
|
for (const codecName of codecs) {
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const repId = codecType === 'dual' ? `${profile.name}-${codecName}` : profile.name;
|
||||||
|
representationIds.push(repId);
|
||||||
|
|
||||||
|
const profileDir = join(outputDir, repId);
|
||||||
|
await mkdir(profileDir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create audio directory
|
||||||
|
const audioDir = join(outputDir, 'audio');
|
||||||
|
await mkdir(audioDir, { recursive: true });
|
||||||
|
|
||||||
|
// Get all files in output directory
|
||||||
|
const files = await readdir(outputDir);
|
||||||
|
|
||||||
|
// Move segment files to their respective directories
|
||||||
|
for (const file of files) {
|
||||||
|
// Skip manifests and directories
|
||||||
|
if (file.endsWith('.mpd') || file.endsWith('.m3u8') || !file.includes('_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move audio files
|
||||||
|
if (file.startsWith('audio_')) {
|
||||||
|
const oldPath = join(outputDir, file);
|
||||||
|
const newPath = join(audioDir, file);
|
||||||
|
await rename(oldPath, newPath);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move video segment files
|
||||||
|
for (const repId of representationIds) {
|
||||||
|
if (file.startsWith(`${repId}_`)) {
|
||||||
|
const oldPath = join(outputDir, file);
|
||||||
|
const newPath = join(outputDir, repId, file);
|
||||||
|
await rename(oldPath, newPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update DASH manifest paths to point to {profile}-{codec}/ folders
|
||||||
|
*/
|
||||||
|
async function updateDashManifestPaths(
|
||||||
|
manifestPath: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
codecType: CodecType
|
||||||
|
): Promise<void> {
|
||||||
|
let mpd = await readFile(manifestPath, 'utf-8');
|
||||||
|
|
||||||
|
// Update paths: $RepresentationID$_$Number$.m4s → $RepresentationID$/$RepresentationID$_$Number$.m4s
|
||||||
|
mpd = mpd.replace(
|
||||||
|
/media="\$RepresentationID\$_\$Number\$\.m4s"/g,
|
||||||
|
'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"'
|
||||||
|
);
|
||||||
|
|
||||||
|
mpd = mpd.replace(
|
||||||
|
/initialization="\$RepresentationID\$_\.mp4"/g,
|
||||||
|
'initialization="$RepresentationID$/$RepresentationID$_.mp4"'
|
||||||
|
);
|
||||||
|
|
||||||
|
await writeFile(manifestPath, mpd, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate HLS playlists (media playlists in folders + master in root)
|
||||||
|
*/
|
||||||
|
async function generateHLSPlaylists(
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
segmentDuration: number,
|
||||||
|
codecType: CodecType
|
||||||
|
): Promise<string> {
|
||||||
|
const masterPlaylistPath = join(outputDir, 'master.m3u8');
|
||||||
|
const variants: Array<{ path: string; bandwidth: number; resolution: string; fps: number }> = [];
|
||||||
|
|
||||||
|
// Generate media playlist for each H.264 profile
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const profileDir = codecType === 'dual' ? `${profile.name}-h264` : profile.name;
|
||||||
|
const profilePath = join(outputDir, profileDir);
|
||||||
|
|
||||||
|
// Read segment files from profile directory
|
||||||
|
const files = await readdir(profilePath);
|
||||||
|
const segmentFiles = files
|
||||||
|
.filter(f => f.endsWith('.m4s'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0');
|
||||||
|
const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0');
|
||||||
|
return numA - numB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const initFile = files.find(f => f.endsWith('_.mp4'));
|
||||||
|
|
||||||
|
if (!initFile || segmentFiles.length === 0) {
|
||||||
|
continue; // Skip if no segments found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate media playlist content
|
||||||
|
let playlistContent = '#EXTM3U\n';
|
||||||
|
playlistContent += `#EXT-X-VERSION:6\n`;
|
||||||
|
playlistContent += `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}\n`;
|
||||||
|
playlistContent += `#EXT-X-MEDIA-SEQUENCE:1\n`;
|
||||||
|
playlistContent += `#EXT-X-INDEPENDENT-SEGMENTS\n`;
|
||||||
|
playlistContent += `#EXT-X-MAP:URI="${initFile}"\n`;
|
||||||
|
|
||||||
|
for (const segmentFile of segmentFiles) {
|
||||||
|
playlistContent += `#EXTINF:${segmentDuration},\n`;
|
||||||
|
playlistContent += `${segmentFile}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
playlistContent += `#EXT-X-ENDLIST\n`;
|
||||||
|
|
||||||
|
// Write media playlist
|
||||||
|
const playlistPath = join(profilePath, 'playlist.m3u8');
|
||||||
|
await writeFile(playlistPath, playlistContent, 'utf-8');
|
||||||
|
|
||||||
|
// Add to variants list
|
||||||
|
const bandwidth = parseInt(profile.videoBitrate) * 1000;
|
||||||
|
variants.push({
|
||||||
|
path: `${profileDir}/playlist.m3u8`,
|
||||||
|
bandwidth,
|
||||||
|
resolution: `${profile.width}x${profile.height}`,
|
||||||
|
fps: profile.fps || 30
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate audio media playlist
|
||||||
|
const audioDir = join(outputDir, 'audio');
|
||||||
|
const audioFiles = await readdir(audioDir);
|
||||||
|
const audioSegments = audioFiles
|
||||||
|
.filter(f => f.endsWith('.m4s'))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0');
|
||||||
|
const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0');
|
||||||
|
return numA - numB;
|
||||||
|
});
|
||||||
|
|
||||||
|
const audioInit = audioFiles.find(f => f.endsWith('_.mp4'));
|
||||||
|
|
||||||
|
if (audioInit && audioSegments.length > 0) {
|
||||||
|
let audioPlaylistContent = '#EXTM3U\n';
|
||||||
|
audioPlaylistContent += `#EXT-X-VERSION:6\n`;
|
||||||
|
audioPlaylistContent += `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}\n`;
|
||||||
|
audioPlaylistContent += `#EXT-X-MEDIA-SEQUENCE:1\n`;
|
||||||
|
audioPlaylistContent += `#EXT-X-INDEPENDENT-SEGMENTS\n`;
|
||||||
|
audioPlaylistContent += `#EXT-X-MAP:URI="${audioInit}"\n`;
|
||||||
|
|
||||||
|
for (const segmentFile of audioSegments) {
|
||||||
|
audioPlaylistContent += `#EXTINF:${segmentDuration},\n`;
|
||||||
|
audioPlaylistContent += `${segmentFile}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
audioPlaylistContent += `#EXT-X-ENDLIST\n`;
|
||||||
|
|
||||||
|
await writeFile(join(audioDir, 'playlist.m3u8'), audioPlaylistContent, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate master playlist
|
||||||
|
let masterContent = '#EXTM3U\n';
|
||||||
|
masterContent += '#EXT-X-VERSION:6\n';
|
||||||
|
masterContent += '#EXT-X-INDEPENDENT-SEGMENTS\n\n';
|
||||||
|
|
||||||
|
// Add audio reference
|
||||||
|
masterContent += `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="audio",AUTOSELECT=YES,URI="audio/playlist.m3u8",CHANNELS="2"\n\n`;
|
||||||
|
|
||||||
|
// Add video variants
|
||||||
|
for (const variant of variants) {
|
||||||
|
masterContent += `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},CODECS="avc1.4D4020,mp4a.40.2",RESOLUTION=${variant.resolution},FRAME-RATE=${variant.fps},AUDIO="audio"\n`;
|
||||||
|
masterContent += `${variant.path}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await writeFile(masterPlaylistPath, masterContent, 'utf-8');
|
||||||
|
|
||||||
|
return masterPlaylistPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,11 @@
|
|||||||
*/
|
*/
|
||||||
export type CodecType = 'av1' | 'h264' | 'dual';
|
export type CodecType = 'av1' | 'h264' | 'dual';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Streaming format type
|
||||||
|
*/
|
||||||
|
export type StreamingFormat = 'dash' | 'hls' | 'both';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration options for DASH conversion
|
* Configuration options for DASH conversion
|
||||||
*/
|
*/
|
||||||
@@ -25,6 +30,9 @@ export interface DashConvertOptions {
|
|||||||
/** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */
|
/** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */
|
||||||
codec?: CodecType;
|
codec?: CodecType;
|
||||||
|
|
||||||
|
/** Streaming format to generate: 'dash', 'hls', or 'both' (default: 'both') */
|
||||||
|
format?: StreamingFormat;
|
||||||
|
|
||||||
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
||||||
useNvenc?: boolean;
|
useNvenc?: boolean;
|
||||||
|
|
||||||
@@ -111,8 +119,11 @@ export interface ConversionProgress {
|
|||||||
* Result of DASH conversion
|
* Result of DASH conversion
|
||||||
*/
|
*/
|
||||||
export interface DashConvertResult {
|
export interface DashConvertResult {
|
||||||
/** Path to generated MPD manifest */
|
/** Path to generated DASH manifest (if format is 'dash' or 'both') */
|
||||||
manifestPath: string;
|
manifestPath?: string;
|
||||||
|
|
||||||
|
/** Path to generated HLS manifest (if format is 'hls' or 'both') */
|
||||||
|
hlsManifestPath?: string;
|
||||||
|
|
||||||
/** Paths to generated video segments */
|
/** Paths to generated video segments */
|
||||||
videoPaths: string[];
|
videoPaths: string[];
|
||||||
@@ -137,6 +148,9 @@ export interface DashConvertResult {
|
|||||||
|
|
||||||
/** Codec type used for encoding */
|
/** Codec type used for encoding */
|
||||||
codecType: CodecType;
|
codecType: CodecType;
|
||||||
|
|
||||||
|
/** Streaming format generated */
|
||||||
|
format: StreamingFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user