sync
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,7 +14,6 @@ dist/
|
||||
*.mkv
|
||||
*.avi
|
||||
*.mov
|
||||
output/
|
||||
test-output/
|
||||
|
||||
# Игнорировать IDE файлы
|
||||
|
||||
32
.npmignore
Normal file
32
.npmignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Source files
|
||||
src/
|
||||
*.ts
|
||||
!*.d.ts
|
||||
|
||||
# Development files
|
||||
app.ts
|
||||
tsconfig.json
|
||||
bun.lock
|
||||
|
||||
# Test files
|
||||
web-test/
|
||||
examples/
|
||||
|
||||
# Documentation
|
||||
FEATURES.md
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
22
LICENSE
Normal file
22
LICENSE
Normal file
@@ -0,0 +1,22 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 grom13
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
333
README.md
333
README.md
@@ -1,321 +1,52 @@
|
||||
# DASH Video Converter 🎬
|
||||
|
||||
Быстрая библиотека для конвертации видео в формат DASH с поддержкой аппаратного ускорения NVIDIA NVENC и генерацией превью спрайтов.
|
||||
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (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
|
||||
```
|
||||
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты (1080p/720p/480p/360p) • 🖼️ Thumbnail спрайты • 📊 Прогресс в реальном времени
|
||||
|
||||
## Быстрый старт
|
||||
|
||||
```typescript
|
||||
import { convertToDash } from '@dash-converter/core';
|
||||
```bash
|
||||
# Использование через npx (без установки)
|
||||
npx @grom13/dvc-cli video.mp4 ./output
|
||||
|
||||
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);
|
||||
# Или глобальная установка
|
||||
npm install -g @grom13/dvc-cli
|
||||
dvc video.mp4 ./output
|
||||
```
|
||||
|
||||
## API
|
||||
**Системные требования:**
|
||||
```bash
|
||||
# Arch Linux
|
||||
sudo pacman -S ffmpeg gpac
|
||||
|
||||
### `convertToDash(options: DashConvertOptions): Promise<DashConvertResult>`
|
||||
# Ubuntu/Debian
|
||||
sudo apt install ffmpeg gpac
|
||||
|
||||
Основная функция конвертации видео в 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;
|
||||
}
|
||||
# macOS
|
||||
brew install ffmpeg gpac
|
||||
```
|
||||
|
||||
#### Профили видео
|
||||
**Результат:** В папке `./output/video/` будет создан `manifest.mpd` и видео сегменты для разных качеств.
|
||||
|
||||
```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 превью в ряд
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Запуск примера
|
||||
## Параметры CLI
|
||||
|
||||
```bash
|
||||
# Конвертировать видео
|
||||
bun run example examples/basic.ts ./input.mp4 ./output
|
||||
|
||||
# Или через npm script
|
||||
bun run example
|
||||
npx @grom13/dvc-cli <input-video> [output-dir]
|
||||
# или после установки:
|
||||
dvc <input-video> [output-dir]
|
||||
```
|
||||
|
||||
## Утилиты
|
||||
| Параметр | Описание | По умолчанию | Обязательный |
|
||||
|----------|----------|--------------|--------------|
|
||||
| `input-video` | Путь к входному видео файлу | - | ✅ |
|
||||
| `output-dir` | Директория для выходных файлов | `./output` | ❌ |
|
||||
|
||||
### Проверка системы
|
||||
**Автоматические настройки:**
|
||||
- Длительность сегментов: 2 секунды
|
||||
- NVENC: автоопределение (GPU если доступен, иначе CPU)
|
||||
- Профили качества: автоматический выбор на основе разрешения исходного видео
|
||||
- Превью спрайты: генерируются автоматически (160x90px, каждые 10 сек)
|
||||
- Параллельное кодирование: включено
|
||||
|
||||
```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
|
||||
**Требования:** Node.js ≥18.0.0, FFmpeg, MP4Box (gpac), опционально NVIDIA GPU для ускорения
|
||||
|
||||
19
bin/cli.js
Executable file
19
bin/cli.js
Executable file
File diff suppressed because one or more lines are too long
0
data/input/.gitkeep
Normal file
0
data/input/.gitkeep
Normal file
0
data/output/.gitkeep
Normal file
0
data/output/.gitkeep
Normal file
136
docs/PUBLISHING.md
Normal file
136
docs/PUBLISHING.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# 📦 Инструкция по публикации в NPM
|
||||
|
||||
## Подготовка к публикации
|
||||
|
||||
### Шаг 1: Авторизация в NPM
|
||||
|
||||
```bash
|
||||
npm login
|
||||
```
|
||||
|
||||
Введите credentials для аккаунта с доступом к организации `@grom13`.
|
||||
|
||||
### Шаг 2: Сборка проекта
|
||||
|
||||
```bash
|
||||
cd /home/gromov/projects/my/dvc-cli
|
||||
npm run build
|
||||
```
|
||||
|
||||
Эта команда выполнит:
|
||||
- Сборку библиотеки в `dist/`
|
||||
- Генерацию TypeScript деклараций (`.d.ts`)
|
||||
- Сборку CLI бинарника в `bin/cli.js`
|
||||
|
||||
### Шаг 3: Проверка перед публикацией (опционально)
|
||||
|
||||
```bash
|
||||
# Посмотреть какие файлы будут опубликованы
|
||||
npm pack --dry-run
|
||||
|
||||
# Или создать тестовый архив для проверки
|
||||
npm pack
|
||||
# Это создаст файл grom13-dvc-cli-0.1.0.tgz
|
||||
```
|
||||
|
||||
## Публикация
|
||||
|
||||
### Шаг 4: Публикация в NPM
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
⚠️ **Важно:** Флаг `--access public` обязателен для scoped пакетов (`@grom13/...`), иначе NPM попытается опубликовать как приватный пакет (требует платную подписку).
|
||||
|
||||
### Шаг 5: Проверка публикации
|
||||
|
||||
```bash
|
||||
# Проверить что пакет доступен
|
||||
npm view @grom13/dvc-cli
|
||||
|
||||
# Протестировать установку через npx
|
||||
npx @grom13/dvc-cli --help
|
||||
|
||||
# Или установить глобально и протестировать
|
||||
npm install -g @grom13/dvc-cli
|
||||
dvc --help
|
||||
```
|
||||
|
||||
## Обновление версии
|
||||
|
||||
Для будущих релизов используйте команды версионирования:
|
||||
|
||||
```bash
|
||||
# Patch версия (0.1.0 → 0.1.1) - исправления багов
|
||||
npm version patch
|
||||
|
||||
# Minor версия (0.1.0 → 0.2.0) - новые функции
|
||||
npm version minor
|
||||
|
||||
# Major версия (0.1.0 → 1.0.0) - breaking changes
|
||||
npm version major
|
||||
```
|
||||
|
||||
После обновления версии:
|
||||
|
||||
```bash
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
## Откат публикации (если нужно)
|
||||
|
||||
```bash
|
||||
# Удалить конкретную версию (в течение 72 часов)
|
||||
npm unpublish @grom13/dvc-cli@0.1.0
|
||||
|
||||
# Удалить весь пакет (использовать осторожно!)
|
||||
npm unpublish @grom13/dvc-cli --force
|
||||
```
|
||||
|
||||
⚠️ **Внимание:** После unpublish нельзя повторно опубликовать ту же версию. Нужно увеличить версию.
|
||||
|
||||
## Использование после публикации
|
||||
|
||||
Пакет будет доступен для использования:
|
||||
|
||||
```bash
|
||||
# Через npx (без установки)
|
||||
npx @grom13/dvc-cli video.mp4 ./output
|
||||
|
||||
# Глобальная установка
|
||||
npm install -g @grom13/dvc-cli
|
||||
dvc video.mp4 ./output
|
||||
|
||||
# Локальная установка в проект
|
||||
npm install @grom13/dvc-cli
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Ошибка: "You must sign up for private packages"
|
||||
|
||||
Решение: Добавьте флаг `--access public` при публикации.
|
||||
|
||||
### Ошибка: "You do not have permission to publish"
|
||||
|
||||
Решение: Убедитесь что вы авторизованы (`npm whoami`) и имеете доступ к организации `@grom13`.
|
||||
|
||||
### Ошибка при сборке
|
||||
|
||||
Решение: Убедитесь что установлены все зависимости:
|
||||
```bash
|
||||
npm install
|
||||
# или
|
||||
bun install
|
||||
```
|
||||
|
||||
## Checklist перед публикацией
|
||||
|
||||
- [ ] Обновлена версия в `package.json`
|
||||
- [ ] Обновлен `README.md` с актуальной информацией
|
||||
- [ ] Проект успешно собирается (`npm run build`)
|
||||
- [ ] Протестирован CLI локально
|
||||
- [ ] Авторизованы в NPM (`npm whoami`)
|
||||
- [ ] Проверены файлы для публикации (`npm pack --dry-run`)
|
||||
|
||||
47
package.json
47
package.json
@@ -1,20 +1,32 @@
|
||||
{
|
||||
"name": "@dash-converter/core",
|
||||
"version": "1.0.0",
|
||||
"name": "@grom13/dvc-cli",
|
||||
"version": "0.1.0",
|
||||
"description": "Fast DASH video converter with NVENC acceleration and thumbnail sprites",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"bin": {
|
||||
"dvc": "./bin/cli.js"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts"
|
||||
}
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"bin",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "bun build src/index.ts --outdir dist --target node",
|
||||
"dev": "bun run src/index.ts",
|
||||
"example": "bun run examples/basic.ts"
|
||||
"build": "npm run build:lib && npm run build:cli",
|
||||
"build:lib": "bun build src/index.ts --outdir dist --target node && tsc --emitDeclarationOnly",
|
||||
"build:cli": "bun build src/cli.ts --outfile bin/cli.js --target node --minify",
|
||||
"prepublishOnly": "npm run build",
|
||||
"dev": "bun run src/cli.ts",
|
||||
"test": "bun run src/cli.ts"
|
||||
},
|
||||
"keywords": [
|
||||
"dash",
|
||||
@@ -22,20 +34,31 @@
|
||||
"converter",
|
||||
"ffmpeg",
|
||||
"nvenc",
|
||||
"streaming"
|
||||
"streaming",
|
||||
"cli",
|
||||
"video-processing",
|
||||
"adaptive-streaming",
|
||||
"thumbnails"
|
||||
],
|
||||
"author": "",
|
||||
"author": "grom13",
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/grom13/dvc-cli.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/grom13/dvc-cli/issues"
|
||||
},
|
||||
"homepage": "https://github.com/grom13/dvc-cli#readme",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.3.2",
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"typescript": "^5.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bun": ">=1.0.0"
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@types/cli-progress": "^3.11.6",
|
||||
"cli-progress": "^3.12.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
#!/usr/bin/env bun
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Quick test script to verify the library works
|
||||
* DASH Video Converter CLI
|
||||
*
|
||||
* Usage:
|
||||
* bun run test.ts <input-video> [output-dir]
|
||||
* dvc <input-video> [output-dir]
|
||||
*
|
||||
* Example:
|
||||
* bun run test.ts ./video.mp4 ./output
|
||||
* dvc ./video.mp4 ./output
|
||||
*/
|
||||
|
||||
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box } from './src/index';
|
||||
import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box } from './index';
|
||||
import cliProgress from 'cli-progress';
|
||||
|
||||
const input = process.argv[2];
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { VideoProfile } from './types';
|
||||
import type { VideoProfile } from '../types';
|
||||
|
||||
/**
|
||||
* Default video quality profiles
|
||||
@@ -7,15 +7,15 @@ import type {
|
||||
VideoProfile,
|
||||
ThumbnailConfig,
|
||||
ConversionProgress
|
||||
} from './types';
|
||||
} from '../types';
|
||||
import {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
checkNvenc,
|
||||
getVideoMetadata,
|
||||
ensureDir
|
||||
} from './utils';
|
||||
import { selectProfiles } from './profiles';
|
||||
} from '../utils';
|
||||
import { selectProfiles } from '../config/profiles';
|
||||
import { generateThumbnailSprite } from './thumbnails';
|
||||
import { encodeProfilesToMP4 } from './encoding';
|
||||
import { packageToDash } from './packaging';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from 'node:path';
|
||||
import { execFFmpeg, selectAudioBitrate } from './utils';
|
||||
import type { VideoProfile, VideoOptimizations } from './types';
|
||||
import { execFFmpeg, selectAudioBitrate } from '../utils';
|
||||
import type { VideoProfile, VideoOptimizations } from '../types';
|
||||
|
||||
/**
|
||||
* Encode single profile to MP4
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from 'node:path';
|
||||
import { execMP4Box } from './utils';
|
||||
import type { VideoProfile } from './types';
|
||||
import { execMP4Box } from '../utils';
|
||||
import type { VideoProfile } from '../types';
|
||||
|
||||
/**
|
||||
* Package MP4 files into DASH format using MP4Box
|
||||
@@ -1,6 +1,6 @@
|
||||
import { join } from 'node:path';
|
||||
import type { ThumbnailConfig } from './types';
|
||||
import { execFFmpeg, formatVttTime } from './utils';
|
||||
import type { ThumbnailConfig } from '../types';
|
||||
import { execFFmpeg, formatVttTime } from '../utils';
|
||||
import { exists, readdir, unlink, rmdir } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
@@ -1,5 +1,5 @@
|
||||
// Main exports
|
||||
export { convertToDash } from './converter';
|
||||
export { convertToDash } from './core/converter';
|
||||
|
||||
// Type exports
|
||||
export type {
|
||||
@@ -22,5 +22,5 @@ export {
|
||||
} from './utils';
|
||||
|
||||
// Profile exports
|
||||
export { DEFAULT_PROFILES, selectProfiles } from './profiles';
|
||||
export { DEFAULT_PROFILES, selectProfiles } from './config/profiles';
|
||||
|
||||
|
||||
242
src/utils.ts
242
src/utils.ts
@@ -1,242 +0,0 @@
|
||||
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')}`;
|
||||
}
|
||||
|
||||
11
src/utils/fs.ts
Normal file
11
src/utils/fs.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { mkdir, exists } from 'node:fs/promises';
|
||||
|
||||
/**
|
||||
* Ensure directory exists
|
||||
*/
|
||||
export async function ensureDir(dirPath: string): Promise<void> {
|
||||
if (!await exists(dirPath)) {
|
||||
await mkdir(dirPath, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
21
src/utils/index.ts
Normal file
21
src/utils/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// System utilities
|
||||
export {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
checkNvenc,
|
||||
execFFmpeg,
|
||||
execMP4Box
|
||||
} from './system';
|
||||
|
||||
// Video utilities
|
||||
export {
|
||||
getVideoMetadata,
|
||||
selectAudioBitrate,
|
||||
formatVttTime
|
||||
} from './video';
|
||||
|
||||
// File system utilities
|
||||
export {
|
||||
ensureDir
|
||||
} from './fs';
|
||||
|
||||
125
src/utils/system.ts
Normal file
125
src/utils/system.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
/**
|
||||
* Check if FFmpeg is available
|
||||
*/
|
||||
export async function checkFFmpeg(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffmpeg', ['-version']);
|
||||
proc.on('error', () => resolve(false));
|
||||
proc.on('close', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if MP4Box is available
|
||||
*/
|
||||
export async function checkMP4Box(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('MP4Box', ['-version']);
|
||||
proc.on('error', () => resolve(false));
|
||||
proc.on('close', (code) => resolve(code === 0));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if NVENC is available
|
||||
*/
|
||||
export async function checkNvenc(): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn('ffmpeg', ['-hide_banner', '-encoders']);
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', () => resolve(false));
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
resolve(false);
|
||||
} else {
|
||||
resolve(output.includes('h264_nvenc') || output.includes('hevc_nvenc'));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
let stderrData = '';
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
const text = data.toString();
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new Error(`FFmpeg error: ${err.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`FFmpeg failed with exit code ${code}\n${stderrData}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute MP4Box command
|
||||
*/
|
||||
export async function execMP4Box(args: string[]): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn('MP4Box', args);
|
||||
|
||||
let stdoutData = '';
|
||||
let stderrData = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
stdoutData += data.toString();
|
||||
});
|
||||
|
||||
proc.stderr.on('data', (data) => {
|
||||
stderrData += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new Error(`MP4Box error: ${err.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve();
|
||||
} else {
|
||||
const output = stderrData || stdoutData;
|
||||
reject(new Error(`MP4Box failed with exit code ${code}\n${output}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
104
src/utils/video.ts
Normal file
104
src/utils/video.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { VideoMetadata } from '../types';
|
||||
|
||||
/**
|
||||
* Get video metadata using ffprobe
|
||||
*/
|
||||
export async function getVideoMetadata(inputPath: string): Promise<VideoMetadata> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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
|
||||
]);
|
||||
|
||||
let output = '';
|
||||
|
||||
proc.stdout.on('data', (data) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new Error(`ffprobe error: ${err.message}`));
|
||||
});
|
||||
|
||||
proc.on('close', (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`ffprobe failed with exit code ${code}`));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
|
||||
resolve({
|
||||
width: videoStream.width,
|
||||
height: videoStream.height,
|
||||
duration,
|
||||
fps,
|
||||
codec: videoStream.codec_name,
|
||||
audioBitrate
|
||||
});
|
||||
} catch (err) {
|
||||
reject(new Error(`Failed to parse ffprobe output: ${err}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* 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')}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user