Compare commits

...

19 Commits

Author SHA1 Message Date
b40ae34387 0.1.15 2026-01-22 11:44:03 +03:00
84231d705f style: Update README 2026-01-22 11:44:00 +03:00
2c8d9d1e9e 0.1.14 2026-01-22 11:39:57 +03:00
41fe1a7370 style: Update README 2026-01-22 11:39:51 +03:00
55fb1f640a 0.1.13 2026-01-22 11:14:47 +03:00
4293b6735a chore: update files 2026-01-22 11:14:21 +03:00
248fe15b62 0.1.12 2026-01-22 11:09:08 +03:00
81add91669 style: update Readme 2026-01-22 11:08:26 +03:00
b6c191290c 0.1.11 2026-01-22 10:44:41 +03:00
5ab30eee4c build cli 2026-01-22 10:44:39 +03:00
187697eca6 fix: Замена кодека при GPU скейлинге на nv12
fix: Исправлена проблема с звуком и телепортами по частям видео
2026-01-22 10:43:54 +03:00
346eb697cf 0.1.10 2026-01-22 09:52:12 +03:00
b8f9f0e046 fix: Исправить баг с масштабированием через VIDEOTOOLBOX ускоритель.
feat: добавлена возможность генерировать видео без звука -m --muted
2026-01-22 09:51:32 +03:00
0813bea1d4 style: README_RU 2026-01-21 10:15:32 +03:00
3bc980ef1d Правка профилей по высоте и документация по качеству 2026-01-21 10:13:20 +03:00
13b624480d 0.1.8 2026-01-20 14:53:04 +03:00
b7e264d56f docs: обновить readme под новые опции 2026-01-20 14:52:59 +03:00
970b58c2a4 0.1.7 2026-01-20 14:31:48 +03:00
17748d3900 fix: добавить поддержку auto для videotoolbox 2026-01-20 14:31:03 +03:00
14 changed files with 290 additions and 213 deletions

View File

@@ -11,18 +11,8 @@ CLI tool to convert videos to DASH and HLS with hardware acceleration (NVENC / I
- 🖼️ Preview: thumbnail sprite + VTT, poster from the first frame
- ⏱️ Progress: per-profile and overall CLI progress bars
## Quick Start
```bash
# Run via npx (no install)
npx @gromlab/create-vod video.mp4
# Or install globally
npm install -g @gromlab/create-vod
create-vod video.mp4
```
**System requirements:**
## Install
For the CLI to work correctly, FFmpeg and MP4Box must be installed in the system.
```bash
# Arch Linux
sudo pacman -S ffmpeg gpac
@@ -34,12 +24,33 @@ sudo apt install ffmpeg gpac
brew install ffmpeg gpac
```
**Output:** A folder `video/` in the current directory with segments under `{profile}-{codec}/`, DASH/HLS manifests in the root, poster, and thumbnail sprite/VTT.
## Quick Start
Before running, make sure `FFmpeg` and `MP4Box` are installed (see Install).
```bash
npx @gromlab/create-vod video.mp4
```
**Output:** In the current directory you'll get:
```
video/
├── manifest.mpd # DASH manifest
├── master.m3u8 # HLS master playlist
├── poster.jpg # Poster frame
├── thumbnails.{jpg,vtt} # Sprite + VTT cues
├── audio/ # Audio init + segments (AAC)
├── 1080p/ # H.264 1080p init + segments
├── 720p/ # H.264 720p
├── 480p/ # H.264 480p
├── 360p/ # H.264 360p
├── 1080p-av1/ # AV1 1080p (if av1 selected)
└── ... # Other profiles/codecs as {profile}-{codec}
```
## CLI Usage
```bash
create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]
create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-p poster-timecode] [-e encoder] [-d decoder] [-m]
```
### Main arguments
@@ -54,44 +65,41 @@ create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-
| Option | Description | Values / Format | Default | Example |
|--------|----------------------------|----------------------------|----------|---------------------------------|
| `-r, --resolutions` | Quality profiles | `360`, `720@60`, `1080-60` | auto | `-r 720,1080,1440@60` |
| `-c, --codec` | Video codec | `h264`, `av1` | auto (h264 + AV1 if HW) | `-c h264` |
| `-f, --format` | Streaming format | `dash`, `hls` | auto (dash + hls) | `-f dash` |
| `-c, --codec` | Video codec(s) | `h264`, `av1` (comma/space separated) | `h264` | `-c h264,av1` |
| `-p, --poster` | Poster timecode | `HH:MM:SS` or seconds | `00:00:00` | `-p 00:00:05` or `-p 10` |
| `-e, --encoder` | Video encoder | `auto`, `nvenc`, `qsv`, `amf`, `vaapi`, `videotoolbox`, `v4l2`, `cpu` | `auto` | `-e nvenc` |
| `-d, --decoder` | Video decoder (hwaccel) | `auto`, `nvenc`, `qsv`, `vaapi`, `videotoolbox`, `v4l2`, `cpu` | `auto` | `-d cpu` |
| `-m, --muted` | Disable audio track | flag | off | `-m` |
### Examples
```bash
# Default (DASH + HLS, auto codec, auto profiles)
create-vod video.mp4
# Default (DASH + HLS, auto profiles)
npx @gromlab/create-vod video.mp4
# Custom output directory
create-vod video.mp4 ./output
npx @gromlab/create-vod video.mp4 ./output
# Selected resolutions
create-vod video.mp4 -r 720,1080,1440
npx @gromlab/create-vod video.mp4 -r 720,1080,1440
# High FPS
create-vod video.mp4 -r 720@60,1080@60
# DASH only
create-vod video.mp4 -f dash
# HLS only (Safari/iOS)
create-vod video.mp4 -f hls -c h264
npx @gromlab/create-vod video.mp4 -r 720@60,1080@60
# Poster from 5th second
create-vod video.mp4 -p 5
npx @gromlab/create-vod video.mp4 -p 5
# Force CPU encode/decode
create-vod video.mp4 -c h264 -e cpu -d cpu
npx @gromlab/create-vod video.mp4 -c h264 -e cpu -d cpu
# Force GPU encode + CPU decode
create-vod video.mp4 -c h264 -e nvenc -d cpu
npx @gromlab/create-vod video.mp4 -c h264 -e nvenc -d cpu
# Combined parameters
create-vod video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10
npx @gromlab/create-vod video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10
# No audio
npx @gromlab/create-vod video.mp4 -m
```
### Supported resolutions
@@ -116,6 +124,6 @@ High FPS (60/90/120) are generated only if the source supports that FPS.
- Thumbnails: auto sprite (160×90, 1s interval) + VTT
- Poster: first frame (0:00:00, configurable via `-p`)
- Parallel encoding: enabled
- AV1: enabled only if hardware AV1 encoder detected (in auto mode); otherwise остаётся H.264
- AV1: enabled only if hardware AV1 encoder detected (in auto mode); otherwise stays on H.264
**Requirements:** Node.js ≥18.0.0, FFmpeg, MP4Box (gpac), optional NVIDIA/Intel/AMD GPU for acceleration

View File

@@ -11,18 +11,8 @@ CLI инструмент для конвертации видео в форма
- 🖼️ Превью: thumbnail спрайты + VTT, постер с первого кадра
- ⏱️ Прогресс: CLI прогресс-бары по профилям и общему этапу
## Быстрый старт
```bash
# Использование через npx (без установки)
npx @gromlab/create-vod video.mp4
# Или глобальная установка
npm install -g @gromlab/create-vod
create-vod video.mp4
```
**Системные требования:**
## Install
Для корректной работы CLI требуется установленые в системе FFmpeg и MP4Box.
```bash
# Arch Linux
sudo pacman -S ffmpeg gpac
@@ -34,12 +24,33 @@ sudo apt install ffmpeg gpac
brew install ffmpeg gpac
```
**Результат:** В текущей директории будет создана папка `video/` с сегментами в папках `{profile}-{codec}/`, манифестами DASH и HLS в корне, постером и превью спрайтами.
## Быстрый старт
Перед запуском убедитесь, что в системе установлены `FFmpeg` и `MP4Box` (см. Install).
```bash
npx @gromlab/create-vod video.mp4
```
**Результат:** В текущей директории появится структура выходных файлов:
```
video/
├── manifest.mpd # DASH манифест
├── master.m3u8 # HLS мастер-плейлист
├── poster.jpg # Постер с указанного таймкода
├── thumbnails.jpg # Спрайт превью
├── thumbnails.vtt # Таймкоды превью
├── audio/ # Аудиосегменты (init + m4s)
├── 1080p/ # Сегменты H.264 1080p
├── 720p/ # Сегменты H.264 720p
├── 480p/ # Сегменты H.264 480p
├── 360p/ # Сегменты H.264 360p
├── 1080p-av1/ # Сегменты AV1 1080p (если выбран av1)
└── ... # Остальные профили/кодеки по схеме {profile}-{codec}
```
## Параметры CLI
```bash
create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode]
create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-p poster-timecode] [-e encoder] [-d decoder] [-m]
```
### Основные параметры
@@ -54,38 +65,35 @@ create-vod <input-video> [output-dir] [-r resolutions] [-c codec] [-f format] [-
| Ключ | Описание | Значения / формат | По умолчанию | Пример |
|------|----------|-------------------|--------------|--------|
| `-r, --resolutions` | Выбор профилей качества | `360`, `720@60`, `1080-60` | авто | `-r 720,1080,1440@60` |
| `-c, --codec` | Видео кодек | `h264`, `av1` | авто (h264 + AV1 при наличии HW) | `-c h264` |
| `-f, --format` | Формат стриминга | `dash`, `hls` | авто (dash + hls) | `-f dash` |
| `-c, --codec` | Видео кодек(и) | `h264`, `av1` (через пробел или запятую) | `h264` | `-c h264,av1` |
| `-p, --poster` | Таймкод для постера | `HH:MM:SS` или секунды | `00:00:00` | `-p 00:00:05` или `-p 10` |
| `-e, --encoder` | Видео энкодер | `auto`, `nvenc`, `qsv`, `amf`, `vaapi`, `videotoolbox`, `v4l2`, `cpu` | `auto` | `-e nvenc` |
| `-d, --decoder` | Видео декодер (hwaccel) | `auto`, `nvenc`, `qsv`, `vaapi`, `videotoolbox`, `v4l2`, `cpu` | `auto` | `-d cpu` |
| `-m, --muted` | Отключить аудио дорожку в выходных файлах | `flag` | `off` | `-m` |
### Примеры использования
```bash
# Базовая конвертация (DASH + HLS, авто кодек, автопрофили)
create-vod video.mp4
npx @gromlab/create-vod video.mp4
# Указать выходную директорию
create-vod video.mp4 ./output
npx @gromlab/create-vod video.mp4 ./output
# Только выбранные разрешения
create-vod video.mp4 -r 720,1080,1440
npx @gromlab/create-vod video.mp4 -r 720,1080,1440
# Высокий FPS для игровых стримов
create-vod video.mp4 -r 720@60,1080@60
# Только DASH формат
create-vod video.mp4 -f dash
# Только HLS для Safari/iOS
create-vod video.mp4 -f hls -c h264
npx @gromlab/create-vod video.mp4 -r 720@60,1080@60
# Постер с 5-й секунды
create-vod video.mp4 -p 5
npx @gromlab/create-vod video.mp4 -p 5
# Комбинация параметров
create-vod video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10
npx @gromlab/create-vod video.mp4 ./output -r 720,1080@60,1440@60 -p 00:00:10
# Без звука
npx @gromlab/create-vod video.mp4 -m
```
### Поддерживаемые разрешения

File diff suppressed because one or more lines are too long

40
docs/QUALITY.md Normal file
View File

@@ -0,0 +1,40 @@
# Регулировка качества и измерение
## Целевое качество
- Ориентир: средний VMAF ≥ 93 для кодированного видео.
## Что регулируем
- GPU-энкодеры используют CQ (Constant Quality).
- CPU-энкодеры используют CRF (Constant Rate Factor).
- Значения по умолчанию взяты из текущей логики кодирования (см. `src/core/encoding.ts`) и могут быть переопределены через CLI (`--h264-cq`, `--h264-crf`, `--av1-cq`, `--av1-crf`).
## Дефолтные CQ/CRF
| Энкодер | H.264 | AV1 |
|------------------|-----------------|----------------|
| NVENC | CQ 32 | CQ 42 |
| QSV | CQ 32 | CQ 42 |
| AMF | CQ 32 | CQ 42 |
| VAAPI | CQ 32 | CQ 42 |
| videotoolbox | CQ 32 | CQ 42 |
| v4l2 | CQ 32 | CQ 42 |
| CPU (libx264 / libsvtav1) | CRF 25→20* | CRF 40→28* |
\* CPU значения зависят от целевого разрешения:
- 360p: H.264 CRF 25, AV1 CRF 40
- 480p: H.264 CRF 24, AV1 CRF 38
- 720p: H.264 CRF 23, AV1 CRF 35
- 1080p: H.264 CRF 22, AV1 CRF 32
- 1440p: H.264 CRF 21, AV1 CRF 30
- 2160p: H.264 CRF 20, AV1 CRF 28
## Как мерить качество (VMAF)
1. Закодируйте тестовый ролик с нужными параметрами (CQ/CRF).
2. Сравните с исходником по VMAF (разрешения должны совпадать, при необходимости приведите к одному размеру).
3. Пример команды FFmpeg c libvmaf:
```bash
ffmpeg -i encoded.mp4 -i source.mp4 \
-lavfi "[0:v]setpts=PTS-STARTPTS[enc];[1:v]setpts=PTS-STARTPTS[ref];[enc][ref]libvmaf=log_path=vmaf.json:log_fmt=json" \
-f null -
```
В логах ищите `VMAF score`; если < 93 увеличьте качество (понизьте CQ/CRF), если > 95 и нужен меньший битрейт — можно чуть поднять CQ/CRF.

View File

@@ -1,7 +1,7 @@
{
"name": "@gromlab/create-vod",
"author": "Gromov Sergei",
"version": "0.1.6",
"version": "0.1.15",
"description": "DASH/HLS video converter with hardware acceleration (NVENC/QSV/AMF/VAAPI), thumbnails and poster generation",
"type": "module",
"main": "./dist/index.js",
@@ -19,6 +19,7 @@
"dist",
"bin",
"README.md",
"README_RU.md",
"LICENSE"
],
"scripts": {

View File

@@ -14,15 +14,14 @@ import { convertToDash, checkFFmpeg, checkMP4Box, getVideoMetadata, detectHardwa
import cliProgress from 'cli-progress';
import { statSync } from 'node:fs';
import { basename, extname } from 'node:path';
import type { CodecChoice, StreamingFormatChoice, QualitySettings, HardwareAccelerationOption, HardwareAccelerator } from './types';
import type { QualitySettings, HardwareAccelerationOption, HardwareAccelerator, CodecType } from './types';
import { selectProfiles, createProfilesFromStrings } from './config/profiles';
// Parse arguments
const args = process.argv.slice(2);
let customProfiles: string[] | undefined;
let posterTimecode: string | undefined;
let codecChoice: CodecChoice = 'auto'; // h264 + AV1 if HW
let formatChoice: StreamingFormatChoice = 'auto'; // DASH + HLS
let codecChoice: Array<CodecType> | undefined; // default h264
const positionalArgs: string[] = [];
// Quality settings
@@ -32,6 +31,7 @@ let av1CQ: number | undefined;
let av1CRF: number | undefined;
let accelerator: HardwareAccelerationOption | undefined;
let decoder: HardwareAccelerationOption | undefined;
let muted = false;
// First pass: extract flags and their values
for (let i = 0; i < args.length; i++) {
@@ -57,22 +57,16 @@ for (let i = 0; i < args.length; i++) {
posterTimecode = args[i + 1];
i++; // Skip next arg
} else if (args[i] === '-c' || args[i] === '--codec') {
const codec = args[i + 1];
if (codec === 'av1' || codec === 'h264') {
codecChoice = codec;
} else {
console.error(`❌ Invalid codec: ${codec}. Valid options: av1, h264`);
process.exit(1);
}
i++; // Skip next arg
} else if (args[i] === '-f' || args[i] === '--format') {
const format = args[i + 1];
if (format === 'dash' || format === 'hls') {
formatChoice = format;
} else {
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls`);
process.exit(1);
const codecArg = args[i + 1];
const parts = codecArg.split(/[,\s]+/).map(p => p.trim()).filter(Boolean);
const allowed = new Set(['h264', 'av1']);
for (const p of parts) {
if (!allowed.has(p)) {
console.error(`❌ Invalid codec: ${p}. Valid options: av1, h264`);
process.exit(1);
}
}
codecChoice = Array.from(new Set(parts)) as Array<'h264' | 'av1'>;
i++; // Skip next arg
} else if (args[i] === '--h264-cq') {
h264CQ = parseInt(args[i + 1]);
@@ -120,6 +114,8 @@ for (let i = 0; i < args.length; i++) {
}
decoder = acc as HardwareAccelerationOption;
i++;
} else if (args[i] === '-m' || args[i] === '--muted') {
muted = true;
} else if (!args[i].startsWith('-')) {
// Positional argument
positionalArgs.push(args[i]);
@@ -139,6 +135,7 @@ if (!input) {
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
console.error(' -e, --encoder <type> Hardware encoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)');
console.error(' -d, --decoder <type> Hardware decoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)');
console.error(' -m, --muted Disable audio track (no audio in output)');
console.error('\nQuality Options (override defaults):');
console.error(' --h264-cq <value> H.264 GPU CQ value (0-51, lower = better, default: auto)');
console.error(' --h264-crf <value> H.264 CPU CRF value (0-51, lower = better, default: auto)');
@@ -255,29 +252,24 @@ if (!hasMP4Box) {
}
// Resolve codec selection
let includeH264 = codecChoice === 'h264' || codecChoice === 'auto';
let includeAv1 = codecChoice === 'av1' || codecChoice === 'auto';
const codecsRequested = codecChoice && codecChoice.length > 0 ? codecChoice : ['h264'];
let includeH264 = codecsRequested.includes('h264');
let includeAv1 = codecsRequested.includes('av1');
if (includeAv1 && !hasAv1Hardware && codecChoice === 'auto') {
includeAv1 = false;
if (!includeH264) {
console.warn('⚠️ H.264 is mandatory for compatibility. Adding H.264.');
includeH264 = true;
}
if (codecChoice === 'av1' && !hasAv1Hardware) {
console.error(`⚠️ Warning: AV1 encoding requested but no hardware AV1 encoder found.`);
if (includeAv1 && !hasAv1Hardware) {
console.error(`⚠️ AV1 requested but no hardware AV1 encoder found.`);
console.error(` CPU-based AV1 encoding (libsvtav1) will be VERY slow.`);
console.error(` Consider using --codec h264 for faster encoding.\n`);
}
// Resolve formats
const wantDash = formatChoice === 'dash' || formatChoice === 'auto';
const wantHls = formatChoice === 'hls' || formatChoice === 'auto';
// Validate HLS requires H.264
if (wantHls && !includeH264) {
console.error(`❌ Error: HLS format requires H.264 codec for Safari/iOS compatibility.`);
console.error(` Please use --codec h264 or omit --codec to keep H.264.\n`);
process.exit(1);
}
// Formats are always both
const wantDash = true;
const wantHls = true;
// Get video metadata and file size
console.log('📊 Analyzing video...\n');
@@ -290,7 +282,7 @@ console.log(` File: ${input}`);
console.log(` Size: ${fileSizeMB} MB`);
console.log(` Resolution: ${metadata.width}x${metadata.height}`);
console.log(` FPS: ${metadata.fps.toFixed(2)}`);
console.log(` Duration: ${Math.floor(metadata.duration / 60)}m ${Math.floor(metadata.duration % 60)}s`);
console.log(` Duration: ${metadata.duration.toFixed(2)}s`);
console.log(` Codec: ${metadata.codec}`);
if (metadata.videoBitrate) {
console.log(` Video Bitrate: ${(metadata.videoBitrate / 1000).toFixed(2)} Mbps`);
@@ -321,7 +313,7 @@ if (customProfiles && customProfiles.length > 0) {
profileResult.warnings.forEach(warn => console.warn(` - ${warn}`));
}
displayProfiles = profileResult.profiles.map(p => p.name);
displayProfiles = profileResult.profiles.map(p => p.fps ? `${p.name}@${p.fps}` : p.name);
} else {
const autoProfiles = selectProfiles(
metadata.width,
@@ -329,7 +321,7 @@ if (customProfiles && customProfiles.length > 0) {
metadata.fps,
metadata.videoBitrate
);
displayProfiles = autoProfiles.map(p => p.name);
displayProfiles = autoProfiles.map(p => p.fps ? `${p.name}@${p.fps}` : p.name);
}
const manifestDesc = [
@@ -343,7 +335,7 @@ const codecListDisplay = [
includeH264 ? 'h264' : null,
includeAv1 ? 'av1' : null
].filter(Boolean).join(', ');
const codecNote = (!includeAv1 && codecChoice === 'auto' && !hasAv1Hardware) ? ' (AV1 disabled: no HW)' : '';
const codecNote = (!includeAv1 && codecsRequested.includes('av1')) ? ' (AV1 disabled: no HW)' : '';
const bestAccelName = (bestAccel && bestAccel.toUpperCase()) || 'CPU';
const bestDecoderName = (bestDecoder && bestDecoder.toUpperCase()) || 'CPU';
const plannedAccel = accelerator ? accelerator.toUpperCase() : bestAccelName;
@@ -363,6 +355,7 @@ console.log(` Poster: ${posterPlanned} (will be generated)`);
console.log(` Thumbnails: ${thumbnailsPlanned ? 'yes (with VTT)' : 'no'}`);
console.log(` Encoder: ${acceleratorDisplay} (available: ${encoderListDisplay})`);
console.log(` Decoder: ${decoderDisplay} (available: ${decoderListDisplay})`);
console.log(` Audio: ${muted ? 'disabled (muted)' : 'enabled'}`);
// Build quality settings if any are specified
let quality: QualitySettings | undefined;
@@ -407,14 +400,17 @@ try {
outputDir,
customProfiles,
posterTimecode,
codec: codecChoice,
format: formatChoice,
codec: [
...(includeH264 ? ['h264'] as const : []),
...(includeAv1 ? ['av1'] as const : [])
],
segmentDuration: 2,
hardwareAccelerator: accelerator,
hardwareDecoder: decoder,
quality,
generateThumbnails: true,
generatePoster: true,
muted,
parallel: true,
onProgress: (progress) => {
const stageName = progress.stage === 'encoding' ? 'Encoding' :

View File

@@ -87,7 +87,7 @@ export const DEFAULT_PROFILES: VideoProfile[] = [
/**
* Select appropriate profiles based on input video resolution
* Only creates profiles that are equal to or smaller than input resolution
* Oriented by height: only profiles with height <= source height
* Always generates 30 FPS profiles by default
* For high FPS (>30), user must explicitly specify in customProfiles
*/
@@ -101,7 +101,7 @@ export function selectProfiles(
// Standard 30 FPS profiles (always created)
const baseProfiles = DEFAULT_PROFILES.filter(profile => {
return profile.width <= inputWidth && profile.height <= inputHeight;
return profile.height <= inputHeight;
});
// Add standard 30fps profiles with bitrate limit
@@ -206,8 +206,8 @@ export function validateProfile(
}
// Check if source supports this resolution
if (profile.width > sourceWidth || profile.height > sourceHeight) {
return { error: `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})` };
if (profile.height > sourceHeight) {
return { error: `Source height (${sourceHeight}px) is lower than requested ${profileStr} height (${profile.height}px)` };
}
// Check if requested FPS exceeds source FPS
@@ -271,4 +271,3 @@ export function createProfilesFromStrings(
return { profiles, errors, warnings };
}

View File

@@ -8,9 +8,7 @@ import type {
ThumbnailConfig,
ConversionProgress,
CodecType,
CodecChoice,
StreamingFormat,
StreamingFormatChoice,
HardwareAccelerationOption,
HardwareAccelerator,
HardwareEncoderInfo,
@@ -43,8 +41,8 @@ export async function convertToDash(
segmentDuration = 2,
profiles: userProfiles,
customProfiles,
codec = 'auto',
format = 'auto',
codec = ['h264'],
formats = ['dash', 'hls'],
hardwareDecoder,
hardwareAccelerator,
quality,
@@ -53,6 +51,7 @@ export async function convertToDash(
generatePoster: shouldGeneratePoster = true,
posterTimecode = '00:00:00',
parallel = true,
muted = false,
onProgress
} = options;
@@ -76,8 +75,8 @@ DASH Conversion Log
Started: ${new Date().toISOString()}
Input: ${input}
Output: ${videoOutputDir}
Codec: ${codec}
Format: ${format}
Codec: ${Array.isArray(codec) ? codec.join(',') : codec}
Formats: ${formats?.join(',') || 'dash,hls'}
===========================================\n`;
await writeFile(logFile, header, 'utf-8');
@@ -90,7 +89,7 @@ Format: ${format}
userProfiles,
customProfiles,
codec,
format,
formats,
hardwareAccelerator,
hardwareDecoder,
quality,
@@ -99,6 +98,7 @@ Format: ${format}
shouldGeneratePoster,
posterTimecode,
parallel,
muted,
onProgress
);
} finally {
@@ -129,8 +129,8 @@ async function convertToDashInternal(
segmentDuration: number,
userProfiles: VideoProfile[] | undefined,
customProfiles: string[] | undefined,
codec: CodecChoice,
format: StreamingFormatChoice,
codec: CodecType | CodecType[],
formats: StreamingFormat[] | undefined,
hardwareAccelerator: HardwareAccelerationOption | undefined,
hardwareDecoder: HardwareAccelerationOption | undefined,
quality: DashConvertOptions['quality'],
@@ -139,6 +139,7 @@ async function convertToDashInternal(
generatePosterFlag: boolean,
posterTimecode: string,
parallel: boolean,
muted: boolean,
onProgress?: (progress: ConversionProgress) => void
): Promise<DashConvertResult> {
@@ -162,7 +163,12 @@ async function convertToDashInternal(
// Get video metadata
const metadata = await getVideoMetadata(input);
const hasAudio = metadata.hasAudio;
const hasAudio = !muted && metadata.hasAudio;
const durationSeconds = metadata.duration;
// Подгоняем длительность сегмента под общий хронометраж, чтобы не оставался короткий хвост
const segmentCount = Math.max(1, Math.ceil(durationSeconds / segmentDuration));
const effectiveSegmentDuration = durationSeconds / segmentCount;
// Determine hardware accelerator (auto by default)
const preferredAccelerator: HardwareAccelerationOption =
@@ -175,12 +181,9 @@ async function convertToDashInternal(
const av1HardwareAvailable = hardwareEncoders.some(info => info.av1Encoder);
let wantH264 = codec === 'h264' || codec === 'auto';
let wantAv1 = codec === 'av1' || codec === 'auto';
if (codec === 'auto' && !av1HardwareAvailable) {
wantAv1 = false;
}
const codecList = Array.isArray(codec) ? codec : [codec];
const wantH264 = codecList.includes('h264');
const wantAv1 = codecList.includes('av1');
const { selected, h264Encoder, av1Encoder, warnings: accelWarnings } = selectHardwareEncoders(
hardwareEncoders,
@@ -200,7 +203,7 @@ async function convertToDashInternal(
hardwareDecoder || 'auto'
);
if (codec === 'av1' && !av1HardwareAvailable) {
if (wantAv1 && !av1HardwareAvailable) {
console.warn('⚠️ AV1 hardware encoder not detected. AV1 will use CPU encoder (slow).');
}
@@ -209,10 +212,7 @@ async function convertToDashInternal(
if (wantAv1) codecsSelected.push('av1');
if (codecsSelected.length === 0) codecsSelected.push('h264');
const formatsSelected: StreamingFormat[] = [];
if (format === 'dash' || format === 'auto') formatsSelected.push('dash');
if (format === 'hls' || format === 'auto') formatsSelected.push('hls');
if (formatsSelected.length === 0) formatsSelected.push('dash');
const formatsSelected: StreamingFormat[] = formats && formats.length > 0 ? Array.from(new Set(formats)) : ['dash', 'hls'];
// Select profiles
let profiles: VideoProfile[];
@@ -319,13 +319,14 @@ async function convertToDashInternal(
videoCodec,
codecPreset,
metadata.duration,
segmentDuration,
effectiveSegmentDuration,
metadata.audioBitrate,
parallel,
maxConcurrent,
type, // Pass codec type to differentiate output files
codecQuality, // Pass quality settings (CQ/CRF)
undefined, // optimizations - for future use
muted,
selectedDecoder === 'cpu' ? undefined : selectedDecoder,
(profileName, percent) => {
const profileIndex = profiles.findIndex(p => p.name === profileName);
@@ -358,7 +359,7 @@ async function convertToDashInternal(
codecMP4Paths,
videoOutputDir,
profiles,
segmentDuration,
effectiveSegmentDuration,
codecsSelected,
formatsSelected,
hasAudio
@@ -462,7 +463,7 @@ function selectHardwareEncoders(
} {
const warnings: string[] = [];
const supportedForAuto = new Set<HardwareAccelerator>(['nvenc', 'qsv', 'amf']);
const supportedForAuto = new Set<HardwareAccelerator>(['nvenc', 'qsv', 'amf', 'vaapi', 'videotoolbox', 'v4l2']);
const relevant = available.filter(info =>
(needsH264 && info.h264Encoder) || (needsAV1 && info.av1Encoder)
);

View File

@@ -53,6 +53,7 @@ export async function encodeProfileToMP4(
codecType: 'h264' | 'av1',
qualitySettings?: CodecQualitySettings,
optimizations?: VideoOptimizations,
muted: boolean = false,
decoderAccel?: HardwareAccelerator,
onProgress?: (percent: number) => void
): Promise<string> {
@@ -157,12 +158,15 @@ export async function encodeProfileToMP4(
// Build video filter chain
const filters: string[] = [];
const targetWidth = profile.width;
const targetHeight = profile.height;
if (decoderAccel === 'nvenc') {
// CUDA path: keep frames on GPU
filters.push(`scale_cuda=${profile.width}:${profile.height}`);
const useCudaScale = decoderAccel === 'nvenc';
if (useCudaScale) {
// CUDA path: вписываем в профиль с сохранением исходного AR
filters.push(`scale_cuda=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2`);
} else {
filters.push(`scale=${profile.width}:${profile.height}`);
filters.push(`scale=${targetWidth}:${targetHeight}:force_original_aspect_ratio=decrease:force_divisible_by=2`);
}
// Apply optimizations (for future use)
@@ -178,17 +182,34 @@ export async function encodeProfileToMP4(
}
}
// Если использовали GPU-скейл, возвращаем кадры в системную память перед CPU-фильтрами
if (useCudaScale) {
filters.push('hwdownload', 'format=nv12');
}
// Центрируем кадр, чтобы браузеры (Firefox/videotoolbox) не игнорировали PAR
filters.push(`pad=${targetWidth}:${targetHeight}:(ow-iw)/2:(oh-ih)/2`, 'setsar=1');
args.push('-vf', filters.join(','));
// Audio encoding
// Select optimal bitrate based on source (don't upscale)
const targetAudioBitrate = parseInt(profile.audioBitrate) || 256;
const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate);
args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate);
// Audio optimizations
if (optimizations?.audioNormalize) {
args.push('-af', 'loudnorm');
if (!muted) {
// Audio encoding с нормализацией таймингов и автоподпаддингом тишиной
const targetAudioBitrate = parseInt(profile.audioBitrate) || 256;
const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate);
args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate);
const targetDur = duration.toFixed(3);
const audioFilters: string[] = [
'aresample=async=1:min_hard_comp=0.1:first_pts=0',
`apad=whole_dur=${targetDur}`,
`atrim=0:${targetDur}`
];
if (optimizations?.audioNormalize) {
audioFilters.push('loudnorm');
}
args.push('-af', audioFilters.join(','));
} else {
args.push('-an'); // без аудио дорожки
}
// Output
@@ -217,6 +238,7 @@ export async function encodeProfilesToMP4(
codecType: 'h264' | 'av1',
qualitySettings?: CodecQualitySettings,
optimizations?: VideoOptimizations,
muted: boolean = false,
decoderAccel?: HardwareAccelerator,
onProgress?: (profileName: string, percent: number) => void
): Promise<Map<string, string>> {
@@ -239,6 +261,7 @@ export async function encodeProfilesToMP4(
codecType,
qualitySettings,
optimizations,
muted,
decoderAccel,
(percent) => {
if (onProgress) {
@@ -269,6 +292,7 @@ export async function encodeProfilesToMP4(
codecType,
qualitySettings,
optimizations,
muted,
decoderAccel,
(percent) => {
if (onProgress) {

View File

@@ -11,9 +11,7 @@ export type {
VideoMetadata,
VideoOptimizations,
CodecType,
CodecChoice,
StreamingFormat,
StreamingFormatChoice,
HardwareAccelerator,
HardwareAccelerationOption,
HardwareEncoderInfo,

View File

@@ -8,16 +8,6 @@ export type CodecType = 'av1' | 'h264';
*/
export type StreamingFormat = 'dash' | 'hls';
/**
* Пользовательский выбор кодека (auto = h264 + av1 при наличии HW)
*/
export type CodecChoice = CodecType | 'auto';
/**
* Пользовательский выбор форматов (auto = dash + hls)
*/
export type StreamingFormatChoice = StreamingFormat | 'auto';
/**
* Тип аппаратного ускорителя
*/
@@ -85,16 +75,19 @@ export interface DashConvertOptions {
/** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */
customProfiles?: string[];
/** Video codec selection: h264, av1, or auto (default: auto = h264 + AV1 if HW) */
codec?: CodecChoice;
/** Video codec selection: one or multiple (default: ['h264']) */
codec?: CodecType | CodecType[];
/** Streaming formats: dash, hls, or auto (default: auto = оба) */
format?: StreamingFormatChoice;
/** Streaming formats: list (default: ['dash','hls']) */
formats?: StreamingFormat[];
/** Предпочитаемый аппаратный ускоритель (auto по умолчанию) */
/** Предпочитаемый аппаратный ускоритель (auto по умолчанию) */
hardwareAccelerator?: HardwareAccelerationOption;
/** Предпочитаемый аппаратный ускоритель для декодера (auto по умолчанию) */
hardwareDecoder?: HardwareAccelerationOption;
/** Отключить аудиодорожку (muted). По умолчанию false. */
muted?: boolean;
/** Quality settings for video encoding (CQ/CRF values) */
quality?: QualitySettings;

View File

@@ -90,13 +90,16 @@ export function selectAudioBitrate(
sourceAudioBitrate: number | undefined,
targetBitrate: number = 256
): string {
const MIN_AUDIO_KBPS = 64; // не опускаться ниже базового качества
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);
// Не занижаем слишком низко: clamp к минималке, но не выше целевого
const clampedSource = Math.max(sourceAudioBitrate, MIN_AUDIO_KBPS);
const optimalBitrate = Math.min(clampedSource, targetBitrate);
// Round to common bitrate values for consistency
if (optimalBitrate <= 64) return '64k';

3
web-test/dash.all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
<script src="https://cdn.dashjs.org/latest/dash.all.min.js"></script>
<!-- <script src="https://cdnjs.cloudflare.com/polyfill/v2/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent,Object.entries,Object.values,URL"></script> -->
<script src="dash.all.min.js"></script>
<script src="https://cdnjs.cloudflare.com/polyfill/v2/polyfill.min.js?features=es6,Array.prototype.includes,CustomEvent,Object.entries,Object.values,URL"></script>
<script src="https://unpkg.com/plyr@3"></script>
<link rel="stylesheet" href="https://cdn.plyr.io/3.7.8/plyr.css" />
@@ -14,7 +14,10 @@
.container {
margin: 50px auto;
max-width: 1500px;
}
}
video {
max-height: 800px;
}
</style>
</head>
<body>
@@ -24,7 +27,8 @@
<script>
document.addEventListener('DOMContentLoaded', () => {
const source = 'https://bitmovin-a.akamaihd.net/content/sintel/sintel.mpd';
const source = 'http://localhost:3000/test-videotoolbox/manifest.mpd';
// const source = 'http://localhost:3000/test-nvenc/manifest.mpd';
const dash = dashjs.MediaPlayer().create();
const video = document.querySelector('video');
dash.initialize(video, source, true);
@@ -35,4 +39,4 @@
});
</script>
</body>
</html>
</html>