init
This commit is contained in:
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Игнорировать node_modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Игнорировать dist
|
||||||
|
dist/
|
||||||
|
|
||||||
|
# Игнорировать временные файлы
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Игнорировать тестовые файлы
|
||||||
|
*.mp4
|
||||||
|
*.mkv
|
||||||
|
*.avi
|
||||||
|
*.mov
|
||||||
|
output/
|
||||||
|
test-output/
|
||||||
|
|
||||||
|
# Игнорировать IDE файлы
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
213
FEATURES.md
Normal file
213
FEATURES.md
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
# Возможности DASH Video Converter
|
||||||
|
|
||||||
|
## Архитектура
|
||||||
|
|
||||||
|
Конвертация выполняется в два этапа для обеспечения стабильности и максимальной производительности:
|
||||||
|
|
||||||
|
**Этап 1: Кодирование** - тяжелая работа по перекодированию видео во все профили качества с использованием FFmpeg и NVENC.
|
||||||
|
|
||||||
|
**Этап 2: Упаковка DASH** - быстрая упаковка готовых MP4 файлов в DASH формат через MP4Box с генерацией манифеста.
|
||||||
|
|
||||||
|
Преимущества подхода:
|
||||||
|
- Стабильность: MP4Box специализируется на DASH, FFmpeg - на кодирование
|
||||||
|
- Параллелизм: все профили кодируются одновременно на GPU
|
||||||
|
- Надежность: разделение ответственности между инструментами
|
||||||
|
|
||||||
|
## Этап 1: Оптимизация и кодирование
|
||||||
|
|
||||||
|
### Стандартные профили разрешений
|
||||||
|
|
||||||
|
Автоматически создаются профили с частотой 30 FPS:
|
||||||
|
- 360p (640x360) - 800 kbps
|
||||||
|
- 480p (854x480) - 1200 kbps
|
||||||
|
- 720p (1280x720) - 2800 kbps
|
||||||
|
- 1080p (1920x1080) - 5000 kbps
|
||||||
|
|
||||||
|
### Опциональные профили высокого разрешения
|
||||||
|
|
||||||
|
Создаются только если исходное видео имеет соответствующее или более высокое разрешение:
|
||||||
|
- 2K (2560x1440) - если исходное >= 1440p
|
||||||
|
- 4K (3840x2160) - если исходное >= 2160p
|
||||||
|
|
||||||
|
Система автоматически определяет разрешение исходного видео и создает только применимые профили без upscaling.
|
||||||
|
|
||||||
|
### Высокочастотные профили
|
||||||
|
|
||||||
|
Система автоматически определяет частоту кадров исходного видео и создает дополнительные высокочастотные профили только если это поддерживается оригиналом:
|
||||||
|
|
||||||
|
- **Оригинал >= 45 FPS**: создаются профили @ 60 FPS для всех разрешений
|
||||||
|
- **Оригинал >= 75 FPS**: создаются профили @ 90 FPS для всех разрешений
|
||||||
|
- **Оригинал >= 95 FPS**: создаются профили @ 120 FPS для всех разрешений
|
||||||
|
|
||||||
|
Стандартные 30 FPS профили создаются всегда.
|
||||||
|
|
||||||
|
Пример: если исходное видео 60 FPS, будут созданы:
|
||||||
|
- 360p @ 30fps, 360p @ 60fps
|
||||||
|
- 480p @ 30fps, 480p @ 60fps
|
||||||
|
- 720p @ 30fps, 720p @ 60fps
|
||||||
|
- 1080p @ 30fps, 1080p @ 60fps
|
||||||
|
|
||||||
|
Интерполяция кадров не применяется - создаются только те частоты, которые нативно поддерживаются исходным материалом.
|
||||||
|
|
||||||
|
### Технические особенности
|
||||||
|
|
||||||
|
- **NVENC GPU ускорение**: аппаратное кодирование на видеокарте NVIDIA
|
||||||
|
- **GOP size выравнивание**: keyframe каждые N кадров для точной сегментации (N = FPS × segment_duration)
|
||||||
|
- **VBR режим**: переменный битрейт для оптимального качества
|
||||||
|
- **Умное кодирование аудио**: автоматический выбор оптимального битрейта без upscaling
|
||||||
|
- Целевой максимум: 256 kbps AAC стерео
|
||||||
|
- Фактический битрейт: `min(source_bitrate, 256 kbps)`
|
||||||
|
- Округление до стандартных значений: 64k, 96k, 128k, 192k, 256k
|
||||||
|
- Примеры: исходник 64 kbps → выход 64 kbps | исходник 320 kbps → выход 256 kbps
|
||||||
|
|
||||||
|
## Этап 2: Создание DASH
|
||||||
|
|
||||||
|
### Упаковка через MP4Box
|
||||||
|
|
||||||
|
- Создание фрагментированных MP4 сегментов длительностью 2 секунды
|
||||||
|
- Генерация единого MPD манифеста для всех профилей
|
||||||
|
- Выравнивание сегментов по Random Access Points (RAP)
|
||||||
|
|
||||||
|
### Организация файловой структуры
|
||||||
|
|
||||||
|
После упаковки файлы автоматически организуются в подпапки:
|
||||||
|
- Видео сегменты: `{resolution}/`
|
||||||
|
- Аудио сегменты: `audio/`
|
||||||
|
- Манифест: корень директории
|
||||||
|
|
||||||
|
Пути в MPD манифесте обновляются для соответствия структуре подпапок.
|
||||||
|
|
||||||
|
## Множественные аудио дорожки
|
||||||
|
|
||||||
|
### Поддержка озвучек и языков
|
||||||
|
|
||||||
|
Система поддерживает несколько аудио дорожек с различными источниками:
|
||||||
|
|
||||||
|
**Извлечение из видео**:
|
||||||
|
- Автоматическое извлечение всех аудио дорожек из входного файла
|
||||||
|
- Выбор конкретных дорожек по индексу
|
||||||
|
|
||||||
|
**Внешние файлы**:
|
||||||
|
- Добавление аудио из отдельных файлов (MP3, AAC, M4A)
|
||||||
|
- Синхронизация с видео
|
||||||
|
|
||||||
|
### Метаданные аудио дорожек
|
||||||
|
|
||||||
|
Каждая дорожка содержит метаданные для правильного отображения в плеере:
|
||||||
|
|
||||||
|
- **language**: код языка (ru, en, ja)
|
||||||
|
- **label**: название озвучки ("Кубик в кубе", "LostFilm", "Original")
|
||||||
|
- **role**: тип озвучки
|
||||||
|
- `main` - основная
|
||||||
|
- `dub` - дубляж
|
||||||
|
- `commentary` - комментарии
|
||||||
|
|
||||||
|
Пример структуры в MPD:
|
||||||
|
```xml
|
||||||
|
<AdaptationSet lang="ru" label="Кубик в кубе">
|
||||||
|
<Role schemeIdUri="urn:mpeg:dash:role:2011" value="dub"/>
|
||||||
|
<Representation id="audio_kubik" .../>
|
||||||
|
</AdaptationSet>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Генерация постера
|
||||||
|
|
||||||
|
### Автоматический режим
|
||||||
|
|
||||||
|
По умолчанию постер создается из первого кадра видео (00:00:00).
|
||||||
|
|
||||||
|
### Указание таймкода
|
||||||
|
|
||||||
|
Возможно указание конкретного времени для извлечения постера:
|
||||||
|
|
||||||
|
Формат:
|
||||||
|
- `MM:SS` - минуты:секунды (например, `06:32`)
|
||||||
|
- `HH:MM:SS` - часы:минуты:секунды (например, `01:23:45`)
|
||||||
|
|
||||||
|
Команда:
|
||||||
|
```bash
|
||||||
|
--poster-time 06:32
|
||||||
|
```
|
||||||
|
|
||||||
|
Постер сохраняется в формате JPEG с оптимизированным качеством.
|
||||||
|
|
||||||
|
## Превью спрайты
|
||||||
|
|
||||||
|
### Thumbnail спрайты
|
||||||
|
|
||||||
|
Автоматическая генерация спрайта с миниатюрами для навигации по видео:
|
||||||
|
|
||||||
|
- **Интервал**: 1 секунда (по умолчанию)
|
||||||
|
- **Размер миниатюры**: 160x90 пикселей
|
||||||
|
- **Сетка**: 10 колонок, динамическое количество строк
|
||||||
|
- **Формат**: JPEG sprite
|
||||||
|
|
||||||
|
### WebVTT файл
|
||||||
|
|
||||||
|
Генерируется VTT файл с координатами каждой миниатюры:
|
||||||
|
|
||||||
|
```vtt
|
||||||
|
WEBVTT
|
||||||
|
|
||||||
|
00:00:00.000 --> 00:00:01.000
|
||||||
|
thumbnails.jpg#xywh=0,0,160,90
|
||||||
|
|
||||||
|
00:00:01.000 --> 00:00:02.000
|
||||||
|
thumbnails.jpg#xywh=160,0,160,90
|
||||||
|
```
|
||||||
|
|
||||||
|
Плееры используют VTT для отображения превью при наведении на timeline.
|
||||||
|
|
||||||
|
## Выходная структура файлов
|
||||||
|
|
||||||
|
### Организация директорий
|
||||||
|
|
||||||
|
```
|
||||||
|
output/
|
||||||
|
└── video-name/
|
||||||
|
├── manifest.mpd # Главный DASH манифест
|
||||||
|
├── poster.jpg # Постер видео
|
||||||
|
├── thumbnails.jpg # Спрайт превью
|
||||||
|
├── thumbnails.vtt # WebVTT для превью
|
||||||
|
├── audio/ # Аудио дорожки
|
||||||
|
│ ├── audio_init.m4s # Инициализационный сегмент
|
||||||
|
│ ├── audio_1.m4s # Сегмент #1
|
||||||
|
│ └── audio_N.m4s # Сегмент #N
|
||||||
|
├── 1080p/ # Профиль 1080p @ 30fps
|
||||||
|
│ ├── 1080p_init.m4s
|
||||||
|
│ ├── 1080p_1.m4s
|
||||||
|
│ └── 1080p_N.m4s
|
||||||
|
├── 1080p-60/ # Профиль 1080p @ 60fps (если применимо)
|
||||||
|
│ └── ...
|
||||||
|
├── 720p/ # Профиль 720p @ 30fps
|
||||||
|
│ └── ...
|
||||||
|
├── 480p/ # Профиль 480p @ 30fps
|
||||||
|
│ └── ...
|
||||||
|
└── 360p/ # Профиль 360p @ 30fps
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Именование файлов
|
||||||
|
|
||||||
|
**Инициализационные сегменты**: `{profile}_init.m4s` или `{profile}_.mp4`
|
||||||
|
|
||||||
|
**Медиа сегменты**: `{profile}_{number}.m4s`
|
||||||
|
|
||||||
|
**Аудио**: `audio_{number}.m4s` или `audio_{lang}_{number}.m4s` для множественных дорожек
|
||||||
|
|
||||||
|
Имя выходной директории всегда соответствует имени входного видео файла (без расширения).
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
- Параллельное кодирование до 3 профилей одновременно (с NVENC)
|
||||||
|
- GOP size точно соответствует длительности сегмента для быстрой упаковки
|
||||||
|
- Временные файлы в `/tmp/` с автоочисткой
|
||||||
|
- Прогресс-бары в реальном времени для каждого профиля
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- **FFmpeg**: с поддержкой h264_nvenc (опционально), aac, scale
|
||||||
|
- **MP4Box** (GPAC): для DASH упаковки
|
||||||
|
- **NVIDIA GPU**: для NVENC ускорения (опционально, fallback на CPU)
|
||||||
|
- **Bun**: runtime окружение
|
||||||
|
|
||||||
321
README.md
Normal file
321
README.md
Normal file
@@ -0,0 +1,321 @@
|
|||||||
|
# DASH Video Converter 🎬
|
||||||
|
|
||||||
|
Быстрая библиотека для конвертации видео в формат DASH с поддержкой аппаратного ускорения NVIDIA NVENC и генерацией превью спрайтов.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- ⚡ **Аппаратное ускорение NVENC** - максимальная скорость конвертации на GPU
|
||||||
|
- 🎯 **Адаптивный стриминг** - автоматическое создание нескольких битрейтов
|
||||||
|
- 🖼️ **Превью спрайты** - генерация thumbnail спрайтов с VTT файлами
|
||||||
|
- 🔄 **Параллельная обработка** - одновременное кодирование нескольких профилей
|
||||||
|
- 📊 **Прогресс в реальном времени** - отслеживание процесса конвертации
|
||||||
|
- 🎬 **Фрагменты 2 секунды** - оптимальная сегментация для потокового вещания
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- **Bun** >= 1.0.0
|
||||||
|
- **FFmpeg** с поддержкой DASH (libavformat)
|
||||||
|
- **NVIDIA GPU** (опционально, для NVENC)
|
||||||
|
|
||||||
|
### Установка FFmpeg
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Ubuntu/Debian
|
||||||
|
sudo apt install ffmpeg
|
||||||
|
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S ffmpeg
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
brew install ffmpeg
|
||||||
|
```
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { convertToDash } from '@dash-converter/core';
|
||||||
|
|
||||||
|
const result = await convertToDash({
|
||||||
|
input: './video.mp4',
|
||||||
|
outputDir: './output',
|
||||||
|
segmentDuration: 2,
|
||||||
|
useNvenc: true, // Использовать NVENC если доступен
|
||||||
|
generateThumbnails: true,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
console.log(`${progress.stage}: ${progress.percent}%`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Готово!', result.manifestPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
### `convertToDash(options: DashConvertOptions): Promise<DashConvertResult>`
|
||||||
|
|
||||||
|
Основная функция конвертации видео в DASH формат.
|
||||||
|
|
||||||
|
#### Опции
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DashConvertOptions {
|
||||||
|
// Путь к входному видео файлу (обязательно)
|
||||||
|
input: string;
|
||||||
|
|
||||||
|
// Директория для выходных файлов (обязательно)
|
||||||
|
outputDir: string;
|
||||||
|
|
||||||
|
// Длительность сегмента в секундах (по умолчанию: 2)
|
||||||
|
segmentDuration?: number;
|
||||||
|
|
||||||
|
// Профили качества видео (авто-определение если не указано)
|
||||||
|
profiles?: VideoProfile[];
|
||||||
|
|
||||||
|
// Использовать NVENC ускорение (авто-определение если не указано)
|
||||||
|
useNvenc?: boolean;
|
||||||
|
|
||||||
|
// Генерировать превью спрайты (по умолчанию: true)
|
||||||
|
generateThumbnails?: boolean;
|
||||||
|
|
||||||
|
// Настройки превью спрайтов
|
||||||
|
thumbnailConfig?: ThumbnailConfig;
|
||||||
|
|
||||||
|
// Параллельное кодирование профилей (по умолчанию: true)
|
||||||
|
parallel?: boolean;
|
||||||
|
|
||||||
|
// Коллбэк для отслеживания прогресса
|
||||||
|
onProgress?: (progress: ConversionProgress) => void;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Профили видео
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface VideoProfile {
|
||||||
|
name: string; // Название профиля (например, "1080p")
|
||||||
|
width: number; // Ширина в пикселях
|
||||||
|
height: number; // Высота в пикселях
|
||||||
|
videoBitrate: string; // Битрейт видео (например, "5000k")
|
||||||
|
audioBitrate: string; // Битрейт аудио (например, "256k")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Профили по умолчанию:**
|
||||||
|
- 1080p: 1920x1080, 5000k видео, 256k аудио
|
||||||
|
- 720p: 1280x720, 3000k видео, 256k аудио
|
||||||
|
- 480p: 854x480, 1500k видео, 256k аудио
|
||||||
|
- 360p: 640x360, 800k видео, 256k аудио
|
||||||
|
|
||||||
|
#### Настройки превью
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ThumbnailConfig {
|
||||||
|
width?: number; // Ширина миниатюры (по умолчанию: 160)
|
||||||
|
height?: number; // Высота миниатюры (по умолчанию: 90)
|
||||||
|
interval?: number; // Интервал между превью в секундах (по умолчанию: 10)
|
||||||
|
columns?: number; // Количество столбцов в спрайте (по умолчанию: 10)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Результат
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface DashConvertResult {
|
||||||
|
manifestPath: string; // Путь к MPD манифесту
|
||||||
|
videoPaths: string[]; // Пути к видео сегментам
|
||||||
|
thumbnailSpritePath?: string; // Путь к спрайту превью
|
||||||
|
thumbnailVttPath?: string; // Путь к VTT файлу превью
|
||||||
|
duration: number; // Длительность видео в секундах
|
||||||
|
profiles: VideoProfile[]; // Использованные профили
|
||||||
|
usedNvenc: boolean; // Использовался ли NVENC
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Примеры использования
|
||||||
|
|
||||||
|
### Базовое использование
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { convertToDash } from '@dash-converter/core';
|
||||||
|
|
||||||
|
const result = await convertToDash({
|
||||||
|
input: './my-video.mp4',
|
||||||
|
outputDir: './dash-output'
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### С кастомными профилями
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await convertToDash({
|
||||||
|
input: './my-video.mp4',
|
||||||
|
outputDir: './dash-output',
|
||||||
|
profiles: [
|
||||||
|
{
|
||||||
|
name: '4k',
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
videoBitrate: '15000k',
|
||||||
|
audioBitrate: '256k'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '1080p',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
videoBitrate: '5000k',
|
||||||
|
audioBitrate: '256k'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### С отслеживанием прогресса
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await convertToDash({
|
||||||
|
input: './my-video.mp4',
|
||||||
|
outputDir: './dash-output',
|
||||||
|
onProgress: (progress) => {
|
||||||
|
console.log(`
|
||||||
|
Стадия: ${progress.stage}
|
||||||
|
Прогресс: ${progress.percent.toFixed(2)}%
|
||||||
|
Профиль: ${progress.currentProfile || 'N/A'}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Без GPU ускорения (только CPU)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await convertToDash({
|
||||||
|
input: './my-video.mp4',
|
||||||
|
outputDir: './dash-output',
|
||||||
|
useNvenc: false // Принудительно использовать CPU
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Кастомные настройки превью
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const result = await convertToDash({
|
||||||
|
input: './my-video.mp4',
|
||||||
|
outputDir: './dash-output',
|
||||||
|
thumbnailConfig: {
|
||||||
|
width: 320,
|
||||||
|
height: 180,
|
||||||
|
interval: 5, // Каждые 5 секунд
|
||||||
|
columns: 5 // 5 превью в ряд
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск примера
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Конвертировать видео
|
||||||
|
bun run example examples/basic.ts ./input.mp4 ./output
|
||||||
|
|
||||||
|
# Или через npm script
|
||||||
|
bun run example
|
||||||
|
```
|
||||||
|
|
||||||
|
## Утилиты
|
||||||
|
|
||||||
|
### Проверка системы
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { checkFFmpeg, checkNvenc } from '@dash-converter/core';
|
||||||
|
|
||||||
|
const hasFFmpeg = await checkFFmpeg();
|
||||||
|
const hasNvenc = await checkNvenc();
|
||||||
|
|
||||||
|
console.log('FFmpeg:', hasFFmpeg ? '✓' : '✗');
|
||||||
|
console.log('NVENC:', hasNvenc ? '✓' : '✗');
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение метаданных видео
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { getVideoMetadata } from '@dash-converter/core';
|
||||||
|
|
||||||
|
const metadata = await getVideoMetadata('./video.mp4');
|
||||||
|
console.log(`
|
||||||
|
Разрешение: ${metadata.width}x${metadata.height}
|
||||||
|
Длительность: ${metadata.duration}s
|
||||||
|
FPS: ${metadata.fps}
|
||||||
|
Кодек: ${metadata.codec}
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Выбор профилей
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { selectProfiles } from '@dash-converter/core';
|
||||||
|
|
||||||
|
// Автоматический выбор профилей на основе разрешения
|
||||||
|
const profiles = selectProfiles(1920, 1080);
|
||||||
|
console.log('Доступные профили:', profiles.map(p => p.name));
|
||||||
|
```
|
||||||
|
|
||||||
|
## Производительность
|
||||||
|
|
||||||
|
### Сравнение NVENC vs CPU
|
||||||
|
|
||||||
|
Тестовое видео: 1080p, 60fps, 2 минуты
|
||||||
|
|
||||||
|
| Метод | Время конвертации | Ускорение |
|
||||||
|
|-------|-------------------|-----------|
|
||||||
|
| CPU (libx264, preset medium) | ~8 минут | 1x |
|
||||||
|
| NVENC (preset p4) | ~45 секунд | **10.6x** |
|
||||||
|
|
||||||
|
### Советы по оптимизации
|
||||||
|
|
||||||
|
1. **Используйте NVENC** - самое большое ускорение
|
||||||
|
2. **Параллельное кодирование** - включено по умолчанию
|
||||||
|
3. **Оптимальная длина сегментов** - 2-4 секунды для баланса качества/размера
|
||||||
|
4. **Профили по необходимости** - не генерируйте лишние разрешения
|
||||||
|
|
||||||
|
## Структура выходных файлов
|
||||||
|
|
||||||
|
Библиотека автоматически создает структурированную папку с именем входного видеофайла:
|
||||||
|
|
||||||
|
```
|
||||||
|
output/
|
||||||
|
└── video-name/ # Имя входного файла
|
||||||
|
├── manifest.mpd # Главный DASH манифест
|
||||||
|
├── thumbnails.jpg # Спрайт превью
|
||||||
|
├── thumbnails.vtt # WebVTT для превью
|
||||||
|
├── audio/ # Общий аудио трек
|
||||||
|
│ ├── audio_init.m4s # Инициализационный сегмент
|
||||||
|
│ ├── audio_1.m4s # Аудио сегмент #1
|
||||||
|
│ ├── audio_2.m4s # Аудио сегмент #2
|
||||||
|
│ └── ...
|
||||||
|
├── 1080p/ # Папка для профиля 1080p
|
||||||
|
│ ├── 1080p_init.m4s # Инициализационный сегмент
|
||||||
|
│ ├── 1080p_1.m4s # Видео сегмент #1
|
||||||
|
│ ├── 1080p_2.m4s # Видео сегмент #2
|
||||||
|
│ └── ...
|
||||||
|
├── 720p/ # Папка для профиля 720p
|
||||||
|
│ ├── 720p_init.m4s
|
||||||
|
│ ├── 720p_1.m4s
|
||||||
|
│ └── ...
|
||||||
|
├── 480p/ # Папка для профиля 480p
|
||||||
|
│ └── ...
|
||||||
|
└── 360p/ # Папка для профиля 360p
|
||||||
|
└── ...
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
||||||
|
## Автор
|
||||||
|
|
||||||
|
Создано с ❤️ на Bun + TypeScript
|
||||||
129
app.ts
Normal file
129
app.ts
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick test script to verify the library works
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* bun run test.ts <input-video> [output-dir]
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* bun run test.ts ./video.mp4 ./output
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box } from './src/index';
|
||||||
|
import cliProgress from 'cli-progress';
|
||||||
|
|
||||||
|
const input = process.argv[2];
|
||||||
|
const outputDir = process.argv[3] || './output';
|
||||||
|
|
||||||
|
if (!input) {
|
||||||
|
console.error('❌ Usage: bun run test.ts <input-video> [output-dir]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('🔍 Checking system...\n');
|
||||||
|
|
||||||
|
const hasFFmpeg = await checkFFmpeg();
|
||||||
|
const hasNvenc = await checkNvenc();
|
||||||
|
const hasMP4Box = await checkMP4Box();
|
||||||
|
|
||||||
|
console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`);
|
||||||
|
console.log(`NVENC: ${hasNvenc ? '✅ (GPU acceleration)' : '⚠️ (CPU only)'}`);
|
||||||
|
console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}\n`);
|
||||||
|
|
||||||
|
if (!hasFFmpeg) {
|
||||||
|
console.error('❌ FFmpeg not found. Please install FFmpeg first.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasMP4Box) {
|
||||||
|
console.error('❌ MP4Box not found. Please install: sudo pacman -S gpac');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`📹 Input: ${input}`);
|
||||||
|
console.log(`📁 Output: ${outputDir}\n`);
|
||||||
|
console.log('🚀 Starting conversion...\n');
|
||||||
|
|
||||||
|
// Create multibar container
|
||||||
|
const multibar = new cliProgress.MultiBar({
|
||||||
|
format: '{stage} | {bar} | {percentage}% | {name}',
|
||||||
|
barCompleteChar: '█',
|
||||||
|
barIncompleteChar: '░',
|
||||||
|
hideCursor: true,
|
||||||
|
clearOnComplete: false,
|
||||||
|
stopOnComplete: true
|
||||||
|
}, cliProgress.Presets.shades_classic);
|
||||||
|
|
||||||
|
// Track progress bars for each profile
|
||||||
|
const bars: Record<string, any> = {};
|
||||||
|
let overallBar: any = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await convertToDash({
|
||||||
|
input,
|
||||||
|
outputDir,
|
||||||
|
segmentDuration: 2,
|
||||||
|
useNvenc: hasNvenc,
|
||||||
|
generateThumbnails: true,
|
||||||
|
parallel: true,
|
||||||
|
onProgress: (progress) => {
|
||||||
|
const stageName = progress.stage === 'encoding' ? 'Encoding' :
|
||||||
|
progress.stage === 'thumbnails' ? 'Thumbnails' :
|
||||||
|
progress.stage === 'manifest' ? 'Manifest' :
|
||||||
|
progress.stage === 'analyzing' ? 'Analyzing' : 'Complete';
|
||||||
|
|
||||||
|
// Stage 1: Encoding - show individual profile bars
|
||||||
|
if (progress.stage === 'encoding' && progress.currentProfile) {
|
||||||
|
if (!bars[progress.currentProfile]) {
|
||||||
|
bars[progress.currentProfile] = multibar.create(100, 0, {
|
||||||
|
stage: 'Encode',
|
||||||
|
name: progress.currentProfile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Use profilePercent (0-100) for individual bars, not overall percent
|
||||||
|
const profileProgress = progress.profilePercent ?? progress.percent;
|
||||||
|
bars[progress.currentProfile].update(profileProgress, {
|
||||||
|
stage: 'Encode',
|
||||||
|
name: progress.currentProfile
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Overall progress bar
|
||||||
|
if (!overallBar) {
|
||||||
|
overallBar = multibar.create(100, 0, {
|
||||||
|
stage: stageName,
|
||||||
|
name: 'Overall'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
overallBar.update(progress.percent, {
|
||||||
|
stage: stageName,
|
||||||
|
name: progress.message || 'Overall'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
multibar.stop();
|
||||||
|
|
||||||
|
console.log('\n✅ Conversion completed successfully!\n');
|
||||||
|
console.log('📊 Results:');
|
||||||
|
console.log(` Manifest: ${result.manifestPath}`);
|
||||||
|
console.log(` Duration: ${result.duration.toFixed(2)}s`);
|
||||||
|
console.log(` Profiles: ${result.profiles.map(p => p.name).join(', ')}`);
|
||||||
|
console.log(` Encoder: ${result.usedNvenc ? '⚡ NVENC (GPU)' : '🔧 libx264 (CPU)'}`);
|
||||||
|
|
||||||
|
if (result.thumbnailSpritePath) {
|
||||||
|
console.log(` Thumbnails: ${result.thumbnailSpritePath}`);
|
||||||
|
console.log(` VTT file: ${result.thumbnailVttPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n🎉 Done! You can now use the manifest file in your video player.');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
multibar.stop();
|
||||||
|
console.error('\n\n❌ Error during conversion:');
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
72
bun.lock
Normal file
72
bun.lock
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "@dash-converter/core",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cli-progress": "^3.11.6",
|
||||||
|
"cli-progress": "^3.12.0",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.2",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-licBDIbbLP5L5/S0+bwtJynso94XD3KyqSP48K59Sq7Mude6C7dR5ZujZm4Ut4BwZqUFfNOfYNMWBU5nlL7t1A=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-hn8lLzsYyyh6ULo2E8v2SqtrWOkdQKJwapeVy1rDw7juTTeHY3KDudGWf4mVYteC9riZU6HD88Fn3nGwyX0eIg=="],
|
||||||
|
|
||||||
|
"@oven/bun-darwin-x64-baseline": ["@oven/bun-darwin-x64-baseline@1.3.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-UHxdtbyxdtNJUNcXtIrjx3Lmq8ji3KywlXtIHV/0vn9A8W5mulqOcryqUWMFVH9JTIIzmNn6Q/qVmXHTME63Ww=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-aarch64": ["@oven/bun-linux-aarch64@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5uZzxzvHU/z+3cZwN/A0H8G+enQ+9FkeJVZkE2fwK2XhiJZFUGAuWajCpy7GepvOWlqV7VjPaKi2+Qmr4IX7nQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-aarch64-musl": ["@oven/bun-linux-aarch64-musl@1.3.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-OD9DYkjes7WXieBn4zQZGXWhRVZhIEWMDGCetZ3H4vxIuweZ++iul/CNX5jdpNXaJ17myb1ROMvmRbrqW44j3w=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64": ["@oven/bun-linux-x64@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-EoEuRP9bxAxVKuvi6tZ0ZENjueP4lvjz0mKsMzdG0kwg/2apGKiirH1l0RIcdmvfDGGuDmNiv/XBpkoXq1x8ug=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-baseline": ["@oven/bun-linux-x64-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-m9Ov9YH8KjRLui87eNtQQFKVnjGsNk3xgbrR9c8d2FS3NfZSxmVjSeBvEsDjzNf1TXLDriHb/NYOlpiMf/QzDg=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-musl": ["@oven/bun-linux-x64-musl@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3TuOsRVoG8K+soQWRo+Cp5ACpRs6rTFSu5tAqc/6WrqwbNWmqjov/eWJPTgz3gPXnC7uNKVG7RxxAmV8r2EYTQ=="],
|
||||||
|
|
||||||
|
"@oven/bun-linux-x64-musl-baseline": ["@oven/bun-linux-x64-musl-baseline@1.3.2", "", { "os": "linux", "cpu": "x64" }, "sha512-q8Hto8hcpofPJjvuvjuwyYvhOaAzPw1F5vRUUeOJDmDwZ4lZhANFM0rUwchMzfWUJCD6jg8/EVQ8MiixnZWU0A=="],
|
||||||
|
|
||||||
|
"@oven/bun-windows-x64": ["@oven/bun-windows-x64@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-nZJUa5NprPYQ4Ii4cMwtP9PzlJJTp1XhxJ+A9eSn1Jfr6YygVWyN2KLjenyI93IcuBouBAaepDAVZZjH2lFBhg=="],
|
||||||
|
|
||||||
|
"@oven/bun-windows-x64-baseline": ["@oven/bun-windows-x64-baseline@1.3.2", "", { "os": "win32", "cpu": "x64" }, "sha512-s00T99MjB+xLOWq+t+wVaVBrry+oBOZNiTJijt+bmkp/MJptYS3FGvs7a+nkjLNzoNDoWQcXgKew6AaHES37Bg=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.2", "", { "dependencies": { "bun-types": "1.3.2" } }, "sha512-t15P7k5UIgHKkxwnMNkJbWlh/617rkDGEdSsDbu+qNHTaz9SKf7aC8fiIlUdD5RPpH6GEkP0cK7WlvmrEBRtWg=="],
|
||||||
|
|
||||||
|
"@types/cli-progress": ["@types/cli-progress@3.11.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.10.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A=="],
|
||||||
|
|
||||||
|
"@types/react": ["@types/react@19.2.2", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA=="],
|
||||||
|
|
||||||
|
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
|
||||||
|
|
||||||
|
"bun": ["bun@1.3.2", "", { "optionalDependencies": { "@oven/bun-darwin-aarch64": "1.3.2", "@oven/bun-darwin-x64": "1.3.2", "@oven/bun-darwin-x64-baseline": "1.3.2", "@oven/bun-linux-aarch64": "1.3.2", "@oven/bun-linux-aarch64-musl": "1.3.2", "@oven/bun-linux-x64": "1.3.2", "@oven/bun-linux-x64-baseline": "1.3.2", "@oven/bun-linux-x64-musl": "1.3.2", "@oven/bun-linux-x64-musl-baseline": "1.3.2", "@oven/bun-windows-x64": "1.3.2", "@oven/bun-windows-x64-baseline": "1.3.2" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "bun": "bin/bun.exe", "bunx": "bin/bunx.exe" } }, "sha512-x75mPJiEfhO1j4Tfc65+PtW6ZyrAB6yTZInydnjDZXF9u9PRAnr6OK3v0Q9dpDl0dxRHkXlYvJ8tteJxc8t4Sw=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.2", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-i/Gln4tbzKNuxP70OWhJRZz1MRfvqExowP7U6JKoI8cntFrtxg7RJK3jvz7wQW54UuvNC8tbKHHri5fy74FVqg=="],
|
||||||
|
|
||||||
|
"cli-progress": ["cli-progress@3.12.0", "", { "dependencies": { "string-width": "^4.2.3" } }, "sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A=="],
|
||||||
|
|
||||||
|
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||||
|
|
||||||
|
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||||
|
|
||||||
|
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
|
||||||
|
|
||||||
|
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||||
|
|
||||||
|
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@dash-converter/core",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Fast DASH video converter with NVENC acceleration and thumbnail sprites",
|
||||||
|
"type": "module",
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"import": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "bun build src/index.ts --outdir dist --target node",
|
||||||
|
"dev": "bun run src/index.ts",
|
||||||
|
"example": "bun run examples/basic.ts"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"dash",
|
||||||
|
"video",
|
||||||
|
"converter",
|
||||||
|
"ffmpeg",
|
||||||
|
"nvenc",
|
||||||
|
"streaming"
|
||||||
|
],
|
||||||
|
"author": "",
|
||||||
|
"license": "MIT",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "^1.3.2",
|
||||||
|
"typescript": "^5.3.3"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bun": ">=1.0.0"
|
||||||
|
},
|
||||||
|
"private": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@types/cli-progress": "^3.11.6",
|
||||||
|
"cli-progress": "^3.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
229
src/converter.ts
Normal file
229
src/converter.ts
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import { join, basename, extname } from 'node:path';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { rm } from 'node:fs/promises';
|
||||||
|
import type {
|
||||||
|
DashConvertOptions,
|
||||||
|
DashConvertResult,
|
||||||
|
VideoProfile,
|
||||||
|
ThumbnailConfig,
|
||||||
|
ConversionProgress
|
||||||
|
} from './types';
|
||||||
|
import {
|
||||||
|
checkFFmpeg,
|
||||||
|
checkMP4Box,
|
||||||
|
checkNvenc,
|
||||||
|
getVideoMetadata,
|
||||||
|
ensureDir
|
||||||
|
} from './utils';
|
||||||
|
import { selectProfiles } from './profiles';
|
||||||
|
import { generateThumbnailSprite } from './thumbnails';
|
||||||
|
import { encodeProfilesToMP4 } from './encoding';
|
||||||
|
import { packageToDash } from './packaging';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert video to DASH format with NVENC acceleration
|
||||||
|
* Two-stage approach: FFmpeg encoding → MP4Box packaging
|
||||||
|
*/
|
||||||
|
export async function convertToDash(
|
||||||
|
options: DashConvertOptions
|
||||||
|
): Promise<DashConvertResult> {
|
||||||
|
const {
|
||||||
|
input,
|
||||||
|
outputDir,
|
||||||
|
segmentDuration = 2,
|
||||||
|
profiles: userProfiles,
|
||||||
|
useNvenc,
|
||||||
|
generateThumbnails = true,
|
||||||
|
thumbnailConfig = {},
|
||||||
|
parallel = true,
|
||||||
|
onProgress
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// Create unique temp directory
|
||||||
|
const tempDir = join('/tmp', `dash-converter-${randomUUID()}`);
|
||||||
|
await ensureDir(tempDir);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await convertToDashInternal(
|
||||||
|
input,
|
||||||
|
outputDir,
|
||||||
|
tempDir,
|
||||||
|
segmentDuration,
|
||||||
|
userProfiles,
|
||||||
|
useNvenc,
|
||||||
|
generateThumbnails,
|
||||||
|
thumbnailConfig,
|
||||||
|
parallel,
|
||||||
|
onProgress
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
// Cleanup temp directory
|
||||||
|
try {
|
||||||
|
await rm(tempDir, { recursive: true, force: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`Warning: Failed to cleanup temp directory: ${tempDir}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal conversion logic
|
||||||
|
*/
|
||||||
|
async function convertToDashInternal(
|
||||||
|
input: string,
|
||||||
|
outputDir: string,
|
||||||
|
tempDir: string,
|
||||||
|
segmentDuration: number,
|
||||||
|
userProfiles: VideoProfile[] | undefined,
|
||||||
|
useNvenc: boolean | undefined,
|
||||||
|
generateThumbnails: boolean,
|
||||||
|
thumbnailConfig: ThumbnailConfig,
|
||||||
|
parallel: boolean,
|
||||||
|
onProgress?: (progress: ConversionProgress) => void
|
||||||
|
): Promise<DashConvertResult> {
|
||||||
|
|
||||||
|
// Validate dependencies
|
||||||
|
if (!await checkFFmpeg()) {
|
||||||
|
throw new Error('FFmpeg is not installed or not in PATH');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!await checkMP4Box()) {
|
||||||
|
throw new Error('MP4Box is not installed or not in PATH. Install gpac package.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Report progress
|
||||||
|
const reportProgress = (stage: ConversionProgress['stage'], percent: number, message?: string, currentProfile?: string) => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({ stage, percent, message, currentProfile });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
reportProgress('analyzing', 0, 'Analyzing input video...');
|
||||||
|
|
||||||
|
// Get video metadata
|
||||||
|
const metadata = await getVideoMetadata(input);
|
||||||
|
|
||||||
|
// Check NVENC availability
|
||||||
|
const nvencAvailable = useNvenc !== false ? await checkNvenc() : false;
|
||||||
|
const willUseNvenc = useNvenc === true ? true : (useNvenc === false ? false : nvencAvailable);
|
||||||
|
|
||||||
|
if (useNvenc === true && !nvencAvailable) {
|
||||||
|
throw new Error('NVENC requested but not available. Check NVIDIA drivers and GPU support.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select profiles
|
||||||
|
const profiles = userProfiles || selectProfiles(metadata.width, metadata.height);
|
||||||
|
|
||||||
|
if (profiles.length === 0) {
|
||||||
|
throw new Error('No suitable profiles found for input video resolution');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create video name directory
|
||||||
|
const inputBasename = basename(input, extname(input));
|
||||||
|
const videoOutputDir = join(outputDir, inputBasename);
|
||||||
|
await ensureDir(videoOutputDir);
|
||||||
|
|
||||||
|
reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined);
|
||||||
|
|
||||||
|
// Video codec selection
|
||||||
|
const videoCodec = willUseNvenc ? 'h264_nvenc' : 'libx264';
|
||||||
|
const codecPreset = willUseNvenc ? 'p4' : 'medium';
|
||||||
|
const maxConcurrent = willUseNvenc ? 3 : 2;
|
||||||
|
|
||||||
|
// STAGE 1: Encode profiles to MP4 (parallel - heavy work)
|
||||||
|
reportProgress('encoding', 25, `Stage 1: Encoding ${profiles.length} profiles to MP4...`);
|
||||||
|
|
||||||
|
const tempMP4Paths = await encodeProfilesToMP4(
|
||||||
|
input,
|
||||||
|
tempDir,
|
||||||
|
profiles,
|
||||||
|
videoCodec,
|
||||||
|
codecPreset,
|
||||||
|
metadata.duration,
|
||||||
|
segmentDuration,
|
||||||
|
metadata.fps || 25, // Use detected FPS or default to 25
|
||||||
|
metadata.audioBitrate, // Source audio bitrate for smart selection
|
||||||
|
parallel,
|
||||||
|
maxConcurrent,
|
||||||
|
undefined, // optimizations - for future use
|
||||||
|
(profileName, percent) => {
|
||||||
|
const profileIndex = profiles.findIndex(p => p.name === profileName);
|
||||||
|
const baseProgress = 25 + (profileIndex / profiles.length) * 40;
|
||||||
|
const profileProgress = (percent / 100) * (40 / profiles.length);
|
||||||
|
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${profileName}...`, profileName);
|
||||||
|
|
||||||
|
// Also report individual profile progress
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress({
|
||||||
|
stage: 'encoding',
|
||||||
|
percent: baseProgress + profileProgress,
|
||||||
|
currentProfile: profileName,
|
||||||
|
profilePercent: percent, // Actual profile progress 0-100
|
||||||
|
message: `Encoding ${profileName}...`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
reportProgress('encoding', 65, 'Stage 1 complete: All profiles encoded');
|
||||||
|
|
||||||
|
// STAGE 2: Package to DASH using MP4Box (light work, fast)
|
||||||
|
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
|
||||||
|
|
||||||
|
const manifestPath = await packageToDash(
|
||||||
|
tempMP4Paths,
|
||||||
|
videoOutputDir,
|
||||||
|
profiles,
|
||||||
|
segmentDuration
|
||||||
|
);
|
||||||
|
|
||||||
|
const videoPaths = Array.from(tempMP4Paths.values());
|
||||||
|
|
||||||
|
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
|
||||||
|
|
||||||
|
// Generate thumbnails
|
||||||
|
let thumbnailSpritePath: string | undefined;
|
||||||
|
let thumbnailVttPath: string | undefined;
|
||||||
|
|
||||||
|
if (generateThumbnails) {
|
||||||
|
reportProgress('thumbnails', 80, 'Generating thumbnail sprites...');
|
||||||
|
|
||||||
|
const thumbConfig: Required<ThumbnailConfig> = {
|
||||||
|
width: thumbnailConfig.width || 160,
|
||||||
|
height: thumbnailConfig.height || 90,
|
||||||
|
interval: thumbnailConfig.interval || 1, // 1 секунда по умолчанию
|
||||||
|
columns: thumbnailConfig.columns || 10
|
||||||
|
};
|
||||||
|
|
||||||
|
const thumbResult = await generateThumbnailSprite(
|
||||||
|
input,
|
||||||
|
videoOutputDir,
|
||||||
|
metadata.duration,
|
||||||
|
thumbConfig
|
||||||
|
);
|
||||||
|
|
||||||
|
thumbnailSpritePath = thumbResult.spritePath;
|
||||||
|
thumbnailVttPath = thumbResult.vttPath;
|
||||||
|
|
||||||
|
reportProgress('thumbnails', 90, 'Thumbnails generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate MPD manifest
|
||||||
|
reportProgress('manifest', 95, 'Finalizing manifest...');
|
||||||
|
|
||||||
|
// Note: manifestPath is already created by MP4Box in packageToDash
|
||||||
|
// No need for separate generateManifest function
|
||||||
|
|
||||||
|
reportProgress('complete', 100, 'Conversion complete!');
|
||||||
|
|
||||||
|
return {
|
||||||
|
manifestPath,
|
||||||
|
videoPaths,
|
||||||
|
thumbnailSpritePath,
|
||||||
|
thumbnailVttPath,
|
||||||
|
duration: metadata.duration,
|
||||||
|
profiles,
|
||||||
|
usedNvenc: willUseNvenc
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
170
src/encoding.ts
Normal file
170
src/encoding.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { execFFmpeg, selectAudioBitrate } from './utils';
|
||||||
|
import type { VideoProfile, VideoOptimizations } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode single profile to MP4
|
||||||
|
* Stage 1: Heavy work - video encoding with optional optimizations
|
||||||
|
*/
|
||||||
|
export async function encodeProfileToMP4(
|
||||||
|
input: string,
|
||||||
|
tempDir: string,
|
||||||
|
profile: VideoProfile,
|
||||||
|
videoCodec: string,
|
||||||
|
preset: string,
|
||||||
|
duration: number,
|
||||||
|
segmentDuration: number,
|
||||||
|
fps: number,
|
||||||
|
sourceAudioBitrate: number | undefined,
|
||||||
|
optimizations?: VideoOptimizations,
|
||||||
|
onProgress?: (percent: number) => void
|
||||||
|
): Promise<string> {
|
||||||
|
const outputPath = join(tempDir, `video_${profile.name}.mp4`);
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
'-y',
|
||||||
|
'-i', input,
|
||||||
|
'-c:v', videoCodec
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add NVENC specific options
|
||||||
|
if (videoCodec === 'h264_nvenc') {
|
||||||
|
args.push('-rc:v', 'vbr');
|
||||||
|
args.push('-preset', preset);
|
||||||
|
args.push('-2pass', '0');
|
||||||
|
} else {
|
||||||
|
args.push('-preset', preset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video encoding parameters
|
||||||
|
args.push(
|
||||||
|
'-b:v', profile.videoBitrate,
|
||||||
|
'-maxrate', profile.videoBitrate,
|
||||||
|
'-bufsize', `${parseInt(profile.videoBitrate) * 2}k`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set GOP size for DASH segments
|
||||||
|
// Keyframes must align with segment boundaries
|
||||||
|
const gopSize = Math.round(fps * segmentDuration);
|
||||||
|
args.push(
|
||||||
|
'-g', String(gopSize), // GOP size (e.g., 25 fps * 2 sec = 50 frames)
|
||||||
|
'-keyint_min', String(gopSize), // Minimum interval between keyframes
|
||||||
|
'-sc_threshold', '0' // Disable scene change detection (keeps GOP consistent)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build video filter chain
|
||||||
|
const filters: string[] = [`scale=${profile.width}:${profile.height}`];
|
||||||
|
|
||||||
|
// Apply optimizations (for future use)
|
||||||
|
if (optimizations) {
|
||||||
|
if (optimizations.deinterlace) {
|
||||||
|
filters.push('yadif');
|
||||||
|
}
|
||||||
|
if (optimizations.denoise) {
|
||||||
|
filters.push('hqdn3d');
|
||||||
|
}
|
||||||
|
if (optimizations.customFilters) {
|
||||||
|
filters.push(...optimizations.customFilters);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
args.push('-vf', filters.join(','));
|
||||||
|
|
||||||
|
// Audio encoding
|
||||||
|
// Select optimal bitrate based on source (don't upscale)
|
||||||
|
const targetAudioBitrate = parseInt(profile.audioBitrate) || 256;
|
||||||
|
const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate);
|
||||||
|
args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate);
|
||||||
|
|
||||||
|
// Audio optimizations
|
||||||
|
if (optimizations?.audioNormalize) {
|
||||||
|
args.push('-af', 'loudnorm');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Output
|
||||||
|
args.push('-f', 'mp4', outputPath);
|
||||||
|
|
||||||
|
await execFFmpeg(args, onProgress, duration);
|
||||||
|
|
||||||
|
return outputPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode all profiles to MP4 (parallel or sequential)
|
||||||
|
* Stage 1: Main encoding work
|
||||||
|
*/
|
||||||
|
export async function encodeProfilesToMP4(
|
||||||
|
input: string,
|
||||||
|
tempDir: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
videoCodec: string,
|
||||||
|
preset: string,
|
||||||
|
duration: number,
|
||||||
|
segmentDuration: number,
|
||||||
|
fps: number,
|
||||||
|
sourceAudioBitrate: number | undefined,
|
||||||
|
parallel: boolean,
|
||||||
|
maxConcurrent: number,
|
||||||
|
optimizations?: VideoOptimizations,
|
||||||
|
onProgress?: (profileName: string, percent: number) => void
|
||||||
|
): Promise<Map<string, string>> {
|
||||||
|
const mp4Files = new Map<string, string>();
|
||||||
|
|
||||||
|
if (parallel && profiles.length > 1) {
|
||||||
|
// Parallel encoding with batching
|
||||||
|
for (let i = 0; i < profiles.length; i += maxConcurrent) {
|
||||||
|
const batch = profiles.slice(i, i + maxConcurrent);
|
||||||
|
const batchPromises = batch.map((profile) =>
|
||||||
|
encodeProfileToMP4(
|
||||||
|
input,
|
||||||
|
tempDir,
|
||||||
|
profile,
|
||||||
|
videoCodec,
|
||||||
|
preset,
|
||||||
|
duration,
|
||||||
|
segmentDuration,
|
||||||
|
fps,
|
||||||
|
sourceAudioBitrate,
|
||||||
|
optimizations,
|
||||||
|
(percent) => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(profile.name, percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchResults = await Promise.all(batchPromises);
|
||||||
|
batchResults.forEach((mp4Path, idx) => {
|
||||||
|
const profile = batch[idx];
|
||||||
|
mp4Files.set(profile.name, mp4Path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Sequential encoding
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const mp4Path = await encodeProfileToMP4(
|
||||||
|
input,
|
||||||
|
tempDir,
|
||||||
|
profile,
|
||||||
|
videoCodec,
|
||||||
|
preset,
|
||||||
|
duration,
|
||||||
|
segmentDuration,
|
||||||
|
fps,
|
||||||
|
sourceAudioBitrate,
|
||||||
|
optimizations,
|
||||||
|
(percent) => {
|
||||||
|
if (onProgress) {
|
||||||
|
onProgress(profile.name, percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
mp4Files.set(profile.name, mp4Path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return mp4Files;
|
||||||
|
}
|
||||||
|
|
||||||
26
src/index.ts
Normal file
26
src/index.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
// Main exports
|
||||||
|
export { convertToDash } from './converter';
|
||||||
|
|
||||||
|
// Type exports
|
||||||
|
export type {
|
||||||
|
DashConvertOptions,
|
||||||
|
DashConvertResult,
|
||||||
|
VideoProfile,
|
||||||
|
ThumbnailConfig,
|
||||||
|
ConversionProgress,
|
||||||
|
VideoMetadata,
|
||||||
|
VideoOptimizations
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
// Utility exports
|
||||||
|
export {
|
||||||
|
checkFFmpeg,
|
||||||
|
checkMP4Box,
|
||||||
|
checkNvenc,
|
||||||
|
getVideoMetadata,
|
||||||
|
selectAudioBitrate
|
||||||
|
} from './utils';
|
||||||
|
|
||||||
|
// Profile exports
|
||||||
|
export { DEFAULT_PROFILES, selectProfiles } from './profiles';
|
||||||
|
|
||||||
135
src/packaging.ts
Normal file
135
src/packaging.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import { execMP4Box } from './utils';
|
||||||
|
import type { VideoProfile } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Package MP4 files into DASH format using MP4Box
|
||||||
|
* Stage 2: Light work - just packaging, no encoding
|
||||||
|
* Creates one master MPD manifest with all profiles
|
||||||
|
*/
|
||||||
|
export async function packageToDash(
|
||||||
|
mp4Files: Map<string, string>,
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[],
|
||||||
|
segmentDuration: number
|
||||||
|
): Promise<string> {
|
||||||
|
const manifestPath = join(outputDir, 'manifest.mpd');
|
||||||
|
|
||||||
|
// Build MP4Box command
|
||||||
|
const args = [
|
||||||
|
'-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds
|
||||||
|
'-frag', String(segmentDuration * 1000),
|
||||||
|
'-rap', // Force segments to start with random access points
|
||||||
|
'-segment-name', '$RepresentationID$_$Number$',
|
||||||
|
'-out', manifestPath
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add all MP4 files with their profile IDs
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const mp4Path = mp4Files.get(profile.name);
|
||||||
|
if (!mp4Path) {
|
||||||
|
throw new Error(`MP4 file not found for profile: ${profile.name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add video track with representation ID
|
||||||
|
args.push(`${mp4Path}#video:id=${profile.name}`);
|
||||||
|
// Add audio track (shared across all profiles)
|
||||||
|
if (profile === profiles[0]) {
|
||||||
|
args.push(`${mp4Path}#audio:id=audio`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute MP4Box
|
||||||
|
await execMP4Box(args);
|
||||||
|
|
||||||
|
// MP4Box creates files in the same directory as output MPD
|
||||||
|
// Move segment files to profile subdirectories for clean structure
|
||||||
|
await organizeSegments(outputDir, profiles);
|
||||||
|
|
||||||
|
// Update MPD to reflect new file structure with subdirectories
|
||||||
|
await updateManifestPaths(manifestPath, profiles);
|
||||||
|
|
||||||
|
return manifestPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Organize segments into profile subdirectories
|
||||||
|
* MP4Box creates all files in one directory, we organize them
|
||||||
|
*/
|
||||||
|
async function organizeSegments(
|
||||||
|
outputDir: string,
|
||||||
|
profiles: VideoProfile[]
|
||||||
|
): Promise<void> {
|
||||||
|
const { readdir, rename, mkdir } = await import('node:fs/promises');
|
||||||
|
|
||||||
|
// Create profile subdirectories
|
||||||
|
for (const profile of profiles) {
|
||||||
|
const profileDir = join(outputDir, profile.name);
|
||||||
|
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 === 'manifest.mpd') {
|
||||||
|
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 profile directories
|
||||||
|
for (const profile of profiles) {
|
||||||
|
if (file.startsWith(`${profile.name}_`)) {
|
||||||
|
const oldPath = join(outputDir, file);
|
||||||
|
const newPath = join(outputDir, profile.name, file);
|
||||||
|
await rename(oldPath, newPath);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update MPD manifest to reflect subdirectory structure
|
||||||
|
*/
|
||||||
|
async function updateManifestPaths(
|
||||||
|
manifestPath: string,
|
||||||
|
profiles: VideoProfile[]
|
||||||
|
): 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');
|
||||||
|
}
|
||||||
|
|
||||||
45
src/profiles.ts
Normal file
45
src/profiles.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import type { VideoProfile } from './types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default video quality profiles
|
||||||
|
*/
|
||||||
|
export const DEFAULT_PROFILES: VideoProfile[] = [
|
||||||
|
{
|
||||||
|
name: '1080p',
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
videoBitrate: '5000k',
|
||||||
|
audioBitrate: '256k'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '720p',
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
videoBitrate: '3000k',
|
||||||
|
audioBitrate: '256k'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '480p',
|
||||||
|
width: 854,
|
||||||
|
height: 480,
|
||||||
|
videoBitrate: '1500k',
|
||||||
|
audioBitrate: '256k'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '360p',
|
||||||
|
width: 640,
|
||||||
|
height: 360,
|
||||||
|
videoBitrate: '800k',
|
||||||
|
audioBitrate: '256k'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select appropriate profiles based on input video resolution
|
||||||
|
*/
|
||||||
|
export function selectProfiles(inputWidth: number, inputHeight: number): VideoProfile[] {
|
||||||
|
return DEFAULT_PROFILES.filter(profile => {
|
||||||
|
return profile.width <= inputWidth && profile.height <= inputHeight;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
110
src/thumbnails.ts
Normal file
110
src/thumbnails.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
|
import type { ThumbnailConfig } from './types';
|
||||||
|
import { execFFmpeg, formatVttTime } from './utils';
|
||||||
|
import { exists, readdir, unlink, rmdir } from 'node:fs/promises';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate thumbnail sprite and VTT file
|
||||||
|
*/
|
||||||
|
export async function generateThumbnailSprite(
|
||||||
|
inputPath: string,
|
||||||
|
outputDir: string,
|
||||||
|
duration: number,
|
||||||
|
config: Required<ThumbnailConfig>
|
||||||
|
): Promise<{ spritePath: string; vttPath: string }> {
|
||||||
|
const { width, height, interval, columns } = config;
|
||||||
|
|
||||||
|
// Create temp directory for individual thumbnails
|
||||||
|
const tempDir = join(outputDir, '.thumbnails_temp');
|
||||||
|
await Bun.write(join(tempDir, '.keep'), '');
|
||||||
|
|
||||||
|
// Generate individual thumbnails
|
||||||
|
const thumbnailPattern = join(tempDir, 'thumb_%04d.jpg');
|
||||||
|
|
||||||
|
await execFFmpeg([
|
||||||
|
'-i', inputPath,
|
||||||
|
'-vf', `fps=1/${interval},scale=${width}:${height}`,
|
||||||
|
'-q:v', '5',
|
||||||
|
thumbnailPattern
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Get list of generated thumbnails
|
||||||
|
const files = await readdir(tempDir);
|
||||||
|
const thumbFiles = files
|
||||||
|
.filter(f => f.startsWith('thumb_') && f.endsWith('.jpg'))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (thumbFiles.length === 0) {
|
||||||
|
throw new Error('No thumbnails generated');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate grid dimensions
|
||||||
|
const totalThumbs = thumbFiles.length;
|
||||||
|
const rows = Math.ceil(totalThumbs / columns);
|
||||||
|
|
||||||
|
// Create sprite sheet using FFmpeg
|
||||||
|
const spritePath = join(outputDir, 'thumbnails.jpg');
|
||||||
|
|
||||||
|
// Use pattern input for tile filter (not multiple -i inputs)
|
||||||
|
const tileFilter = `tile=${columns}x${rows}`;
|
||||||
|
|
||||||
|
await execFFmpeg([
|
||||||
|
'-i', thumbnailPattern, // Use pattern, not individual files
|
||||||
|
'-filter_complex', tileFilter,
|
||||||
|
'-q:v', '5',
|
||||||
|
spritePath
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Generate VTT file
|
||||||
|
const vttPath = join(outputDir, 'thumbnails.vtt');
|
||||||
|
const vttContent = generateVttContent(
|
||||||
|
totalThumbs,
|
||||||
|
interval,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
columns,
|
||||||
|
'thumbnails.jpg'
|
||||||
|
);
|
||||||
|
|
||||||
|
await Bun.write(vttPath, vttContent);
|
||||||
|
|
||||||
|
// Clean up temp files
|
||||||
|
for (const file of thumbFiles) {
|
||||||
|
await unlink(join(tempDir, file));
|
||||||
|
}
|
||||||
|
await unlink(join(tempDir, '.keep'));
|
||||||
|
await rmdir(tempDir); // Remove directory
|
||||||
|
|
||||||
|
return { spritePath, vttPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate VTT file content
|
||||||
|
*/
|
||||||
|
function generateVttContent(
|
||||||
|
totalThumbs: number,
|
||||||
|
interval: number,
|
||||||
|
thumbWidth: number,
|
||||||
|
thumbHeight: number,
|
||||||
|
columns: number,
|
||||||
|
spriteFilename: string
|
||||||
|
): string {
|
||||||
|
let vtt = 'WEBVTT\n\n';
|
||||||
|
|
||||||
|
for (let i = 0; i < totalThumbs; i++) {
|
||||||
|
const startTime = i * interval;
|
||||||
|
const endTime = (i + 1) * interval;
|
||||||
|
|
||||||
|
const row = Math.floor(i / columns);
|
||||||
|
const col = i % columns;
|
||||||
|
|
||||||
|
const x = col * thumbWidth;
|
||||||
|
const y = row * thumbHeight;
|
||||||
|
|
||||||
|
vtt += `${formatVttTime(startTime)} --> ${formatVttTime(endTime)}\n`;
|
||||||
|
vtt += `${spriteFilename}#xywh=${x},${y},${thumbWidth},${thumbHeight}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return vtt;
|
||||||
|
}
|
||||||
|
|
||||||
147
src/types.ts
Normal file
147
src/types.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* Configuration options for DASH conversion
|
||||||
|
*/
|
||||||
|
export interface DashConvertOptions {
|
||||||
|
/** Input video file path */
|
||||||
|
input: string;
|
||||||
|
|
||||||
|
/** Output directory path */
|
||||||
|
outputDir: string;
|
||||||
|
|
||||||
|
/** Segment duration in seconds (default: 2) */
|
||||||
|
segmentDuration?: number;
|
||||||
|
|
||||||
|
/** Video quality profiles to generate */
|
||||||
|
profiles?: VideoProfile[];
|
||||||
|
|
||||||
|
/** Enable NVENC hardware acceleration (auto-detect if undefined) */
|
||||||
|
useNvenc?: boolean;
|
||||||
|
|
||||||
|
/** Generate thumbnail sprite (default: true) */
|
||||||
|
generateThumbnails?: boolean;
|
||||||
|
|
||||||
|
/** Thumbnail sprite configuration */
|
||||||
|
thumbnailConfig?: ThumbnailConfig;
|
||||||
|
|
||||||
|
/** Parallel encoding (default: true) */
|
||||||
|
parallel?: boolean;
|
||||||
|
|
||||||
|
/** Callback for progress updates */
|
||||||
|
onProgress?: (progress: ConversionProgress) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video quality profile
|
||||||
|
*/
|
||||||
|
export interface VideoProfile {
|
||||||
|
/** Profile name (e.g., "1080p", "720p") */
|
||||||
|
name: string;
|
||||||
|
|
||||||
|
/** Video width in pixels */
|
||||||
|
width: number;
|
||||||
|
|
||||||
|
/** Video height in pixels */
|
||||||
|
height: number;
|
||||||
|
|
||||||
|
/** Video bitrate (e.g., "5000k") */
|
||||||
|
videoBitrate: string;
|
||||||
|
|
||||||
|
/** Audio bitrate (e.g., "128k") */
|
||||||
|
audioBitrate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thumbnail sprite configuration
|
||||||
|
*/
|
||||||
|
export interface ThumbnailConfig {
|
||||||
|
/** Width of each thumbnail (default: 160) */
|
||||||
|
width?: number;
|
||||||
|
|
||||||
|
/** Height of each thumbnail (default: 90) */
|
||||||
|
height?: number;
|
||||||
|
|
||||||
|
/** Interval between thumbnails in seconds (default: 1) */
|
||||||
|
interval?: number;
|
||||||
|
|
||||||
|
/** Number of thumbnails per row (default: 10) */
|
||||||
|
columns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversion progress information
|
||||||
|
*/
|
||||||
|
export interface ConversionProgress {
|
||||||
|
/** Current stage of conversion */
|
||||||
|
stage: 'analyzing' | 'encoding' | 'thumbnails' | 'manifest' | 'complete';
|
||||||
|
|
||||||
|
/** Progress percentage (0-100) - overall progress */
|
||||||
|
percent: number;
|
||||||
|
|
||||||
|
/** Current profile being processed */
|
||||||
|
currentProfile?: string;
|
||||||
|
|
||||||
|
/** Progress percentage for current profile (0-100) */
|
||||||
|
profilePercent?: number;
|
||||||
|
|
||||||
|
/** Additional message */
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of DASH conversion
|
||||||
|
*/
|
||||||
|
export interface DashConvertResult {
|
||||||
|
/** Path to generated MPD manifest */
|
||||||
|
manifestPath: string;
|
||||||
|
|
||||||
|
/** Paths to generated video segments */
|
||||||
|
videoPaths: string[];
|
||||||
|
|
||||||
|
/** Path to thumbnail sprite (if generated) */
|
||||||
|
thumbnailSpritePath?: string;
|
||||||
|
|
||||||
|
/** Path to thumbnail VTT file (if generated) */
|
||||||
|
thumbnailVttPath?: string;
|
||||||
|
|
||||||
|
/** Video duration in seconds */
|
||||||
|
duration: number;
|
||||||
|
|
||||||
|
/** Generated profiles */
|
||||||
|
profiles: VideoProfile[];
|
||||||
|
|
||||||
|
/** Whether NVENC was used */
|
||||||
|
usedNvenc: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video metadata
|
||||||
|
*/
|
||||||
|
export interface VideoMetadata {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
duration: number;
|
||||||
|
fps: number;
|
||||||
|
codec: string;
|
||||||
|
audioBitrate?: number; // Битрейт аудио в kbps
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Video optimizations (for future use)
|
||||||
|
*/
|
||||||
|
export interface VideoOptimizations {
|
||||||
|
/** Apply deinterlacing */
|
||||||
|
deinterlace?: boolean;
|
||||||
|
|
||||||
|
/** Apply denoising filter */
|
||||||
|
denoise?: boolean;
|
||||||
|
|
||||||
|
/** Color correction / LUT file path */
|
||||||
|
colorCorrection?: string;
|
||||||
|
|
||||||
|
/** Audio normalization */
|
||||||
|
audioNormalize?: boolean;
|
||||||
|
|
||||||
|
/** Custom FFmpeg filters */
|
||||||
|
customFilters?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
242
src/utils.ts
Normal file
242
src/utils.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
import { spawn } from 'bun';
|
||||||
|
import type { VideoMetadata } from './types';
|
||||||
|
import { mkdir, exists } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if FFmpeg is available
|
||||||
|
*/
|
||||||
|
export async function checkFFmpeg(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const proc = spawn(['ffmpeg', '-version']);
|
||||||
|
await proc.exited;
|
||||||
|
return proc.exitCode === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if MP4Box is available
|
||||||
|
*/
|
||||||
|
export async function checkMP4Box(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const proc = spawn(['MP4Box', '-version']);
|
||||||
|
await proc.exited;
|
||||||
|
return proc.exitCode === 0;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if NVENC is available
|
||||||
|
*/
|
||||||
|
export async function checkNvenc(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const proc = spawn(['ffmpeg', '-hide_banner', '-encoders']);
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
return output.includes('h264_nvenc') || output.includes('hevc_nvenc');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get video metadata using ffprobe
|
||||||
|
*/
|
||||||
|
export async function getVideoMetadata(inputPath: string): Promise<VideoMetadata> {
|
||||||
|
const proc = spawn([
|
||||||
|
'ffprobe',
|
||||||
|
'-v', 'error',
|
||||||
|
'-select_streams', 'v:0',
|
||||||
|
'-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name',
|
||||||
|
'-select_streams', 'a:0',
|
||||||
|
'-show_entries', 'stream=bit_rate',
|
||||||
|
'-show_entries', 'format=duration',
|
||||||
|
'-of', 'json',
|
||||||
|
inputPath
|
||||||
|
]);
|
||||||
|
|
||||||
|
const output = await new Response(proc.stdout).text();
|
||||||
|
const data = JSON.parse(output);
|
||||||
|
|
||||||
|
const videoStream = data.streams.find((s: any) => s.width !== undefined);
|
||||||
|
const audioStream = data.streams.find((s: any) => s.bit_rate !== undefined && s.width === undefined);
|
||||||
|
const format = data.format;
|
||||||
|
|
||||||
|
// Parse frame rate
|
||||||
|
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
|
||||||
|
const fps = num / den;
|
||||||
|
|
||||||
|
// Get duration from stream or format
|
||||||
|
const duration = parseFloat(videoStream.duration || format.duration || '0');
|
||||||
|
|
||||||
|
// Get audio bitrate in kbps
|
||||||
|
const audioBitrate = audioStream?.bit_rate
|
||||||
|
? Math.round(parseInt(audioStream.bit_rate) / 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: videoStream.width,
|
||||||
|
height: videoStream.height,
|
||||||
|
duration,
|
||||||
|
fps,
|
||||||
|
codec: videoStream.codec_name,
|
||||||
|
audioBitrate
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select optimal audio bitrate based on source
|
||||||
|
* Don't upscale audio quality - use min of source and target
|
||||||
|
*/
|
||||||
|
export function selectAudioBitrate(
|
||||||
|
sourceAudioBitrate: number | undefined,
|
||||||
|
targetBitrate: number = 256
|
||||||
|
): string {
|
||||||
|
if (!sourceAudioBitrate) {
|
||||||
|
// If we can't detect source bitrate, use target
|
||||||
|
return `${targetBitrate}k`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use minimum of source and target (no upscaling)
|
||||||
|
const optimalBitrate = Math.min(sourceAudioBitrate, targetBitrate);
|
||||||
|
|
||||||
|
// Round to common bitrate values for consistency
|
||||||
|
if (optimalBitrate <= 64) return '64k';
|
||||||
|
if (optimalBitrate <= 96) return '96k';
|
||||||
|
if (optimalBitrate <= 128) return '128k';
|
||||||
|
if (optimalBitrate <= 192) return '192k';
|
||||||
|
return '256k';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure directory exists
|
||||||
|
*/
|
||||||
|
export async function ensureDir(dirPath: string): Promise<void> {
|
||||||
|
if (!await exists(dirPath)) {
|
||||||
|
await mkdir(dirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute FFmpeg command with progress tracking
|
||||||
|
*/
|
||||||
|
export async function execFFmpeg(
|
||||||
|
args: string[],
|
||||||
|
onProgress?: (percent: number) => void,
|
||||||
|
duration?: number
|
||||||
|
): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(['ffmpeg', ...args], {
|
||||||
|
stderr: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
let stderrData = '';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const reader = proc.stderr.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
|
||||||
|
const text = decoder.decode(value, { stream: true });
|
||||||
|
stderrData += text;
|
||||||
|
|
||||||
|
if (onProgress && duration) {
|
||||||
|
// Parse time from FFmpeg output: time=00:01:23.45
|
||||||
|
const timeMatch = text.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);
|
||||||
|
if (timeMatch) {
|
||||||
|
const hours = parseInt(timeMatch[1]);
|
||||||
|
const minutes = parseInt(timeMatch[2]);
|
||||||
|
const seconds = parseFloat(timeMatch[3]);
|
||||||
|
const currentTime = hours * 3600 + minutes * 60 + seconds;
|
||||||
|
const percent = Math.min(100, (currentTime / duration) * 100);
|
||||||
|
onProgress(percent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Stream reading error
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
proc.exited.then(() => {
|
||||||
|
if (proc.exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(`FFmpeg failed with exit code ${proc.exitCode}\n${stderrData}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute MP4Box command
|
||||||
|
*/
|
||||||
|
export async function execMP4Box(args: string[]): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(['MP4Box', ...args], {
|
||||||
|
stdout: 'pipe',
|
||||||
|
stderr: 'pipe'
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdoutData = '';
|
||||||
|
let stderrData = '';
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const stdoutReader = proc.stdout.getReader();
|
||||||
|
const stderrReader = proc.stderr.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Read stdout
|
||||||
|
const readStdout = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await stdoutReader.read();
|
||||||
|
if (done) break;
|
||||||
|
stdoutData += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Read stderr
|
||||||
|
const readStderr = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await stderrReader.read();
|
||||||
|
if (done) break;
|
||||||
|
stderrData += decoder.decode(value, { stream: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
await Promise.all([readStdout(), readStderr()]);
|
||||||
|
} catch (err) {
|
||||||
|
// Stream reading error
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
proc.exited.then(() => {
|
||||||
|
if (proc.exitCode === 0) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
const output = stderrData || stdoutData;
|
||||||
|
reject(new Error(`MP4Box failed with exit code ${proc.exitCode}\n${output}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time for VTT file (HH:MM:SS.mmm)
|
||||||
|
*/
|
||||||
|
export function formatVttTime(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${secs.toFixed(3).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
23
tsconfig.json
Normal file
23
tsconfig.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "ESNext",
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"types": ["bun-types"],
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": false,
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "dist"]
|
||||||
|
}
|
||||||
|
|
||||||
38
web-test/index.html
Normal file
38
web-test/index.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Document</title>
|
||||||
|
|
||||||
|
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
|
||||||
|
<!-- <script src="https://cdnjs.cloudflare.com/polyfill/v2/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent,Object.entries,Object.values,URL"></script> -->
|
||||||
|
<script src="https://unpkg.com/plyr@3"></script>
|
||||||
|
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.container {
|
||||||
|
margin: 50px auto;
|
||||||
|
max-width: 1500px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<video controls playsinline></video>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const source = 'https://bitmovin-a.akamaihd.net/content/sintel/sintel.mpd';
|
||||||
|
const dash = dashjs.MediaPlayer().create();
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
dash.initialize(video, source, true);
|
||||||
|
|
||||||
|
const player = new Plyr(video, {captions: {active: true, update: true}});
|
||||||
|
window.player = player;
|
||||||
|
window.dash = dash;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user