sync
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -14,7 +14,6 @@ dist/
|
|||||||
*.mkv
|
*.mkv
|
||||||
*.avi
|
*.avi
|
||||||
*.mov
|
*.mov
|
||||||
output/
|
|
||||||
test-output/
|
test-output/
|
||||||
|
|
||||||
# Игнорировать IDE файлы
|
# Игнорировать 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 Video Converter 🎬
|
||||||
|
|
||||||
Быстрая библиотека для конвертации видео в формат DASH с поддержкой аппаратного ускорения NVIDIA NVENC и генерацией превью спрайтов.
|
CLI инструмент для конвертации видео в формат DASH с поддержкой GPU ускорения (NVENC), адаптивным стримингом и автоматической генерацией превью.
|
||||||
|
|
||||||
## Возможности
|
**Возможности:** ⚡ NVENC ускорение • 🎯 Множественные битрейты (1080p/720p/480p/360p) • 🖼️ Thumbnail спрайты • 📊 Прогресс в реальном времени
|
||||||
|
|
||||||
- ⚡ **Аппаратное ускорение 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
|
```bash
|
||||||
import { convertToDash } from '@dash-converter/core';
|
# Использование через npx (без установки)
|
||||||
|
npx @grom13/dvc-cli video.mp4 ./output
|
||||||
|
|
||||||
const result = await convertToDash({
|
# Или глобальная установка
|
||||||
input: './video.mp4',
|
npm install -g @grom13/dvc-cli
|
||||||
outputDir: './output',
|
dvc video.mp4 ./output
|
||||||
segmentDuration: 2,
|
|
||||||
useNvenc: true, // Использовать NVENC если доступен
|
|
||||||
generateThumbnails: true,
|
|
||||||
onProgress: (progress) => {
|
|
||||||
console.log(`${progress.stage}: ${progress.percent}%`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Готово!', result.manifestPath);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## API
|
**Системные требования:**
|
||||||
|
```bash
|
||||||
|
# Arch Linux
|
||||||
|
sudo pacman -S ffmpeg gpac
|
||||||
|
|
||||||
### `convertToDash(options: DashConvertOptions): Promise<DashConvertResult>`
|
# Ubuntu/Debian
|
||||||
|
sudo apt install ffmpeg gpac
|
||||||
|
|
||||||
Основная функция конвертации видео в DASH формат.
|
# macOS
|
||||||
|
brew install ffmpeg gpac
|
||||||
#### Опции
|
|
||||||
|
|
||||||
```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;
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Профили видео
|
**Результат:** В папке `./output/video/` будет создан `manifest.mpd` и видео сегменты для разных качеств.
|
||||||
|
|
||||||
```typescript
|
## Параметры CLI
|
||||||
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
|
```bash
|
||||||
# Конвертировать видео
|
npx @grom13/dvc-cli <input-video> [output-dir]
|
||||||
bun run example examples/basic.ts ./input.mp4 ./output
|
# или после установки:
|
||||||
|
dvc <input-video> [output-dir]
|
||||||
# Или через npm script
|
|
||||||
bun run example
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Утилиты
|
| Параметр | Описание | По умолчанию | Обязательный |
|
||||||
|
|----------|----------|--------------|--------------|
|
||||||
|
| `input-video` | Путь к входному видео файлу | - | ✅ |
|
||||||
|
| `output-dir` | Директория для выходных файлов | `./output` | ❌ |
|
||||||
|
|
||||||
### Проверка системы
|
**Автоматические настройки:**
|
||||||
|
- Длительность сегментов: 2 секунды
|
||||||
|
- NVENC: автоопределение (GPU если доступен, иначе CPU)
|
||||||
|
- Профили качества: автоматический выбор на основе разрешения исходного видео
|
||||||
|
- Превью спрайты: генерируются автоматически (160x90px, каждые 10 сек)
|
||||||
|
- Параллельное кодирование: включено
|
||||||
|
|
||||||
```typescript
|
**Требования:** Node.js ≥18.0.0, FFmpeg, MP4Box (gpac), опционально NVIDIA GPU для ускорения
|
||||||
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
|
|
||||||
|
|||||||
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",
|
"name": "@grom13/dvc-cli",
|
||||||
"version": "1.0.0",
|
"version": "0.1.0",
|
||||||
"description": "Fast DASH video converter with NVENC acceleration and thumbnail sprites",
|
"description": "Fast DASH video converter with NVENC acceleration and thumbnail sprites",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts",
|
"types": "./dist/index.d.ts",
|
||||||
|
"bin": {
|
||||||
|
"dvc": "./bin/cli.js"
|
||||||
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
"import": "./dist/index.js",
|
"import": "./dist/index.js",
|
||||||
"types": "./dist/index.d.ts"
|
"types": "./dist/index.d.ts"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"bin",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "bun build src/index.ts --outdir dist --target node",
|
"build": "npm run build:lib && npm run build:cli",
|
||||||
"dev": "bun run src/index.ts",
|
"build:lib": "bun build src/index.ts --outdir dist --target node && tsc --emitDeclarationOnly",
|
||||||
"example": "bun run examples/basic.ts"
|
"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": [
|
"keywords": [
|
||||||
"dash",
|
"dash",
|
||||||
@@ -22,20 +34,31 @@
|
|||||||
"converter",
|
"converter",
|
||||||
"ffmpeg",
|
"ffmpeg",
|
||||||
"nvenc",
|
"nvenc",
|
||||||
"streaming"
|
"streaming",
|
||||||
|
"cli",
|
||||||
|
"video-processing",
|
||||||
|
"adaptive-streaming",
|
||||||
|
"thumbnails"
|
||||||
],
|
],
|
||||||
"author": "",
|
"author": "grom13",
|
||||||
"license": "MIT",
|
"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": {
|
"devDependencies": {
|
||||||
"@types/bun": "^1.3.2",
|
"@types/bun": "^1.3.2",
|
||||||
|
"@types/cli-progress": "^3.11.6",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
|
||||||
"bun": ">=1.0.0"
|
|
||||||
},
|
|
||||||
"private": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@types/cli-progress": "^3.11.6",
|
|
||||||
"cli-progress": "^3.12.0"
|
"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:
|
* Usage:
|
||||||
* bun run test.ts <input-video> [output-dir]
|
* dvc <input-video> [output-dir]
|
||||||
*
|
*
|
||||||
* Example:
|
* 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';
|
import cliProgress from 'cli-progress';
|
||||||
|
|
||||||
const input = process.argv[2];
|
const input = process.argv[2];
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { VideoProfile } from './types';
|
import type { VideoProfile } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default video quality profiles
|
* Default video quality profiles
|
||||||
@@ -7,15 +7,15 @@ import type {
|
|||||||
VideoProfile,
|
VideoProfile,
|
||||||
ThumbnailConfig,
|
ThumbnailConfig,
|
||||||
ConversionProgress
|
ConversionProgress
|
||||||
} from './types';
|
} from '../types';
|
||||||
import {
|
import {
|
||||||
checkFFmpeg,
|
checkFFmpeg,
|
||||||
checkMP4Box,
|
checkMP4Box,
|
||||||
checkNvenc,
|
checkNvenc,
|
||||||
getVideoMetadata,
|
getVideoMetadata,
|
||||||
ensureDir
|
ensureDir
|
||||||
} from './utils';
|
} from '../utils';
|
||||||
import { selectProfiles } from './profiles';
|
import { selectProfiles } from '../config/profiles';
|
||||||
import { generateThumbnailSprite } from './thumbnails';
|
import { generateThumbnailSprite } from './thumbnails';
|
||||||
import { encodeProfilesToMP4 } from './encoding';
|
import { encodeProfilesToMP4 } from './encoding';
|
||||||
import { packageToDash } from './packaging';
|
import { packageToDash } from './packaging';
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { execFFmpeg, selectAudioBitrate } from './utils';
|
import { execFFmpeg, selectAudioBitrate } from '../utils';
|
||||||
import type { VideoProfile, VideoOptimizations } from './types';
|
import type { VideoProfile, VideoOptimizations } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encode single profile to MP4
|
* Encode single profile to MP4
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { execMP4Box } from './utils';
|
import { execMP4Box } from '../utils';
|
||||||
import type { VideoProfile } from './types';
|
import type { VideoProfile } from '../types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Package MP4 files into DASH format using MP4Box
|
* Package MP4 files into DASH format using MP4Box
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { ThumbnailConfig } from './types';
|
import type { ThumbnailConfig } from '../types';
|
||||||
import { execFFmpeg, formatVttTime } from './utils';
|
import { execFFmpeg, formatVttTime } from '../utils';
|
||||||
import { exists, readdir, unlink, rmdir } from 'node:fs/promises';
|
import { exists, readdir, unlink, rmdir } from 'node:fs/promises';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// Main exports
|
// Main exports
|
||||||
export { convertToDash } from './converter';
|
export { convertToDash } from './core/converter';
|
||||||
|
|
||||||
// Type exports
|
// Type exports
|
||||||
export type {
|
export type {
|
||||||
@@ -22,5 +22,5 @@ export {
|
|||||||
} from './utils';
|
} from './utils';
|
||||||
|
|
||||||
// Profile exports
|
// 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