diff --git a/README.md b/README.md index 419b73e..f3c57f4 100644 --- a/README.md +++ b/README.md @@ -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 [output-dir] [-r resolutions] [-p poster-timecode] +dvc-cli [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode] ``` ### Основные параметры @@ -47,12 +47,14 @@ dvc-cli [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 ``` ### Поддерживаемые разрешения diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md new file mode 100644 index 0000000..ecdd7fb --- /dev/null +++ b/docs/CLI_REFERENCE.md @@ -0,0 +1,385 @@ +# CLI Reference — Справочник команд + +Полное руководство по использованию DASH Video Converter CLI. + +--- + +## Синтаксис + +```bash +dvc-cli [output-dir] [options] +``` + +## Позиционные аргументы + +| Аргумент | Описание | По умолчанию | +|----------|----------|--------------| +| `input-video` | Путь к входному видео файлу | **Обязательный** | +| `output-dir` | Директория для сохранения результата | `.` (текущая папка) | + +--- + +## Опции + +### `-r, --resolutions` — Профили разрешений + +Задает список профилей для генерации. Можно указывать разрешение и FPS. + +**Формат:** +- `` — только разрешение (FPS = 30) +- `@` — разрешение с 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) — Быстрый старт + diff --git a/src/cli.ts b/src/cli.ts index 7668113..fed6b99 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 [output-dir] [-r resolutions] [-c codec] [-p poster-timecode]'); + console.error('❌ Usage: dvc-cli [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(); diff --git a/src/core/converter.ts b/src/core/converter.ts index e08bb30..feae6b2 100644 --- a/src/core/converter.ts +++ b/src/core/converter.ts @@ -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 }; } diff --git a/src/core/packaging.ts b/src/core/packaging.ts index 468ef85..2487a75 100644 --- a/src/core/packaging.ts +++ b/src/core/packaging.ts @@ -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>, + outputDir: string, + profiles: VideoProfile[], + segmentDuration: number, + codecType: CodecType +): Promise { + 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 { + 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 { + 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>, + 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 { + 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 { + 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 { + 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; +} + diff --git a/src/types/index.ts b/src/types/index.ts index dd96f58..7d850ce 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; } /**