fix: Исправление ошибок конвертации dash
This commit is contained in:
94
bin/cli.js
94
bin/cli.js
File diff suppressed because one or more lines are too long
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)
|
||||
|
||||
# Пример: dvc-cli уже реализует это
|
||||
dvc-cli 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 сек
|
||||
|
||||
73
src/cli.ts
73
src/cli.ts
@@ -13,7 +13,7 @@
|
||||
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, checkAV1Support, getVideoMetadata } from './index';
|
||||
import cliProgress from 'cli-progress';
|
||||
import { statSync } from 'node:fs';
|
||||
import type { CodecType, StreamingFormat } from './types';
|
||||
import type { CodecType, StreamingFormat, QualitySettings } from './types';
|
||||
|
||||
// Parse arguments
|
||||
const args = process.argv.slice(2);
|
||||
@@ -23,6 +23,12 @@ 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;
|
||||
|
||||
// First pass: extract flags and their values
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '-r' || args[i] === '--resolutions') {
|
||||
@@ -64,6 +70,34 @@ for (let i = 0; i < args.length; i++) {
|
||||
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].startsWith('-')) {
|
||||
// Positional argument
|
||||
positionalArgs.push(args[i]);
|
||||
@@ -75,23 +109,28 @@ const input = positionalArgs[0];
|
||||
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
|
||||
|
||||
if (!input) {
|
||||
console.error('❌ Usage: dvc-cli <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]');
|
||||
console.error('❌ Usage: dvc-cli <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: dash)');
|
||||
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('\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(' dvc-cli video.mp4');
|
||||
console.error(' dvc-cli video.mp4 ./output');
|
||||
console.error(' dvc-cli video.mp4 -r 360,480,720');
|
||||
console.error(' dvc-cli video.mp4 -c av1');
|
||||
console.error(' dvc-cli video.mp4 -c av1 --av1-cq 40');
|
||||
console.error(' dvc-cli video.mp4 -c dual --h264-cq 30 --av1-cq 39');
|
||||
console.error(' dvc-cli video.mp4 -f hls');
|
||||
console.error(' dvc-cli video.mp4 -f both');
|
||||
console.error(' dvc-cli video.mp4 -c dual -f both');
|
||||
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash');
|
||||
console.error(' dvc-cli video.mp4 -p 00:00:05');
|
||||
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -f both -p 10');
|
||||
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -f both -p 10 --h264-cq 28 --av1-cq 37');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -163,6 +202,27 @@ if (customProfiles) {
|
||||
if (posterTimecode) {
|
||||
console.log(`🖼️ Poster timecode: ${posterTimecode}`);
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -189,6 +249,7 @@ try {
|
||||
format: formatType,
|
||||
segmentDuration: 2,
|
||||
useNvenc: hasNvenc,
|
||||
quality,
|
||||
generateThumbnails: true,
|
||||
generatePoster: true,
|
||||
parallel: true,
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
checkNvenc,
|
||||
checkAV1Support,
|
||||
getVideoMetadata,
|
||||
ensureDir
|
||||
ensureDir,
|
||||
setLogFile
|
||||
} from '../utils';
|
||||
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
|
||||
import { generateThumbnailSprite, generatePoster } from './thumbnails';
|
||||
@@ -39,6 +40,7 @@ export async function convertToDash(
|
||||
codec = 'dual',
|
||||
format = 'both',
|
||||
useNvenc,
|
||||
quality,
|
||||
generateThumbnails = true,
|
||||
thumbnailConfig = {},
|
||||
generatePoster: shouldGeneratePoster = true,
|
||||
@@ -51,6 +53,27 @@ export async function convertToDash(
|
||||
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,
|
||||
@@ -62,6 +85,7 @@ export async function convertToDash(
|
||||
codec,
|
||||
format,
|
||||
useNvenc,
|
||||
quality,
|
||||
generateThumbnails,
|
||||
thumbnailConfig,
|
||||
shouldGeneratePoster,
|
||||
@@ -70,6 +94,14 @@ export async function convertToDash(
|
||||
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 });
|
||||
@@ -92,6 +124,7 @@ async function convertToDashInternal(
|
||||
codec: CodecType,
|
||||
format: StreamingFormat,
|
||||
useNvenc: boolean | undefined,
|
||||
quality: DashConvertOptions['quality'],
|
||||
generateThumbnails: boolean,
|
||||
thumbnailConfig: ThumbnailConfig,
|
||||
generatePosterFlag: boolean,
|
||||
@@ -227,6 +260,9 @@ async function convertToDashInternal(
|
||||
|
||||
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,
|
||||
@@ -239,6 +275,7 @@ async function convertToDashInternal(
|
||||
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);
|
||||
|
||||
@@ -1,6 +1,41 @@
|
||||
import { join } from 'node:path';
|
||||
import { execFFmpeg, selectAudioBitrate } from '../utils';
|
||||
import type { VideoProfile, VideoOptimizations } from '../types';
|
||||
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
|
||||
@@ -16,6 +51,7 @@ export async function encodeProfileToMP4(
|
||||
segmentDuration: number,
|
||||
sourceAudioBitrate: number | undefined,
|
||||
codecType: 'h264' | 'av1',
|
||||
qualitySettings?: CodecQualitySettings,
|
||||
optimizations?: VideoOptimizations,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<string> {
|
||||
@@ -27,45 +63,63 @@ export async function encodeProfileToMP4(
|
||||
'-c:v', videoCodec
|
||||
];
|
||||
|
||||
// Add codec-specific options
|
||||
// Determine if using GPU or CPU encoder
|
||||
const isGPU = videoCodec.includes('nvenc') || videoCodec.includes('qsv') || videoCodec.includes('amf');
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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', '23'); // Quality level for QSV
|
||||
args.push('-global_quality', String(qualityValue));
|
||||
} else if (videoCodec === 'av1_amf') {
|
||||
// AMD AMF AV1
|
||||
args.push('-quality', 'balanced');
|
||||
args.push('-rc', 'vbr_latency');
|
||||
args.push('-rc', 'cqp');
|
||||
args.push('-qp_i', String(qualityValue));
|
||||
args.push('-qp_p', String(qualityValue));
|
||||
} else if (videoCodec === 'libsvtav1') {
|
||||
// CPU-based SVT-AV1
|
||||
// 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 (libx264, libx265, etc.)
|
||||
// Default fallback
|
||||
args.push('-preset', preset);
|
||||
}
|
||||
|
||||
// Video encoding parameters
|
||||
// AV1 is ~40% more efficient than H.264 at same quality (Netflix/YouTube standard)
|
||||
// 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 targetBitrate = Math.round(parseInt(profile.videoBitrate) * bitrateMultiplier);
|
||||
const bitrateString = `${targetBitrate}k`;
|
||||
|
||||
args.push(
|
||||
'-b:v', bitrateString,
|
||||
'-maxrate', bitrateString,
|
||||
'-bufsize', `${targetBitrate * 2}k`
|
||||
);
|
||||
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
|
||||
@@ -130,6 +184,7 @@ export async function encodeProfilesToMP4(
|
||||
parallel: boolean,
|
||||
maxConcurrent: number,
|
||||
codecType: 'h264' | 'av1',
|
||||
qualitySettings?: CodecQualitySettings,
|
||||
optimizations?: VideoOptimizations,
|
||||
onProgress?: (profileName: string, percent: number) => void
|
||||
): Promise<Map<string, string>> {
|
||||
@@ -150,6 +205,7 @@ export async function encodeProfilesToMP4(
|
||||
segmentDuration,
|
||||
sourceAudioBitrate,
|
||||
codecType,
|
||||
qualitySettings,
|
||||
optimizations,
|
||||
(percent) => {
|
||||
if (onProgress) {
|
||||
@@ -178,6 +234,7 @@ export async function encodeProfilesToMP4(
|
||||
segmentDuration,
|
||||
sourceAudioBitrate,
|
||||
codecType,
|
||||
qualitySettings,
|
||||
optimizations,
|
||||
(percent) => {
|
||||
if (onProgress) {
|
||||
|
||||
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) {
|
||||
content += `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},CODECS="avc1.4D4020,mp4a.40.2",RESOLUTION=${variant.resolution},FRAME-RATE=${variant.fps}`;
|
||||
if (hasAudio) {
|
||||
content += `,AUDIO="audio"`;
|
||||
}
|
||||
content += `\n`;
|
||||
content += `${variant.path}\n\n`;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { join } from 'node:path';
|
||||
import { execMP4Box } from '../utils';
|
||||
import type { VideoProfile, CodecType, StreamingFormat } from '../types';
|
||||
import { readFile, writeFile, readdir, rename, mkdir } from 'node:fs/promises';
|
||||
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
|
||||
@@ -52,6 +60,7 @@ export async function packageToDash(
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -61,6 +70,14 @@ export async function packageToDash(
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -127,38 +144,6 @@ async function organizeSegments(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update MPD manifest to reflect subdirectory structure
|
||||
*/
|
||||
async function updateManifestPaths(
|
||||
manifestPath: string,
|
||||
profiles: VideoProfile[],
|
||||
codecType: CodecType
|
||||
): Promise<void> {
|
||||
const { readFile, writeFile } = await import('node:fs/promises');
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* Package MP4 files into HLS format using MP4Box
|
||||
* Stage 2: Light work - just packaging, no encoding
|
||||
@@ -220,7 +205,7 @@ export async function packageToHLS(
|
||||
await organizeSegmentsHLS(outputDir, profiles);
|
||||
|
||||
// Update manifest to reflect new file structure with subdirectories
|
||||
await updateManifestPathsHLS(manifestPath, profiles);
|
||||
await updateHLSManifestPaths(manifestPath, profiles);
|
||||
|
||||
return manifestPath;
|
||||
}
|
||||
@@ -277,35 +262,6 @@ async function organizeSegmentsHLS(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -319,58 +275,17 @@ export async function packageToFormats(
|
||||
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;
|
||||
|
||||
// Step 1: Generate DASH segments and manifest using MP4Box
|
||||
if (format === 'dash' || format === 'both') {
|
||||
// Move and update DASH manifest
|
||||
manifestPath = join(outputDir, 'manifest.mpd');
|
||||
await rename(tempManifestPath, manifestPath);
|
||||
await updateDashManifestPaths(manifestPath, profiles, codec);
|
||||
manifestPath = await packageToDash(codecMP4Files, outputDir, profiles, segmentDuration, codec);
|
||||
}
|
||||
|
||||
// Step 2: Generate HLS playlists from existing segments
|
||||
if (format === 'hls' || format === 'both') {
|
||||
// Generate HLS playlists
|
||||
// HLS generation from segments
|
||||
hlsManifestPath = await generateHLSPlaylists(
|
||||
outputDir,
|
||||
profiles,
|
||||
@@ -379,101 +294,9 @@ export async function packageToFormats(
|
||||
);
|
||||
}
|
||||
|
||||
// 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)
|
||||
*/
|
||||
@@ -507,20 +330,8 @@ async function generateHLSPlaylists(
|
||||
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`;
|
||||
// Generate media playlist content using manifest module
|
||||
const playlistContent = generateHLSMediaPlaylist(segmentFiles, initFile, segmentDuration);
|
||||
|
||||
// Write media playlist
|
||||
const playlistPath = join(profilePath, 'playlist.m3u8');
|
||||
@@ -550,37 +361,12 @@ async function generateHLSPlaylists(
|
||||
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`;
|
||||
|
||||
const audioPlaylistContent = generateHLSMediaPlaylist(audioSegments, audioInit, segmentDuration);
|
||||
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`;
|
||||
}
|
||||
|
||||
// Generate master playlist using manifest module
|
||||
const masterContent = generateHLSMasterPlaylist(variants, audioInit !== undefined && audioSegments.length > 0);
|
||||
await writeFile(masterPlaylistPath, masterContent, 'utf-8');
|
||||
|
||||
return masterPlaylistPath;
|
||||
|
||||
@@ -8,6 +8,28 @@ export type CodecType = 'av1' | 'h264' | 'dual';
|
||||
*/
|
||||
export type StreamingFormat = 'dash' | 'hls' | 'both';
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -36,6 +58,9 @@ export interface DashConvertOptions {
|
||||
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
||||
useNvenc?: boolean;
|
||||
|
||||
/** Quality settings for video encoding (CQ/CRF values) */
|
||||
quality?: QualitySettings;
|
||||
|
||||
/** Generate thumbnail sprite (default: true) */
|
||||
generateThumbnails?: boolean;
|
||||
|
||||
|
||||
@@ -5,7 +5,8 @@ export {
|
||||
checkNvenc,
|
||||
checkAV1Support,
|
||||
execFFmpeg,
|
||||
execMP4Box
|
||||
execMP4Box,
|
||||
setLogFile
|
||||
} from './system';
|
||||
|
||||
// Video utilities
|
||||
|
||||
@@ -1,4 +1,28 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import { appendFile } from 'node:fs/promises';
|
||||
|
||||
// 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
|
||||
@@ -22,6 +46,17 @@ export async function checkMP4Box(): Promise<boolean> {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@@ -89,6 +124,10 @@ export async function execFFmpeg(
|
||||
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);
|
||||
|
||||
@@ -113,13 +152,20 @@ export async function execFFmpeg(
|
||||
});
|
||||
|
||||
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}`));
|
||||
}
|
||||
});
|
||||
@@ -130,6 +176,10 @@ export async function execFFmpeg(
|
||||
* 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);
|
||||
|
||||
@@ -145,14 +195,22 @@ export async function execMP4Box(args: string[]): Promise<void> {
|
||||
});
|
||||
|
||||
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}`));
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user