This commit is contained in:
2025-11-09 01:28:42 +03:00
parent c4c13268c5
commit 9873a44c47
23 changed files with 554 additions and 573 deletions

1
.gitignore vendored
View File

@@ -14,7 +14,6 @@ dist/
*.mkv *.mkv
*.avi *.avi
*.mov *.mov
output/
test-output/ test-output/
# Игнорировать IDE файлы # Игнорировать IDE файлы

32
.npmignore Normal file
View 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
View 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
View File

@@ -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

File diff suppressed because one or more lines are too long

0
data/input/.gitkeep Normal file
View File

0
data/output/.gitkeep Normal file
View File

136
docs/PUBLISHING.md Normal file
View 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`)

View File

@@ -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"
} }
} }

View File

@@ -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];

View File

@@ -1,4 +1,4 @@
import type { VideoProfile } from './types'; import type { VideoProfile } from '../types';
/** /**
* Default video quality profiles * Default video quality profiles

View File

@@ -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';

View File

@@ -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

View File

@@ -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

View File

@@ -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';
/** /**

View File

@@ -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';

View File

@@ -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
View 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
View 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
View 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
View 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')}`;
}