add: Фоллбек совместимость HLS

This commit is contained in:
2025-11-11 22:56:44 +03:00
parent 8cf4210d20
commit 3b54c059f0
6 changed files with 900 additions and 31 deletions

View File

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

385
docs/CLI_REFERENCE.md Normal file
View File

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

View File

@@ -13,13 +13,14 @@
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, checkAV1Support, getVideoMetadata } from './index';
import cliProgress from 'cli-progress';
import { statSync } from 'node:fs';
import type { CodecType } from './types';
import type { CodecType, StreamingFormat } from './types';
// Parse arguments
const args = process.argv.slice(2);
let customProfiles: string[] | undefined;
let posterTimecode: string | undefined;
let codecType: CodecType = 'dual'; // Default to dual codec
let formatType: StreamingFormat = 'both'; // Default to both formats (DASH + HLS)
const positionalArgs: string[] = [];
// First pass: extract flags and their values
@@ -54,6 +55,15 @@ for (let i = 0; i < args.length; i++) {
process.exit(1);
}
i++; // Skip next arg
} else if (args[i] === '-f' || args[i] === '--format') {
const format = args[i + 1];
if (format === 'dash' || format === 'hls' || format === 'both') {
formatType = format;
} else {
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls, both`);
process.exit(1);
}
i++; // Skip next arg
} else if (!args[i].startsWith('-')) {
// Positional argument
positionalArgs.push(args[i]);
@@ -65,21 +75,23 @@ const input = positionalArgs[0];
const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию
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(' -r, --resolutions Video resolutions (e.g., 360,480,720 or 720@60,1080@60)');
console.error(' -c, --codec Video codec: av1, h264, or dual (default: dual)');
console.error(' -f, --format Streaming format: dash, hls, or both (default: dash)');
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
console.error('\nExamples:');
console.error(' dvc-cli video.mp4');
console.error(' dvc-cli video.mp4 ./output');
console.error(' dvc-cli video.mp4 -r 360,480,720');
console.error(' dvc-cli video.mp4 -c av1');
console.error(' dvc-cli video.mp4 -c h264');
console.error(' dvc-cli video.mp4 -c dual');
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1');
console.error(' dvc-cli video.mp4 -f hls');
console.error(' dvc-cli video.mp4 -f both');
console.error(' dvc-cli video.mp4 -c dual -f both');
console.error(' dvc-cli video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash');
console.error(' dvc-cli video.mp4 -p 00:00:05');
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -p 10');
console.error(' dvc-cli video.mp4 ./output -r 720,1080 -c dual -f both -p 10');
process.exit(1);
}
@@ -116,6 +128,13 @@ if ((codecType === 'av1' || codecType === 'dual') && !av1Support.available) {
console.error(` Consider using --codec h264 for faster encoding.\n`);
}
// Validate HLS requires H.264
if ((formatType === 'hls' || formatType === 'both') && codecType === 'av1') {
console.error(`❌ Error: HLS format requires H.264 codec for Safari/iOS compatibility.`);
console.error(` Please use --codec h264 or --codec dual with --format hls\n`);
process.exit(1);
}
// Get video metadata and file size
console.log('📊 Analyzing video...\n');
const metadata = await getVideoMetadata(input);
@@ -137,6 +156,7 @@ if (metadata.audioBitrate) {
}
console.log(`\n📁 Output: ${outputDir}`);
console.log(`🎬 Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264 for maximum compatibility)' : ''}`);
console.log(`📺 Format: ${formatType}${formatType === 'both' ? ' (DASH + HLS for maximum compatibility)' : formatType === 'hls' ? ' (H.264 only for Safari/iOS)' : ''}`);
if (customProfiles) {
console.log(`🎯 Custom profiles: ${customProfiles.join(', ')}`);
}
@@ -166,6 +186,7 @@ try {
customProfiles,
posterTimecode,
codec: codecType,
format: formatType,
segmentDuration: 2,
useNvenc: hasNvenc,
generateThumbnails: true,
@@ -212,9 +233,18 @@ try {
console.log('\n✅ Conversion completed successfully!\n');
console.log('📊 Results:');
console.log(` Manifest: ${result.manifestPath}`);
if (result.manifestPath) {
console.log(` DASH Manifest: ${result.manifestPath}`);
}
if (result.hlsManifestPath) {
console.log(` HLS Manifest: ${result.hlsManifestPath}`);
}
console.log(` Duration: ${result.duration.toFixed(2)}s`);
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
console.log(` Format: ${result.format}`);
console.log(` Codec: ${result.codecType}${result.codecType === 'dual' ? ' (AV1 + H.264)' : ''}`);
console.log(` Encoder: ${result.usedNvenc ? '⚡ GPU accelerated' : '🔧 CPU'}`);
@@ -227,7 +257,7 @@ try {
console.log(` VTT file: ${result.thumbnailVttPath}`);
}
console.log('\n🎉 Done! You can now use the manifest file in your video player.');
console.log('\n🎉 Done! You can now use the manifest file(s) in your video player.');
} catch (error) {
multibar.stop();

View File

@@ -7,7 +7,8 @@ import type {
VideoProfile,
ThumbnailConfig,
ConversionProgress,
CodecType
CodecType,
StreamingFormat
} from '../types';
import {
checkFFmpeg,
@@ -20,7 +21,7 @@ import {
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
import { generateThumbnailSprite, generatePoster } from './thumbnails';
import { encodeProfilesToMP4 } from './encoding';
import { packageToDash } from './packaging';
import { packageToFormats } from './packaging';
/**
* Convert video to DASH format with NVENC acceleration
@@ -36,6 +37,7 @@ export async function convertToDash(
profiles: userProfiles,
customProfiles,
codec = 'dual',
format = 'both',
useNvenc,
generateThumbnails = true,
thumbnailConfig = {},
@@ -58,6 +60,7 @@ export async function convertToDash(
userProfiles,
customProfiles,
codec,
format,
useNvenc,
generateThumbnails,
thumbnailConfig,
@@ -87,6 +90,7 @@ async function convertToDashInternal(
userProfiles: VideoProfile[] | undefined,
customProfiles: string[] | undefined,
codec: CodecType,
format: StreamingFormat,
useNvenc: boolean | undefined,
generateThumbnails: boolean,
thumbnailConfig: ThumbnailConfig,
@@ -260,15 +264,16 @@ async function convertToDashInternal(
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
// STAGE 2: Package to DASH using MP4Box (light work, fast)
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
// STAGE 2: Package to segments and manifests (unified, no duplication)
reportProgress('encoding', 70, `Stage 2: Creating segments and manifests...`);
const manifestPath = await packageToDash(
const { manifestPath, hlsManifestPath } = await packageToFormats(
codecMP4Paths,
videoOutputDir,
profiles,
segmentDuration,
codec
codec,
format
);
// Collect all video paths from all codecs
@@ -277,7 +282,7 @@ async function convertToDashInternal(
videoPaths.push(...Array.from(mp4Paths.values()));
}
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
reportProgress('encoding', 80, 'Stage 2 complete: All formats packaged');
// Generate thumbnails
let thumbnailSpritePath: string | undefined;
@@ -321,16 +326,17 @@ async function convertToDashInternal(
reportProgress('thumbnails', 95, 'Poster generated');
}
// Generate MPD manifest
reportProgress('manifest', 95, 'Finalizing manifest...');
// Finalize
reportProgress('manifest', 95, 'Finalizing...');
// Note: manifestPath is already created by MP4Box in packageToDash
// Note: manifestPath/hlsManifestPath are already created by MP4Box in packageToDash/packageToHLS
// No need for separate generateManifest function
reportProgress('complete', 100, 'Conversion complete!');
return {
manifestPath,
hlsManifestPath,
videoPaths,
thumbnailSpritePath,
thumbnailVttPath,
@@ -338,7 +344,8 @@ async function convertToDashInternal(
duration: metadata.duration,
profiles,
usedNvenc: willUseNvenc,
codecType: codec
codecType: codec,
format
};
}

View File

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

View File

@@ -3,6 +3,11 @@
*/
export type CodecType = 'av1' | 'h264' | 'dual';
/**
* Streaming format type
*/
export type StreamingFormat = 'dash' | 'hls' | 'both';
/**
* Configuration options for DASH conversion
*/
@@ -25,6 +30,9 @@ export interface DashConvertOptions {
/** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */
codec?: CodecType;
/** Streaming format to generate: 'dash', 'hls', or 'both' (default: 'both') */
format?: StreamingFormat;
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
useNvenc?: boolean;
@@ -111,8 +119,11 @@ export interface ConversionProgress {
* Result of DASH conversion
*/
export interface DashConvertResult {
/** Path to generated MPD manifest */
manifestPath: string;
/** Path to generated DASH manifest (if format is 'dash' or 'both') */
manifestPath?: string;
/** Path to generated HLS manifest (if format is 'hls' or 'both') */
hlsManifestPath?: string;
/** Paths to generated video segments */
videoPaths: string[];
@@ -137,6 +148,9 @@ export interface DashConvertResult {
/** Codec type used for encoding */
codecType: CodecType;
/** Streaming format generated */
format: StreamingFormat;
}
/**