commit b93554f0cd9bb0f19d2ac071ffaf72be1fc6864b Author: S.Gromov Date: Tue Jan 20 00:25:55 2026 +0300 feat: Обновленая реализация CLI diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3653267 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Игнорировать node_modules +node_modules/ + +# Игнорировать dist +dist/ + +# Игнорировать временные файлы +*.log +*.tmp +.DS_Store + +# Игнорировать тестовые файлы +*.mp4 +*.mkv +*.avi +*.mov +test-output/ + +# Игнорировать IDE файлы +.vscode/ +.idea/ +*.swp +*.swo + +/data/ \ No newline at end of file diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..7ec44c7 --- /dev/null +++ b/.npmignore @@ -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/ + diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..cfd5b38 --- /dev/null +++ b/LICENSE @@ -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. + diff --git a/README.md b/README.md new file mode 100644 index 0000000..67fd761 --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# DASH Video Converter 🎬 + +CLI инструмент для конвертации видео в форматы DASH и HLS с поддержкой аппаратного ускорения (NVENC / Intel QSV / AMD AMF / VAAPI), адаптивным стримингом и автоматической генерацией превью. + +**Возможности:** +- ⚡ Аппаратное ускорение: NVENC / Intel QSV / AMD AMF / VAAPI (автовыбор по приоритету) +- 🎯 Форматы: DASH и HLS (оба из одних сегментов) +- 📊 Профили качества: множественные битрейты и FPS, авто или кастом +- 🖼️ Превью: thumbnail спрайты + VTT, постер с первого кадра +- ⏱️ Прогресс: CLI прогресс-бары по профилям и общему этапу + +## Быстрый старт + +```bash +# Использование через npx (без установки) +npx @gromlab/create-vod video.mp4 + +# Или глобальная установка +npm install -g @gromlab/create-vod +create-vod video.mp4 +``` + +**Системные требования:** +```bash +# Arch Linux +sudo pacman -S ffmpeg gpac + +# Ubuntu/Debian +sudo apt install ffmpeg gpac + +# macOS +brew install ffmpeg gpac +``` + +**Результат:** В текущей директории будет создана папка `video/` с сегментами в папках `{profile}-{codec}/`, манифестами DASH и HLS в корне, постером и превью спрайтами. + +## Параметры CLI + +```bash +create-vod [output-dir] [-r resolutions] [-c codec] [-f format] [-p poster-timecode] +``` + +### Основные параметры + +| Параметр | Описание | По умолчанию | Обязательный | +|----------|----------|--------------|--------------| +| `input-video` | Путь к входному видео файлу | - | ✅ | +| `output-dir` | Директория для выходных файлов | `.` (текущая папка) | ❌ | + +### Опциональные ключи + +| Ключ | Описание | Формат | Пример | +|------|----------|--------|--------| +| `-r, --resolutions` | Выбор профилей качества | `360`, `720@60`, `1080-60` | `-r 720,1080,1440@60` | +| `-c, --codec` | Видео кодек | `h264`, `av1`, `dual` | `-c dual` (по умолчанию) | +| `-f, --format` | Формат стриминга | `dash`, `hls`, `both` | `-f both` (по умолчанию) | +| `-p, --poster` | Таймкод для постера | `HH:MM:SS` или секунды | `-p 00:00:05` или `-p 10` | +| `--accel` | Аппаратный ускоритель | `auto`, `nvenc`, `qsv`, `amf`, `cpu` | `--accel nvenc` | + +### Примеры использования + +```bash +# Базовая конвертация (DASH + HLS, dual codec, автопрофили) +create-vod video.mp4 + +# Указать выходную директорию +create-vod video.mp4 ./output + +# Только выбранные разрешения +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 + +# Постер с 5-й секунды +create-vod video.mp4 -p 5 + +# Комбинация параметров +create-vod video.mp4 ./output -r 720,1080@60,1440@60 -c dual -f both -p 00:00:10 +``` + +### Поддерживаемые разрешения + +| Разрешение | Стандартное название | FPS варианты | +|------------|---------------------|--------------| +| `360` | 360p (640×360) | 30, 60, 90, 120 | +| `480` | 480p (854×480) | 30, 60, 90, 120 | +| `720` | 720p HD (1280×720) | 30, 60, 90, 120 | +| `1080` | 1080p Full HD (1920×1080) | 30, 60, 90, 120 | +| `1440` | 1440p 2K (2560×1440) | 30, 60, 90, 120 | +| `2160` | 2160p 4K (3840×2160) | 30, 60, 90, 120 | + +**Примечание:** Высокие FPS (60/90/120) создаются автоматически только если исходное видео поддерживает соответствующий FPS. + +## Автоматические настройки + +- **Длительность сегментов:** 2 секунды +- **NVENC:** автоопределение (GPU если доступен, иначе CPU) +- **Профили качества:** автоматический выбор на основе разрешения исходного видео +- **Битрейт:** динамический расчет по формуле BPP (Bits Per Pixel) +- **Превью спрайты:** генерируются автоматически (160×90px, интервал 1 сек) +- **Постер:** извлекается с начала видео (кадр 0:00:00, можно изменить через `-p`) +- **Параллельное кодирование:** включено + +**Требования:** Node.js ≥18.0.0, FFmpeg, MP4Box (gpac), опционально NVIDIA GPU для ускорения diff --git a/bin/cli.js b/bin/cli.js new file mode 100755 index 0000000..7a79587 --- /dev/null +++ b/bin/cli.js @@ -0,0 +1,85 @@ +#!/usr/bin/env node +import{createRequire as NF}from"node:module";var WF=Object.create;var{getPrototypeOf:_F,defineProperty:iD,getOwnPropertyNames:QF}=Object;var HF=Object.prototype.hasOwnProperty;var FD=(u,D,C)=>{C=u!=null?WF(_F(u)):{};let F=D||!u||!u.__esModule?iD(C,"default",{value:u,enumerable:!0}):C;for(let E of QF(u))if(!HF.call(F,E))iD(F,E,{get:()=>u[E],enumerable:!0});return F};var x=(u,D)=>()=>(D||u((D={exports:{}}).exports,D),D.exports);var d=NF(import.meta.url);var _u=x((W3,Wu)=>{class ku{constructor(u,D,C){this.etaBufferLength=u||100,this.valueBuffer=[C],this.timeBuffer=[D],this.eta="0"}update(u,D,C){this.valueBuffer.push(D),this.timeBuffer.push(u),this.calculate(C-D)}getTime(){return this.eta}calculate(u){let D=this.valueBuffer.length,C=Math.min(this.etaBufferLength,D),F=this.valueBuffer[D-1]-this.valueBuffer[D-C],E=this.timeBuffer[D-1]-this.timeBuffer[D-C],B=F/E;this.valueBuffer=this.valueBuffer.slice(-this.etaBufferLength),this.timeBuffer=this.timeBuffer.slice(-this.etaBufferLength);let A=Math.ceil(u/B/1000);if(isNaN(A))this.eta="NULL";else if(!isFinite(A))this.eta="INF";else if(A>1e7)this.eta="INF";else if(A<0)this.eta=0;else this.eta=A}}Wu.exports=ku});var VD=x((_3,Hu)=>{var c=d("readline");class Qu{constructor(u){this.stream=u,this.linewrap=!0,this.dy=0}cursorSave(){if(!this.stream.isTTY)return;this.stream.write("\x1B7")}cursorRestore(){if(!this.stream.isTTY)return;this.stream.write("\x1B8")}cursor(u){if(!this.stream.isTTY)return;if(u)this.stream.write("\x1B[?25h");else this.stream.write("\x1B[?25l")}cursorTo(u=null,D=null){if(!this.stream.isTTY)return;c.cursorTo(this.stream,u,D)}cursorRelative(u=null,D=null){if(!this.stream.isTTY)return;this.dy=this.dy+D,c.moveCursor(this.stream,u,D)}cursorRelativeReset(){if(!this.stream.isTTY)return;c.moveCursor(this.stream,0,-this.dy),c.cursorTo(this.stream,0,null),this.dy=0}clearRight(){if(!this.stream.isTTY)return;c.clearLine(this.stream,1)}clearLine(){if(!this.stream.isTTY)return;c.clearLine(this.stream,0)}clearBottom(){if(!this.stream.isTTY)return;c.clearScreenDown(this.stream)}newline(){this.stream.write(` +`),this.dy++}write(u,D=!1){if(this.linewrap===!0&&D===!1)this.stream.write(u.substr(0,this.getWidth()));else this.stream.write(u)}lineWrapping(u){if(!this.stream.isTTY)return;if(this.linewrap=u,u)this.stream.write("\x1B[?7h");else this.stream.write("\x1B[?7l")}isTTY(){return this.stream.isTTY===!0}getWidth(){return this.stream.columns||(this.stream.isTTY?80:200)}}Hu.exports=Qu});var zu=x((Q3,Nu)=>{Nu.exports=({onlyFirst:u=!1}={})=>{let D=["[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)","(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))"].join("|");return new RegExp(D,u?void 0:"g")}});var Iu=x((H3,qu)=>{var cF=zu();qu.exports=(u)=>typeof u==="string"?u.replace(cF(),""):u});var Vu=x((N3,LD)=>{var xu=(u)=>{if(Number.isNaN(u))return!1;if(u>=4352&&(u<=4447||u===9001||u===9002||11904<=u&&u<=12871&&u!==12351||12880<=u&&u<=19903||19968<=u&&u<=42182||43360<=u&&u<=43388||44032<=u&&u<=55203||63744<=u&&u<=64255||65040<=u&&u<=65049||65072<=u&&u<=65131||65281<=u&&u<=65376||65504<=u&&u<=65510||110592<=u&&u<=110593||127488<=u&&u<=127569||131072<=u&&u<=262141))return!0;return!1};LD.exports=xu;LD.exports.default=xu});var Mu=x((z3,Lu)=>{Lu.exports=function(){return/\uD83C\uDFF4\uDB40\uDC67\uDB40\uDC62(?:\uDB40\uDC65\uDB40\uDC6E\uDB40\uDC67|\uDB40\uDC73\uDB40\uDC63\uDB40\uDC74|\uDB40\uDC77\uDB40\uDC6C\uDB40\uDC73)\uDB40\uDC7F|\uD83D\uDC68(?:\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68\uD83C\uDFFB|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFE])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D)?\uD83D\uDC68|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D[\uDC68\uDC69])\u200D(?:\uD83D[\uDC66\uDC67])|[\u2695\u2696\u2708]\uFE0F|\uD83D[\uDC66\uDC67]|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|(?:\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708])\uFE0F|\uD83C\uDFFB\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C[\uDFFB-\uDFFF])|(?:\uD83E\uDDD1\uD83C\uDFFB\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)\uD83C\uDFFB|\uD83E\uDDD1(?:\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1)|(?:\uD83E\uDDD1\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFF\u200D\uD83E\uDD1D\u200D(?:\uD83D[\uDC68\uDC69]))(?:\uD83C[\uDFFB-\uDFFE])|(?:\uD83E\uDDD1\uD83C\uDFFC\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB\uDFFC])|\uD83D\uDC69(?:\uD83C\uDFFE\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB-\uDFFD\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFC\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFD-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFB\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFC-\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFD\u200D(?:\uD83E\uDD1D\u200D\uD83D\uDC68(?:\uD83C[\uDFFB\uDFFC\uDFFE\uDFFF])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\u200D(?:\u2764\uFE0F\u200D(?:\uD83D\uDC8B\u200D(?:\uD83D[\uDC68\uDC69])|\uD83D[\uDC68\uDC69])|\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD])|\uD83C\uDFFF\u200D(?:\uD83C[\uDF3E\uDF73\uDF93\uDFA4\uDFA8\uDFEB\uDFED]|\uD83D[\uDCBB\uDCBC\uDD27\uDD2C\uDE80\uDE92]|\uD83E[\uDDAF-\uDDB3\uDDBC\uDDBD]))|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67]))|(?:\uD83E\uDDD1\uD83C\uDFFD\u200D\uD83E\uDD1D\u200D\uD83E\uDDD1|\uD83D\uDC69\uD83C\uDFFE\u200D\uD83E\uDD1D\u200D\uD83D\uDC69)(?:\uD83C[\uDFFB-\uDFFD])|\uD83D\uDC69\u200D\uD83D\uDC66\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC69\u200D(?:\uD83D[\uDC66\uDC67])|(?:\uD83D\uDC41\uFE0F\u200D\uD83D\uDDE8|\uD83D\uDC69(?:\uD83C\uDFFF\u200D[\u2695\u2696\u2708]|\uD83C\uDFFE\u200D[\u2695\u2696\u2708]|\uD83C\uDFFC\u200D[\u2695\u2696\u2708]|\uD83C\uDFFB\u200D[\u2695\u2696\u2708]|\uD83C\uDFFD\u200D[\u2695\u2696\u2708]|\u200D[\u2695\u2696\u2708])|(?:(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)\uFE0F|\uD83D\uDC6F|\uD83E[\uDD3C\uDDDE\uDDDF])\u200D[\u2640\u2642]|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:(?:\uD83C[\uDFFB-\uDFFF])\u200D[\u2640\u2642]|\u200D[\u2640\u2642])|\uD83C\uDFF4\u200D\u2620)\uFE0F|\uD83D\uDC69\u200D\uD83D\uDC67\u200D(?:\uD83D[\uDC66\uDC67])|\uD83C\uDFF3\uFE0F\u200D\uD83C\uDF08|\uD83D\uDC15\u200D\uD83E\uDDBA|\uD83D\uDC69\u200D\uD83D\uDC66|\uD83D\uDC69\u200D\uD83D\uDC67|\uD83C\uDDFD\uD83C\uDDF0|\uD83C\uDDF4\uD83C\uDDF2|\uD83C\uDDF6\uD83C\uDDE6|[#\*0-9]\uFE0F\u20E3|\uD83C\uDDE7(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEF\uDDF1-\uDDF4\uDDF6-\uDDF9\uDDFB\uDDFC\uDDFE\uDDFF])|\uD83C\uDDF9(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDED\uDDEF-\uDDF4\uDDF7\uDDF9\uDDFB\uDDFC\uDDFF])|\uD83C\uDDEA(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDED\uDDF7-\uDDFA])|\uD83E\uDDD1(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF7(?:\uD83C[\uDDEA\uDDF4\uDDF8\uDDFA\uDDFC])|\uD83D\uDC69(?:\uD83C[\uDFFB-\uDFFF])|\uD83C\uDDF2(?:\uD83C[\uDDE6\uDDE8-\uDDED\uDDF0-\uDDFF])|\uD83C\uDDE6(?:\uD83C[\uDDE8-\uDDEC\uDDEE\uDDF1\uDDF2\uDDF4\uDDF6-\uDDFA\uDDFC\uDDFD\uDDFF])|\uD83C\uDDF0(?:\uD83C[\uDDEA\uDDEC-\uDDEE\uDDF2\uDDF3\uDDF5\uDDF7\uDDFC\uDDFE\uDDFF])|\uD83C\uDDED(?:\uD83C[\uDDF0\uDDF2\uDDF3\uDDF7\uDDF9\uDDFA])|\uD83C\uDDE9(?:\uD83C[\uDDEA\uDDEC\uDDEF\uDDF0\uDDF2\uDDF4\uDDFF])|\uD83C\uDDFE(?:\uD83C[\uDDEA\uDDF9])|\uD83C\uDDEC(?:\uD83C[\uDDE6\uDDE7\uDDE9-\uDDEE\uDDF1-\uDDF3\uDDF5-\uDDFA\uDDFC\uDDFE])|\uD83C\uDDF8(?:\uD83C[\uDDE6-\uDDEA\uDDEC-\uDDF4\uDDF7-\uDDF9\uDDFB\uDDFD-\uDDFF])|\uD83C\uDDEB(?:\uD83C[\uDDEE-\uDDF0\uDDF2\uDDF4\uDDF7])|\uD83C\uDDF5(?:\uD83C[\uDDE6\uDDEA-\uDDED\uDDF0-\uDDF3\uDDF7-\uDDF9\uDDFC\uDDFE])|\uD83C\uDDFB(?:\uD83C[\uDDE6\uDDE8\uDDEA\uDDEC\uDDEE\uDDF3\uDDFA])|\uD83C\uDDF3(?:\uD83C[\uDDE6\uDDE8\uDDEA-\uDDEC\uDDEE\uDDF1\uDDF4\uDDF5\uDDF7\uDDFA\uDDFF])|\uD83C\uDDE8(?:\uD83C[\uDDE6\uDDE8\uDDE9\uDDEB-\uDDEE\uDDF0-\uDDF5\uDDF7\uDDFA-\uDDFF])|\uD83C\uDDF1(?:\uD83C[\uDDE6-\uDDE8\uDDEE\uDDF0\uDDF7-\uDDFB\uDDFE])|\uD83C\uDDFF(?:\uD83C[\uDDE6\uDDF2\uDDFC])|\uD83C\uDDFC(?:\uD83C[\uDDEB\uDDF8])|\uD83C\uDDFA(?:\uD83C[\uDDE6\uDDEC\uDDF2\uDDF3\uDDF8\uDDFE\uDDFF])|\uD83C\uDDEE(?:\uD83C[\uDDE8-\uDDEA\uDDF1-\uDDF4\uDDF6-\uDDF9])|\uD83C\uDDEF(?:\uD83C[\uDDEA\uDDF2\uDDF4\uDDF5])|(?:\uD83C[\uDFC3\uDFC4\uDFCA]|\uD83D[\uDC6E\uDC71\uDC73\uDC77\uDC81\uDC82\uDC86\uDC87\uDE45-\uDE47\uDE4B\uDE4D\uDE4E\uDEA3\uDEB4-\uDEB6]|\uD83E[\uDD26\uDD37-\uDD39\uDD3D\uDD3E\uDDB8\uDDB9\uDDCD-\uDDCF\uDDD6-\uDDDD])(?:\uD83C[\uDFFB-\uDFFF])|(?:\u26F9|\uD83C[\uDFCB\uDFCC]|\uD83D\uDD75)(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u261D\u270A-\u270D]|\uD83C[\uDF85\uDFC2\uDFC7]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66\uDC67\uDC6B-\uDC6D\uDC70\uDC72\uDC74-\uDC76\uDC78\uDC7C\uDC83\uDC85\uDCAA\uDD74\uDD7A\uDD90\uDD95\uDD96\uDE4C\uDE4F\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1C\uDD1E\uDD1F\uDD30-\uDD36\uDDB5\uDDB6\uDDBB\uDDD2-\uDDD5])(?:\uD83C[\uDFFB-\uDFFF])|(?:[\u231A\u231B\u23E9-\u23EC\u23F0\u23F3\u25FD\u25FE\u2614\u2615\u2648-\u2653\u267F\u2693\u26A1\u26AA\u26AB\u26BD\u26BE\u26C4\u26C5\u26CE\u26D4\u26EA\u26F2\u26F3\u26F5\u26FA\u26FD\u2705\u270A\u270B\u2728\u274C\u274E\u2753-\u2755\u2757\u2795-\u2797\u27B0\u27BF\u2B1B\u2B1C\u2B50\u2B55]|\uD83C[\uDC04\uDCCF\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE1A\uDE2F\uDE32-\uDE36\uDE38-\uDE3A\uDE50\uDE51\uDF00-\uDF20\uDF2D-\uDF35\uDF37-\uDF7C\uDF7E-\uDF93\uDFA0-\uDFCA\uDFCF-\uDFD3\uDFE0-\uDFF0\uDFF4\uDFF8-\uDFFF]|\uD83D[\uDC00-\uDC3E\uDC40\uDC42-\uDCFC\uDCFF-\uDD3D\uDD4B-\uDD4E\uDD50-\uDD67\uDD7A\uDD95\uDD96\uDDA4\uDDFB-\uDE4F\uDE80-\uDEC5\uDECC\uDED0-\uDED2\uDED5\uDEEB\uDEEC\uDEF4-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])|(?:[#\*0-9\xA9\xAE\u203C\u2049\u2122\u2139\u2194-\u2199\u21A9\u21AA\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA\u24C2\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE\u2600-\u2604\u260E\u2611\u2614\u2615\u2618\u261D\u2620\u2622\u2623\u2626\u262A\u262E\u262F\u2638-\u263A\u2640\u2642\u2648-\u2653\u265F\u2660\u2663\u2665\u2666\u2668\u267B\u267E\u267F\u2692-\u2697\u2699\u269B\u269C\u26A0\u26A1\u26AA\u26AB\u26B0\u26B1\u26BD\u26BE\u26C4\u26C5\u26C8\u26CE\u26CF\u26D1\u26D3\u26D4\u26E9\u26EA\u26F0-\u26F5\u26F7-\u26FA\u26FD\u2702\u2705\u2708-\u270D\u270F\u2712\u2714\u2716\u271D\u2721\u2728\u2733\u2734\u2744\u2747\u274C\u274E\u2753-\u2755\u2757\u2763\u2764\u2795-\u2797\u27A1\u27B0\u27BF\u2934\u2935\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55\u3030\u303D\u3297\u3299]|\uD83C[\uDC04\uDCCF\uDD70\uDD71\uDD7E\uDD7F\uDD8E\uDD91-\uDD9A\uDDE6-\uDDFF\uDE01\uDE02\uDE1A\uDE2F\uDE32-\uDE3A\uDE50\uDE51\uDF00-\uDF21\uDF24-\uDF93\uDF96\uDF97\uDF99-\uDF9B\uDF9E-\uDFF0\uDFF3-\uDFF5\uDFF7-\uDFFF]|\uD83D[\uDC00-\uDCFD\uDCFF-\uDD3D\uDD49-\uDD4E\uDD50-\uDD67\uDD6F\uDD70\uDD73-\uDD7A\uDD87\uDD8A-\uDD8D\uDD90\uDD95\uDD96\uDDA4\uDDA5\uDDA8\uDDB1\uDDB2\uDDBC\uDDC2-\uDDC4\uDDD1-\uDDD3\uDDDC-\uDDDE\uDDE1\uDDE3\uDDE8\uDDEF\uDDF3\uDDFA-\uDE4F\uDE80-\uDEC5\uDECB-\uDED2\uDED5\uDEE0-\uDEE5\uDEE9\uDEEB\uDEEC\uDEF0\uDEF3-\uDEFA\uDFE0-\uDFEB]|\uD83E[\uDD0D-\uDD3A\uDD3C-\uDD45\uDD47-\uDD71\uDD73-\uDD76\uDD7A-\uDDA2\uDDA5-\uDDAA\uDDAE-\uDDCA\uDDCD-\uDDFF\uDE70-\uDE73\uDE78-\uDE7A\uDE80-\uDE82\uDE90-\uDE95])\uFE0F|(?:[\u261D\u26F9\u270A-\u270D]|\uD83C[\uDF85\uDFC2-\uDFC4\uDFC7\uDFCA-\uDFCC]|\uD83D[\uDC42\uDC43\uDC46-\uDC50\uDC66-\uDC78\uDC7C\uDC81-\uDC83\uDC85-\uDC87\uDC8F\uDC91\uDCAA\uDD74\uDD75\uDD7A\uDD90\uDD95\uDD96\uDE45-\uDE47\uDE4B-\uDE4F\uDEA3\uDEB4-\uDEB6\uDEC0\uDECC]|\uD83E[\uDD0F\uDD18-\uDD1F\uDD26\uDD30-\uDD39\uDD3C-\uDD3E\uDDB5\uDDB6\uDDB8\uDDB9\uDDBB\uDDCD-\uDDCF\uDDD1-\uDDDD])/g}});var Ru=x((q3,MD)=>{var lF=Iu(),dF=Vu(),pF=Mu(),Ou=(u)=>{if(typeof u!=="string"||u.length===0)return 0;if(u=lF(u),u.length===0)return 0;u=u.replace(pF()," ");let D=0;for(let C=0;C=127&&F<=159)continue;if(F>=768&&F<=879)continue;if(F>65535)C++;D+=dF(F)?2:1}return D};MD.exports=Ou;MD.exports.default=Ou});var OD=x((I3,ju)=>{ju.exports=function(D,C,F){if(C.autopadding!==!0)return D;function E(B,A){return(C.autopaddingChar+B).slice(-A)}switch(F){case"percentage":return E(D,3);default:return D}}});var RD=x((x3,wu)=>{wu.exports=function(D,C){let F=Math.round(D*C.barsize),E=C.barsize-F;return C.barCompleteString.substr(0,F)+C.barGlue+C.barIncompleteString.substr(0,E)}});var jD=x((V3,Su)=>{Su.exports=function(D,C,F){function E(A){if(F)return F*Math.round(A/F);else return A}function B(A){return(C.autopaddingChar+A).slice(-2)}if(D>3600)return B(Math.floor(D/3600))+"h"+B(E(D%3600/60))+"m";else if(D>60)return B(Math.floor(D/60))+"m"+B(E(D%60))+"s";else if(D>10)return B(E(D))+"s";else return B(D)+"s"}});var wD=x((L3,Tu)=>{var nF=Ru(),sF=OD(),aF=RD(),rF=jD();Tu.exports=function(D,C,F){let E=D.format,B=D.formatTime||rF,A=D.formatValue||sF,$=D.formatBar||aF,J=Math.floor(C.progress*100)+"",G=C.stopTime||Date.now(),X=Math.round((G-C.startTime)/1000),Y=Object.assign({},F,{bar:$(C.progress,D),percentage:A(J,D,"percentage"),total:A(C.total,D,"total"),value:A(C.value,D,"value"),eta:A(C.eta,D,"eta"),eta_formatted:B(C.eta,D,5),duration:A(X,D,"duration"),duration_formatted:B(X,D,1)});E=E.replace(/\{(\w+)\}/g,function(_,U){if(typeof Y[U]<"u")return Y[U];return _});let Z=Math.max(0,C.maxWidth-nF(E)-2),K=Math.floor(Z/2);switch(D.align){case"right":E=Z>0?" ".repeat(Z)+E:E;break;case"center":E=K>0?" ".repeat(K)+E:E;break;case"left":default:break}return E}});var $D=x((M3,bu)=>{function z(u,D){if(typeof u>"u"||u===null)return D;else return u}bu.exports={parse:function(D,C){let F={},E=Object.assign({},C,D);return F.throttleTime=1000/z(E.fps,10),F.stream=z(E.stream,process.stderr),F.terminal=z(E.terminal,null),F.clearOnComplete=z(E.clearOnComplete,!1),F.stopOnComplete=z(E.stopOnComplete,!1),F.barsize=z(E.barsize,40),F.align=z(E.align,"left"),F.hideCursor=z(E.hideCursor,!1),F.linewrap=z(E.linewrap,!1),F.barGlue=z(E.barGlue,""),F.barCompleteChar=z(E.barCompleteChar,"="),F.barIncompleteChar=z(E.barIncompleteChar,"-"),F.format=z(E.format,"progress [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}"),F.formatTime=z(E.formatTime,null),F.formatValue=z(E.formatValue,null),F.formatBar=z(E.formatBar,null),F.etaBufferLength=z(E.etaBuffer,10),F.etaAsynchronousUpdate=z(E.etaAsynchronousUpdate,!1),F.progressCalculationRelative=z(E.progressCalculationRelative,!1),F.synchronousUpdate=z(E.synchronousUpdate,!0),F.noTTYOutput=z(E.noTTYOutput,!1),F.notTTYSchedule=z(E.notTTYSchedule,2000),F.emptyOnZero=z(E.emptyOnZero,!1),F.forceRedraw=z(E.forceRedraw,!1),F.autopadding=z(E.autopadding,!1),F.gracefulExit=z(E.gracefulExit,!1),F},assignDerivedOptions:function(D){return D.barCompleteString=D.barCompleteChar.repeat(D.barsize+1),D.barIncompleteString=D.barIncompleteChar.repeat(D.barsize+1),D.autopaddingChar=D.autopadding?z(D.autopaddingChar," "):"",D}}});var SD=x((O3,vu)=>{var yu=_u(),iF=VD(),oF=wD(),tF=$D(),eF=d("events");vu.exports=class extends eF{constructor(D){super();this.options=tF.assignDerivedOptions(D),this.terminal=this.options.terminal?this.options.terminal:new iF(this.options.stream),this.value=0,this.startValue=0,this.total=100,this.lastDrawnString=null,this.startTime=null,this.stopTime=null,this.lastRedraw=Date.now(),this.eta=new yu(this.options.etaBufferLength,0,0),this.payload={},this.isActive=!1,this.formatter=typeof this.options.format==="function"?this.options.format:oF}render(D=!1){let C={progress:this.getProgress(),eta:this.eta.getTime(),startTime:this.startTime,stopTime:this.stopTime,total:this.total,value:this.value,maxWidth:this.terminal.getWidth()};if(this.options.etaAsynchronousUpdate)this.updateETA();let F=this.formatter(this.options,C,this.payload);if(D||this.options.forceRedraw||this.options.noTTYOutput&&!this.terminal.isTTY()||this.lastDrawnString!=F)this.emit("redraw-pre"),this.terminal.cursorTo(0,null),this.terminal.write(F),this.terminal.clearRight(),this.lastDrawnString=F,this.lastRedraw=Date.now(),this.emit("redraw-post")}start(D,C,F){this.value=C||0,this.total=typeof D<"u"&&D>=0?D:100,this.startValue=C||0,this.payload=F||{},this.startTime=Date.now(),this.stopTime=null,this.lastDrawnString="",this.eta=new yu(this.options.etaBufferLength,this.startTime,this.value),this.isActive=!0,this.emit("start",D,C)}stop(){this.isActive=!1,this.stopTime=Date.now(),this.emit("stop",this.total,this.value)}update(D,C={}){if(typeof D==="number")this.value=D,this.eta.update(Date.now(),D,this.total);let F=(typeof D==="object"?D:C)||{};this.emit("update",this.total,this.value);for(let E in F)this.payload[E]=F[E];if(this.value>=this.getTotal()&&this.options.stopOnComplete)this.stop()}getProgress(){let D=this.value/this.total;if(this.options.progressCalculationRelative)D=(this.value-this.startValue)/(this.total-this.startValue);if(isNaN(D))D=this.options&&this.options.emptyOnZero?0:1;return D=Math.min(Math.max(D,0),1),D}increment(D=1,C={}){if(typeof D==="object")this.update(this.value+1,D);else this.update(this.value+D,C)}getTotal(){return this.total}setTotal(D){if(typeof D<"u"&&D>=0)this.total=D}updateETA(){this.eta.update(Date.now(),this.value,this.total)}}});var hu=x((R3,Pu)=>{var D8=SD(),u8=$D();Pu.exports=class extends D8{constructor(D,C){super(u8.parse(D,C));if(this.timer=null,this.options.noTTYOutput&&this.terminal.isTTY()===!1)this.options.synchronousUpdate=!1;this.schedulingRate=this.terminal.isTTY()?this.options.throttleTime:this.options.notTTYSchedule,this.sigintCallback=null}render(){if(this.timer)clearTimeout(this.timer),this.timer=null;if(super.render(),this.options.noTTYOutput&&this.terminal.isTTY()===!1)this.terminal.newline();this.timer=setTimeout(this.render.bind(this),this.schedulingRate)}update(D,C){if(!this.timer)return;if(super.update(D,C),this.options.synchronousUpdate&&this.lastRedraw+this.options.throttleTime*2{var F8=VD(),C8=SD(),E8=$D(),B8=d("events");fu.exports=class extends B8{constructor(D,C){super();this.bars=[],this.options=E8.parse(D,C),this.options.synchronousUpdate=!1,this.terminal=this.options.terminal?this.options.terminal:new F8(this.options.stream),this.timer=null,this.isActive=!1,this.schedulingRate=this.terminal.isTTY()?this.options.throttleTime:this.options.notTTYSchedule,this.loggingBuffer=[],this.sigintCallback=null}create(D,C,F,E={}){let B=new C8(Object.assign({},this.options,{terminal:this.terminal},E));if(this.bars.push(B),this.options.noTTYOutput===!1&&this.terminal.isTTY()===!1)return B;if(this.sigintCallback===null&&this.options.gracefulExit)this.sigintCallback=this.stop.bind(this),process.once("SIGINT",this.sigintCallback),process.once("SIGTERM",this.sigintCallback);if(!this.isActive){if(this.options.hideCursor===!0)this.terminal.cursor(!1);if(this.options.linewrap===!1)this.terminal.lineWrapping(!1);this.timer=setTimeout(this.update.bind(this),this.schedulingRate)}return this.isActive=!0,B.start(D,C,F),this.emit("start"),B}remove(D){let C=this.bars.indexOf(D);if(C<0)return!1;return this.bars.splice(C,1),this.update(),this.terminal.newline(),this.terminal.clearBottom(),!0}update(){if(this.timer)clearTimeout(this.timer),this.timer=null;if(this.emit("update-pre"),this.terminal.cursorRelativeReset(),this.emit("redraw-pre"),this.loggingBuffer.length>0){this.terminal.clearLine();while(this.loggingBuffer.length>0)this.terminal.write(this.loggingBuffer.shift(),!0)}for(let D=0;D0)this.terminal.newline();this.bars[D].render()}if(this.emit("redraw-post"),this.options.noTTYOutput&&this.terminal.isTTY()===!1)this.terminal.newline(),this.terminal.newline();if(this.timer=setTimeout(this.update.bind(this),this.schedulingRate),this.emit("update-post"),this.options.stopOnComplete&&!this.bars.find((D)=>D.isActive))this.stop()}stop(){if(clearTimeout(this.timer),this.timer=null,this.sigintCallback)process.removeListener("SIGINT",this.sigintCallback),process.removeListener("SIGTERM",this.sigintCallback),this.sigintCallback=null;if(this.isActive=!1,this.options.hideCursor===!0)this.terminal.cursor(!0);if(this.options.linewrap===!1)this.terminal.lineWrapping(!0);if(this.terminal.cursorRelativeReset(),this.emit("stop-pre-clear"),this.options.clearOnComplete)this.terminal.clearBottom();else{for(let D=0;D0)this.terminal.newline();this.bars[D].render(),this.bars[D].stop()}this.terminal.newline()}this.emit("stop")}log(D){this.loggingBuffer.push(D)}}});var cu=x((w3,mu)=>{mu.exports={format:"progress [{bar}] {percentage}% | ETA: {eta}s | {value}/{total}",barCompleteChar:"=",barIncompleteChar:"-"}});var du=x((S3,lu)=>{lu.exports={format:" {bar} {percentage}% | ETA: {eta}s | {value}/{total}",barCompleteChar:"█",barIncompleteChar:"░"}});var nu=x((T3,pu)=>{pu.exports={format:" \x1B[90m{bar}\x1B[0m {percentage}% | ETA: {eta}s | {value}/{total}",barCompleteChar:"█",barIncompleteChar:"░"}});var au=x((b3,su)=>{su.exports={format:" {bar}■ {percentage}% | ETA: {eta}s | {value}/{total}",barCompleteChar:"■",barIncompleteChar:" "}});var iu=x((y3,ru)=>{var A8=cu(),Z8=du(),$8=nu(),J8=au();ru.exports={legacy:A8,shades_classic:Z8,shades_grey:$8,rect:J8}});var eu=x((v3,tu)=>{var ou=hu(),K8=gu(),X8=iu(),Y8=wD(),G8=OD(),U8=RD(),k8=jD();tu.exports={Bar:ou,SingleBar:ou,MultiBar:K8,Presets:X8,Format:{Formatter:Y8,BarFormat:U8,ValueFormat:G8,TimeFormat:k8}}});import{join as ZD,basename as Yu,extname as Gu}from"node:path";import{randomUUID as fF}from"node:crypto";import{rm as Uu}from"node:fs/promises";import{spawn as e}from"node:child_process";import{appendFile as zF}from"node:fs/promises";var WD=null;function _D(u){WD=u}async function P(u){if(WD)try{await zF(WD,u,"utf-8")}catch(D){}}async function p(){return new Promise((u)=>{let D=e("ffmpeg",["-version"]);D.on("error",()=>u(!1)),D.on("close",(C)=>u(C===0))})}async function n(){return new Promise((u)=>{let D=e("MP4Box",["-version"]);D.on("error",()=>u(!1)),D.on("close",(C)=>u(C===0))})}async function s(){let u=await new Promise((E)=>{let B=e("ffmpeg",["-hide_banner","-encoders"]),A="";B.stdout.on("data",($)=>{A+=$.toString()}),B.on("error",()=>E("")),B.on("close",()=>E(A))}),D=(E)=>u.includes(E),C=[],F=[{acc:"nvenc",h264:D("h264_nvenc")?"h264_nvenc":void 0,av1:D("av1_nvenc")?"av1_nvenc":void 0},{acc:"qsv",h264:D("h264_qsv")?"h264_qsv":void 0,av1:D("av1_qsv")?"av1_qsv":void 0},{acc:"amf",h264:D("h264_amf")?"h264_amf":void 0,av1:D("av1_amf")?"av1_amf":void 0},{acc:"vaapi",h264:D("h264_vaapi")?"h264_vaapi":void 0,av1:D("av1_vaapi")?"av1_vaapi":void 0},{acc:"videotoolbox",h264:D("h264_videotoolbox")?"h264_videotoolbox":void 0,av1:D("av1_videotoolbox")?"av1_videotoolbox":void 0},{acc:"v4l2",h264:D("h264_v4l2m2m")?"h264_v4l2m2m":void 0,av1:D("av1_v4l2m2m")?"av1_v4l2m2m":void 0}];for(let E of F)if(E.h264||E.av1)C.push({accelerator:E.acc,h264Encoder:E.h264,av1Encoder:E.av1});return C}async function g(u,D,C){let E=` +=== FFmpeg Command [${new Date().toISOString()}] === +ffmpeg ${u.join(" ")} +`;return await P(E),new Promise((B,A)=>{let $=e("ffmpeg",u),J="";$.stderr.on("data",(G)=>{let X=G.toString();if(J+=X,D&&C){let Y=X.match(/time=(\d{2}):(\d{2}):(\d{2}\.\d{2})/);if(Y){let Z=parseInt(Y[1]),K=parseInt(Y[2]),_=parseFloat(Y[3]),U=Z*3600+K*60+_,q=Math.min(100,U/C*100);D(q)}}}),$.on("error",(G)=>{P(`ERROR: ${G.message} +`),A(Error(`FFmpeg error: ${G.message}`))}),$.on("close",(G)=>{if(G===0){let Y=J.split(` +`).filter((Z)=>Z.trim()).slice(-10).join(` +`);P(`SUCCESS: Exit code ${G} +--- Last 10 lines of output --- +${Y} +`),B()}else P(`FAILED: Exit code ${G} +--- Full error output --- +${J} +`),A(Error(`FFmpeg failed with exit code ${G} +${J}`))})})}async function QD(u){let C=` +=== MP4Box Command [${new Date().toISOString()}] === +MP4Box ${u.join(" ")} +`;return await P(C),new Promise((F,E)=>{let B=e("MP4Box",u),A="",$="";B.stdout.on("data",(J)=>{A+=J.toString()}),B.stderr.on("data",(J)=>{$+=J.toString()}),B.on("error",(J)=>{P(`ERROR: ${J.message} +`),E(Error(`MP4Box error: ${J.message}`))}),B.on("close",(J)=>{if(J===0){let Y=(A||$).split(` +`).filter((Z)=>Z.trim()).slice(-10).join(` +`);P(`SUCCESS: Exit code ${J} +--- Last 10 lines of output --- +${Y} +`),F()}else{let G=$||A;P(`FAILED: Exit code ${J} +--- Full error output --- +${G} +`),E(Error(`MP4Box failed with exit code ${J} +${G}`))}})})}import{spawn as qF}from"node:child_process";async function a(u){return new Promise((D,C)=>{let F=qF("ffprobe",["-v","error","-show_entries","stream=width,height,duration,r_frame_rate,codec_name,codec_type,bit_rate","-show_entries","format=duration","-of","json",u]),E="";F.stdout.on("data",(B)=>{E+=B.toString()}),F.on("error",(B)=>{C(Error(`ffprobe error: ${B.message}`))}),F.on("close",(B)=>{if(B!==0){C(Error(`ffprobe failed with exit code ${B}`));return}try{let A=JSON.parse(E),$=A.streams.find((U)=>U.codec_type==="video"),J=A.streams.find((U)=>U.codec_type==="audio"),G=A.format;if(!$){C(Error("No video stream found in input file"));return}let X=30;if($.r_frame_rate){let[U,q]=$.r_frame_rate.split("/").map(Number);if(U&&q&&q!==0)X=U/q}let Y=parseFloat($.duration||G.duration||"0"),Z=A.streams.find((U)=>U.codec_type==="audio"&&U.bit_rate),K=Z?.bit_rate?Math.round(parseInt(Z.bit_rate)/1000):void 0,_=$.bit_rate?Math.round(parseInt($.bit_rate)/1000):void 0;D({width:$.width,height:$.height,duration:Y,fps:X,codec:$.codec_name,hasAudio:Boolean(J),audioBitrate:K,videoBitrate:_})}catch(A){C(Error(`Failed to parse ffprobe output: ${A}`))}})})}function CD(u,D=256){if(!u)return`${D}k`;let C=Math.min(u,D);if(C<=64)return"64k";if(C<=96)return"96k";if(C<=128)return"128k";if(C<=192)return"192k";return"256k"}function ED(u){let D=Math.floor(u/3600),C=Math.floor(u%3600/60),F=u%60;return`${String(D).padStart(2,"0")}:${String(C).padStart(2,"0")}:${F.toFixed(3).padStart(6,"0")}`}import{mkdir as IF,access as xF,constants as VF}from"node:fs/promises";async function m(u){try{await xF(u,VF.F_OK)}catch{await IF(u,{recursive:!0})}}function OF(u,D){let C=u*D;if(C<=230400)return 0.08;if(C<=409920)return 0.075;if(C<=921600)return 0.07;if(C<=2073600)return 0.065;if(C<=3686400)return 0.06;return 0.055}function y(u,D,C=30,F){let E=OF(u,D),B=Math.round(u*D*C*E/1000);if(F&&B>F)B=F;return`${B}k`}var HD=[{name:"360p",width:640,height:360,videoBitrate:y(640,360,30),audioBitrate:"192k"},{name:"480p",width:854,height:480,videoBitrate:y(854,480,30),audioBitrate:"192k"},{name:"720p",width:1280,height:720,videoBitrate:y(1280,720,30),audioBitrate:"192k"},{name:"1080p",width:1920,height:1080,videoBitrate:y(1920,1080,30),audioBitrate:"256k"},{name:"1440p",width:2560,height:1440,videoBitrate:y(2560,1440,30),audioBitrate:"256k"},{name:"2160p",width:3840,height:2160,videoBitrate:y(3840,2160,30),audioBitrate:"256k"}];function DD(u,D,C=30,F){let E=[],B=HD.filter((A)=>{return A.width<=u&&A.height<=D});for(let A of B)E.push({...A,videoBitrate:y(A.width,A.height,30,F),fps:30});return E}function RF(u,D,C){return{...u,name:`${u.name}-${D}`,videoBitrate:y(u.width,u.height,D,C),fps:D}}function oD(u){let C=u.trim().match(/^(\d+)p?(?:[@-](\d+))?$/i);if(!C)return null;let F=C[1]+"p",E=C[2]?parseInt(C[2]):30;return{resolution:F,fps:E}}function tD(u,D=30,C){let F=HD.find((E)=>E.name===u);if(!F)return null;if(D===30)return{...F,videoBitrate:y(F.width,F.height,30,C),fps:30};return RF(F,D,C)}function jF(u,D,C,F){let E=oD(u);if(!E)return{error:`Invalid profile format: ${u}. Use format like: 360, 720@60, 1080-60`};let B=tD(E.resolution,E.fps);if(!B)return{error:`Unknown resolution: ${E.resolution}. Available: 360, 480, 720, 1080, 1440, 2160`};if(B.width>D||B.height>C)return{error:`Source resolution (${D}x${C}) is lower than ${u} (${B.width}x${B.height})`};let A=120,$=E.fps,J;if(E.fps>F)$=Math.min(F,A),J=`Requested ${E.fps} FPS in ${u}, but source is ${F} FPS. Using ${$} FPS instead`;else if(E.fps>A)$=A,J=`Requested ${E.fps} FPS in ${u} exceeds maximum ${A} FPS. Using ${$} FPS instead`;return J?{warning:J,adjustedFps:$}:{}}function BD(u,D,C,F,E){let B=[],A=[],$=[];for(let J of u){let G=jF(J,D,C,F);if(G.error){A.push(G.error);continue}if(G.warning)$.push(G.warning);let X=oD(J);if(!X)continue;let Y=G.adjustedFps!==void 0?G.adjustedFps:X.fps,Z=tD(X.resolution,Y,E);if(Z)B.push(Z)}return{profiles:B,errors:A,warnings:$}}import{join as h}from"node:path";import{readdir as wF,unlink as eD,rmdir as SF,writeFile as Du}from"node:fs/promises";async function uu(u,D,C="00:00:00"){let F=h(D,"poster.jpg"),E=/^\d+(\.\d+)?$/.test(C)?C:C;return await g(["-ss",E,"-i",u,"-vframes","1","-q:v","2","-y",F]),F}async function Fu(u,D,C,F){let{width:E,height:B,interval:A,columns:$}=F,J=h(D,".thumbnails_temp");await m(J),await Du(h(J,".keep"),"");let G=h(J,"thumb_%04d.jpg");await g(["-i",u,"-vf",`fps=1/${A},scale=${E}:${B}`,"-q:v","5",G]);let Y=(await wF(J)).filter((W)=>W.startsWith("thumb_")&&W.endsWith(".jpg")).sort();if(Y.length===0)throw Error("No thumbnails generated");let Z=Y.length,K=Math.ceil(Z/$),_=h(D,"thumbnails.jpg"),U=`tile=${$}x${K}`;await g(["-i",G,"-filter_complex",U,"-q:v","5",_]);let q=h(D,"thumbnails.vtt"),k=TF(Z,A,E,B,$,"thumbnails.jpg");await Du(q,k);for(let W of Y)await eD(h(J,W));return await eD(h(J,".keep")),await SF(J),{spritePath:_,vttPath:q}}function TF(u,D,C,F,E,B){let A=`WEBVTT + +`;for(let $=0;$ ${ED(G)} +`,A+=`${B}#xywh=${Z},${K},${C},${F} + +`}return A}import{join as bF}from"node:path";function yF(u,D,C){if(C)if(D==="h264")return 32;else return 42;else if(D==="h264"){if(u<=360)return 25;if(u<=480)return 24;if(u<=720)return 23;if(u<=1080)return 22;if(u<=1440)return 21;return 20}else{if(u<=360)return 40;if(u<=480)return 38;if(u<=720)return 35;if(u<=1080)return 32;if(u<=1440)return 30;return 28}}async function Cu(u,D,C,F,E,B,A,$,J,G,X,Y){let Z=bF(D,`video_${J}_${C.name}.mp4`),K=["-y","-i",u,"-c:v",F],_=F.includes("nvenc")||F.includes("qsv")||F.includes("amf")||F.includes("vaapi")||F.includes("videotoolbox")||F.includes("v4l2"),U;if(_&&G?.cq!==void 0)U=G.cq;else if(!_&&G?.crf!==void 0)U=G.crf;else U=yF(C.height,J,_);if(F==="h264_nvenc")K.push("-rc:v","vbr"),K.push("-cq",String(U)),K.push("-preset",E),K.push("-2pass","0");else if(F==="av1_nvenc")K.push("-rc:v","vbr"),K.push("-cq",String(U)),K.push("-preset",E),K.push("-2pass","0");else if(F==="av1_qsv")K.push("-preset",E),K.push("-global_quality",String(U));else if(F==="h264_qsv")K.push("-preset",E),K.push("-global_quality",String(U));else if(F==="av1_amf")K.push("-quality","balanced"),K.push("-rc","cqp"),K.push("-qp_i",String(U)),K.push("-qp_p",String(U));else if(F==="h264_amf")K.push("-quality","balanced"),K.push("-rc","cqp"),K.push("-qp_i",String(U)),K.push("-qp_p",String(U));else if(F==="libsvtav1")K.push("-crf",String(U)),K.push("-preset",E),K.push("-svtav1-params","tune=0:enable-overlays=1");else if(F==="libx264")K.push("-crf",String(U)),K.push("-preset",E);else K.push("-preset",E);let q=J==="av1"?0.6:1,k=Math.round(parseInt(C.videoBitrate)*q*1.5);K.push("-maxrate",`${k}k`),K.push("-bufsize",`${k*2}k`);let W=C.fps||30,N=Math.round(W*A);K.push("-g",String(N),"-keyint_min",String(N),"-sc_threshold","0");let V=[`scale=${C.width}:${C.height}`];if(X){if(X.deinterlace)V.push("yadif");if(X.denoise)V.push("hqdn3d");if(X.customFilters)V.push(...X.customFilters)}K.push("-vf",V.join(","));let b=parseInt(C.audioBitrate)||256,R=CD($,b);if(K.push("-c:a","aac","-b:a",R),X?.audioNormalize)K.push("-af","loudnorm");return K.push("-f","mp4",Z),await g(K,Y,B),Z}async function Eu(u,D,C,F,E,B,A,$,J,G,X,Y,Z,K){let _=new Map;if(J&&C.length>1)for(let U=0;UCu(u,D,N,F,E,B,A,$,X,Y,Z,(V)=>{if(K)K(N.name,V)}));(await Promise.all(k)).forEach((N,V)=>{let b=q[V];_.set(b.name,N)})}else for(let U of C){let q=await Cu(u,D,U,F,E,B,A,$,X,Y,Z,(k)=>{if(K)K(U.name,k)});_.set(U.name,q)}return _}import{join as M}from"node:path";import{readdir as Ju,rename as e8,mkdir as D3,writeFile as ID}from"node:fs/promises";import{readFile as ND,writeFile as zD}from"node:fs/promises";async function Bu(u){let D=await ND(u,"utf-8");D=D.replace(/\/\/>/g,"/>"),D=D.replace(/\/\s+\/>/g,"/>"),D=D.replace(/(]+)\s+\/>/g,"$1/>"),D=D.replace(/]+)\/>\s*<\/Representation>/g,""),D=D.replace(/]+)\/>\s*(]*\/>)/g,` + $2 + `),D=D.replace(/]+)>\s*(?=<(?:Representation|\/AdaptationSet))/g,` +`),await zD(u,D,"utf-8")}async function Au(u,D,C){let F=await ND(u,"utf-8");F=F.replace(/media="\$RepresentationID\$_\$Number\$\.m4s"/g,'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"'),F=F.replace(/initialization="\$RepresentationID\$_\.mp4"/g,'initialization="$RepresentationID$/$RepresentationID$_.mp4"'),await zD(u,F,"utf-8")}async function Zu(u){let C=(await ND(u,"utf-8")).split(` +`),F=[],E=0;while(E")){let Z=C[E];if(Z.includes(""))Y=!1}else if(Z.includes("0&&X.length>0)F.push(B),J.forEach((Z)=>F.push(Z)),G.forEach((Z)=>F.push(Z)),F.push(" "),F.push(B),J.forEach((Z)=>F.push(Z)),X.forEach((Z)=>F.push(Z)),F.push(" ");else{F.push(B);for(let Z=A+1;ZN.endsWith(".m4s")).sort((N,V)=>{let b=parseInt(N.match(/_(\d+)\.m4s$/)?.[1]||"0"),R=parseInt(V.match(/_(\d+)\.m4s$/)?.[1]||"0");return b-R}),U=K.find((N)=>N.endsWith("_.mp4"));if(!U||_.length===0)continue;let q=qD(_,U,C),k=M(Z,"playlist.m3u8");await ID(k,q,"utf-8");let W=parseInt(X.videoBitrate)*1000;A.push({path:`${Y}/playlist.m3u8`,bandwidth:W,resolution:`${X.width}x${X.height}`,fps:X.fps||30})}let $,J=[];if(E){let X=M(u,"audio"),Y=[];try{Y=await Ju(X)}catch{Y=[]}if(J=Y.filter((Z)=>Z.endsWith(".m4s")).sort((Z,K)=>{let _=parseInt(Z.match(/_(\d+)\.m4s$/)?.[1]||"0"),U=parseInt(K.match(/_(\d+)\.m4s$/)?.[1]||"0");return _-U}),$=Y.find((Z)=>Z.endsWith("_.mp4")),$&&J.length>0){let Z=qD(J,$,C);await ID(M(X,"playlist.m3u8"),Z,"utf-8")}}let G=$u(A,E&&$!==void 0&&J.length>0);return await ID(B,G,"utf-8"),B}async function xD(u){let{input:D,outputDir:C,segmentDuration:F=2,profiles:E,customProfiles:B,codec:A="dual",format:$="both",useNvenc:J,hardwareAccelerator:G,quality:X,generateThumbnails:Y=!0,thumbnailConfig:Z={},generatePoster:K=!0,posterTimecode:_="00:00:00",parallel:U=!0,onProgress:q}=u,k=ZD("/tmp",`dash-converter-${fF()}`);await m(k);let W=Yu(D,Gu(D)),N=ZD(C,W);await m(N);let V=ZD(N,"conversion.log");_D(V);let{writeFile:b}=await import("node:fs/promises"),R=`=========================================== +DASH Conversion Log +Started: ${new Date().toISOString()} +Input: ${D} +Output: ${N} +Codec: ${A} +Format: ${$} +=========================================== +`;await b(V,R,"utf-8");try{return await gF(D,C,k,F,E,B,A,$,J,G,X,Y,Z,K,_,U,q)}finally{let{appendFile:XD}=await import("node:fs/promises");try{await XD(V,` +Completed: ${new Date().toISOString()} +`,"utf-8")}catch(YD){}try{await Uu(k,{recursive:!0,force:!0})}catch(YD){console.warn(`Warning: Failed to cleanup temp directory: ${k}`)}}}async function gF(u,D,C,F,E,B,A,$,J,G,X,Y,Z,K,_,U,q){if(!await p())throw Error("FFmpeg is not installed or not in PATH");if(!await n())throw Error("MP4Box is not installed or not in PATH. Install gpac package.");let k=(H,I,UD,kD)=>{if(q)q({stage:H,percent:I,message:UD,currentProfile:kD})};k("analyzing",0,"Analyzing input video...");let W=await a(u),N=W.hasAudio,V=G&&G!=="auto"?G:J===!0?"nvenc":J===!1?"cpu":"auto",b=await s(),{selected:R,h264Encoder:XD,av1Encoder:YD,warnings:mD}=mF(b,V,A);if(mD.length>0)for(let H of mD)console.warn(`⚠️ ${H}`);let O;if(B&&B.length>0){let H=BD(B,W.width,W.height,W.fps,W.videoBitrate);if(H.errors.length>0){console.warn(` +❌ Profile errors:`);for(let I of H.errors)console.warn(` - ${I}`);console.warn("")}if(H.warnings.length>0){console.warn(` +⚠️ Profile warnings:`);for(let I of H.warnings)console.warn(` - ${I}`);console.warn("")}if(O=H.profiles,O.length===0)throw Error("No valid profiles found in custom list. Check errors above.")}else if(E)O=E;else O=DD(W.width,W.height,W.fps,W.videoBitrate);if(O.length===0)throw Error("No suitable profiles found for input video resolution");let AF=Yu(u,Gu(u)),o=ZD(D,AF);try{await Uu(o,{recursive:!0,force:!0})}catch(H){}await m(o);let v=[];if(A==="h264"||A==="dual"){let H=XD||"libx264",I=Xu(H,"h264");v.push({type:"h264",codec:H,preset:I})}if(A==="av1"||A==="dual"){let H=YD||"libsvtav1",I=Xu(H,"av1");v.push({type:"av1",codec:H,preset:I})}let ZF=v.map((H)=>H.type.toUpperCase()).join(" + "),$F=R==="cpu"?"CPU":R.toUpperCase();k("analyzing",20,`Using ${ZF} encoding (${$F})`,void 0);let JF=R==="cpu"?2:3,GD=new Map;for(let H=0;H{let V8=O.findIndex((kF)=>kF.name===t),aD=25+nD*40,rD=sD/100*(40*YF/O.length);if(k("encoding",aD+rD,`Encoding ${I.toUpperCase()} ${t}...`,`${I}-${t}`),q)q({stage:"encoding",percent:aD+rD,currentProfile:`${I}-${t}`,profilePercent:sD,message:`Encoding ${I.toUpperCase()} ${t}...`})});GD.set(I,UF)}k("encoding",65,"Stage 1 complete: All codecs and profiles encoded"),k("encoding",70,"Stage 2: Creating segments and manifests...");let{manifestPath:KF,hlsManifestPath:XF}=await Ku(GD,o,O,F,A,$,N),cD=[];for(let H of GD.values())cD.push(...Array.from(H.values()));k("encoding",80,"Stage 2 complete: All formats packaged");let lD,dD;if(Y){k("thumbnails",80,"Generating thumbnail sprites...");let H={width:Z.width||160,height:Z.height||90,interval:Z.interval||1,columns:Z.columns||10},I=await Fu(u,o,W.duration,H);lD=I.spritePath,dD=I.vttPath,k("thumbnails",90,"Thumbnails generated")}let pD;if(K)k("thumbnails",92,"Generating poster image..."),pD=await uu(u,o,_),k("thumbnails",95,"Poster generated");return k("manifest",95,"Finalizing..."),k("complete",100,"Conversion complete!"),{manifestPath:KF,hlsManifestPath:XF,videoPaths:cD,thumbnailSpritePath:lD,thumbnailVttPath:dD,posterPath:pD,duration:W.duration,profiles:O,usedNvenc:v.some((H)=>H.codec.includes("nvenc")),selectedAccelerator:R,codecType:A,format:$}}var AD={nvenc:100,qsv:90,amf:80,vaapi:70,videotoolbox:65,v4l2:60,cpu:1};function mF(u,D,C){let F=C==="h264"||C==="dual",E=C==="av1"||C==="dual",B=[],A=new Set(["nvenc","qsv","amf"]),$=u.filter((k)=>F&&k.h264Encoder||E&&k.av1Encoder),J=$.filter((k)=>A.has(k.accelerator)),G=(k)=>$.find((W)=>W.accelerator===k),X;if(D!=="auto"){if(D==="cpu")X=void 0;else if(!A.has(D))B.push(`Ускоритель "${D}" пока не поддерживается, использую CPU`);else if(X=G(D),!X)throw Error(`Аппаратный ускоритель "${D}" недоступен в системе`)}else if(X=(J.length>0?J:[]).sort((W,N)=>(AD[N.accelerator]||0)-(AD[W.accelerator]||0))[0],!X&&$.length>0)B.push("Доступен аппаратный ускоритель, но он пока не поддерживается пайплайном, использую CPU");let Z=(J.length>0?J:[]).sort((k,W)=>(AD[W.accelerator]||0)-(AD[k.accelerator]||0)),K=(k)=>{let W=k==="h264"?X?.h264Encoder:X?.av1Encoder;if(W)return{encoder:W,accel:X?.accelerator};let N=Z.find((V)=>k==="h264"?V.h264Encoder:V.av1Encoder);if(N){if(D!=="auto"&&X)B.push(`Выбранный ускоритель "${X.accelerator}" не поддерживает ${k.toUpperCase()}, использую ${N.accelerator}`);return{encoder:k==="h264"?N.h264Encoder:N.av1Encoder,accel:N.accelerator}}if(D!=="auto"&&D!=="cpu")B.push(`Ускоритель "${D}" не поддерживает ${k.toUpperCase()}, использую CPU`);return{encoder:void 0,accel:"cpu"}},_=F?K("h264"):{encoder:void 0,accel:X?.accelerator},U=E?K("av1"):{encoder:void 0,accel:X?.accelerator};return{selected:X?.accelerator||_.accel||U.accel||"cpu",h264Encoder:_.encoder,av1Encoder:U.encoder,warnings:B}}function Xu(u,D){if(u.includes("nvenc"))return"p4";if(u.includes("qsv"))return"medium";if(u.includes("amf"))return"balanced";if(u.includes("vaapi"))return"5";if(u.includes("videotoolbox"))return"medium";if(u.includes("v4l2"))return"medium";if(u==="libsvtav1")return"8";if(u==="libx264")return"medium";return D==="av1"?"8":"medium"}var yD=FD(eu(),1);import{statSync as W8}from"node:fs";var Q=process.argv.slice(2),uD,hD,l="dual",r="both",fD=[],j,w,S,T,FF;for(let u=0;uF.trim()).filter((F)=>F.length>0)}else if(Q[u]==="-p"||Q[u]==="--poster")hD=Q[u+1],u++;else if(Q[u]==="-c"||Q[u]==="--codec"){let D=Q[u+1];if(D==="av1"||D==="h264"||D==="dual")l=D;else console.error(`❌ Invalid codec: ${D}. Valid options: av1, h264, dual`),process.exit(1);u++}else if(Q[u]==="-f"||Q[u]==="--format"){let D=Q[u+1];if(D==="dash"||D==="hls"||D==="both")r=D;else console.error(`❌ Invalid format: ${D}. Valid options: dash, hls, both`),process.exit(1);u++}else if(Q[u]==="--h264-cq"){if(j=parseInt(Q[u+1]),isNaN(j)||j<0||j>51)console.error(`❌ Invalid H.264 CQ value: ${Q[u+1]}. Must be 0-51`),process.exit(1);u++}else if(Q[u]==="--h264-crf"){if(w=parseInt(Q[u+1]),isNaN(w)||w<0||w>51)console.error(`❌ Invalid H.264 CRF value: ${Q[u+1]}. Must be 0-51`),process.exit(1);u++}else if(Q[u]==="--av1-cq"){if(S=parseInt(Q[u+1]),isNaN(S)||S<0||S>51)console.error(`❌ Invalid AV1 CQ value: ${Q[u+1]}. Must be 0-51`),process.exit(1);u++}else if(Q[u]==="--av1-crf"){if(T=parseInt(Q[u+1]),isNaN(T)||T<0||T>63)console.error(`❌ Invalid AV1 CRF value: ${Q[u+1]}. Must be 0-63`),process.exit(1);u++}else if(Q[u]==="--accel"||Q[u]==="--hardware"){let D=Q[u+1];if(!["auto","nvenc","qsv","amf","cpu"].includes(D))console.error(`❌ Invalid accelerator: ${D}. Valid: auto, nvenc, qsv, amf, cpu`),process.exit(1);FF=D,u++}else if(!Q[u].startsWith("-"))fD.push(Q[u]);var i=fD[0],CF=fD[1]||".";if(!i)console.error("❌ Usage: create-vod [output-dir] [options]"),console.error(` +Options:`),console.error(" -r, --resolutions Video resolutions (e.g., 360,480,720 or 720@60,1080@60)"),console.error(" -c, --codec Video codec: av1, h264, or dual (default: dual)"),console.error(" -f, --format Streaming format: dash, hls, or both (default: both)"),console.error(" -p, --poster Poster timecode (e.g., 00:00:05 or 10)"),console.error(" --accel Hardware accelerator: auto|nvenc|qsv|amf|cpu (default: auto)"),console.error(` +Quality Options (override defaults):`),console.error(" --h264-cq H.264 GPU CQ value (0-51, lower = better, default: auto)"),console.error(" --h264-crf H.264 CPU CRF value (0-51, lower = better, default: auto)"),console.error(" --av1-cq AV1 GPU CQ value (0-51, lower = better, default: auto)"),console.error(" --av1-crf AV1 CPU CRF value (0-63, lower = better, default: auto)"),console.error(` +Examples:`),console.error(" create-vod video.mp4"),console.error(" create-vod video.mp4 ./output"),console.error(" create-vod video.mp4 -r 360,480,720"),console.error(" create-vod video.mp4 -c av1 --av1-cq 40"),console.error(" create-vod video.mp4 -c dual --h264-cq 30 --av1-cq 39"),console.error(" create-vod video.mp4 -f hls"),console.error(" create-vod video.mp4 -c dual -f both"),console.error(" create-vod video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash"),console.error(" create-vod video.mp4 -p 00:00:05"),console.error(" create-vod video.mp4 ./output -r 720,1080 -c dual -f both -p 10 --h264-cq 28 --av1-cq 37"),process.exit(1);console.log(`\uD83D\uDD0D Checking system... +`);var EF=await p(),BF=await n(),gD=await s(),DF={nvenc:100,qsv:90,amf:80,vaapi:70,videotoolbox:65,v4l2:60},KD=gD.slice().sort((u,D)=>(DF[D.accelerator]||0)-(DF[u.accelerator]||0))[0];console.log(`FFmpeg: ${EF?"✅":"❌"}`);console.log(`MP4Box: ${BF?"✅":"❌"}`);var _8=Array.from(new Set(gD.map((u)=>u.accelerator.toUpperCase()))),vD=KD?KD.accelerator.toUpperCase():void 0,uF=_8.filter((u)=>u!==vD),Q8=vD?`✅ ${vD}${uF.length>0?` (${uF.join(", ")})`:""}`:"❌";console.log(`Hardware: ${Q8}`);console.log("");if(!EF)console.error("❌ FFmpeg not found. Please install FFmpeg first."),process.exit(1);if(!BF)console.error("❌ MP4Box not found. Please install: sudo pacman -S gpac"),process.exit(1);var H8=gD.some((u)=>u.av1Encoder);if((l==="av1"||l==="dual")&&!H8)console.error("⚠️ Warning: AV1 encoding 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. +`);if((r==="hls"||r==="both")&&l==="av1")console.error("❌ Error: HLS format requires H.264 codec for Safari/iOS compatibility."),console.error(` Please use --codec h264 or --codec dual with --format hls +`),process.exit(1);console.log(`\uD83D\uDCCA Analyzing video... +`);var L=await a(i),N8=W8(i),z8=(N8.size/1048576).toFixed(2);console.log("\uD83D\uDCF9 Video Information:");console.log(` File: ${i}`);console.log(` Size: ${z8} MB`);console.log(` Resolution: ${L.width}x${L.height}`);console.log(` FPS: ${L.fps.toFixed(2)}`);console.log(` Duration: ${Math.floor(L.duration/60)}m ${Math.floor(L.duration%60)}s`);console.log(` Codec: ${L.codec}`);if(L.videoBitrate)console.log(` Video Bitrate: ${(L.videoBitrate/1000).toFixed(2)} Mbps`);if(L.audioBitrate)console.log(` Audio Bitrate: ${L.audioBitrate} kbps`);var PD=[];if(uD&&uD.length>0){let u=BD(uD,L.width,L.height,L.fps,L.videoBitrate);if(u.errors.length>0)console.error(` +❌ Profile errors:`),u.errors.forEach((D)=>console.error(` - ${D}`)),process.exit(1);if(u.warnings.length>0)console.warn(` +⚠️ Profile warnings:`),u.warnings.forEach((D)=>console.warn(` - ${D}`));PD=u.profiles.map((D)=>D.name)}else PD=DD(L.width,L.height,L.fps,L.videoBitrate).map((D)=>D.name);var q8=r==="both"?"DASH (manifest.mpd), HLS (master.m3u8)":r==="dash"?"DASH (manifest.mpd)":"HLS (master.m3u8)",I8=!0,x8=hD||"00:00:00";console.log(` +\uD83D\uDCE6 Parameters:`);console.log(` Input: ${i}`);console.log(` Output: ${CF}`);console.log(` Codec: ${l}${l==="dual"?" (AV1 + H.264)":""}`);console.log(` Profiles: ${PD.join(", ")}`);console.log(` Manifests: ${q8}`);console.log(` Poster: ${x8} (will be generated)`);console.log(` Thumbnails: ${I8?"yes (with VTT)":"no"}`);console.log(` Accelerator: ${KD?KD.accelerator.toUpperCase():"CPU"}`);var f;if(j!==void 0||w!==void 0||S!==void 0||T!==void 0){if(f={},j!==void 0||w!==void 0){if(f.h264={},j!==void 0)f.h264.cq=j;if(w!==void 0)f.h264.crf=w;console.log(`\uD83C\uDF9A️ H.264 Quality: ${j!==void 0?`CQ ${j}`:""}${w!==void 0?` CRF ${w}`:""}`)}if(S!==void 0||T!==void 0){if(f.av1={},S!==void 0)f.av1.cq=S;if(T!==void 0)f.av1.crf=T;console.log(`\uD83C\uDF9A️ AV1 Quality: ${S!==void 0?`CQ ${S}`:""}${T!==void 0?` CRF ${T}`:""}`)}}console.log(` +\uD83D\uDE80 Starting conversion... +`);var JD=new yD.default.MultiBar({format:"{stage} | {bar} | {percentage}% | {name}",barCompleteChar:"█",barIncompleteChar:"░",hideCursor:!0,clearOnComplete:!1,stopOnComplete:!0},yD.default.Presets.shades_classic),TD={},bD=null;try{let u=await xD({input:i,outputDir:CF,customProfiles:uD,posterTimecode:hD,codec:l,format:r,segmentDuration:2,hardwareAccelerator:FF,quality:f,generateThumbnails:!0,generatePoster:!0,parallel:!0,onProgress:(D)=>{let C=D.stage==="encoding"?"Encoding":D.stage==="thumbnails"?"Thumbnails":D.stage==="manifest"?"Manifest":D.stage==="analyzing"?"Analyzing":"Complete";if(D.stage==="encoding"&&D.currentProfile){if(!TD[D.currentProfile])TD[D.currentProfile]=JD.create(100,0,{stage:"Encode",name:D.currentProfile});let F=D.profilePercent??D.percent;TD[D.currentProfile].update(F,{stage:"Encode",name:D.currentProfile})}if(!bD)bD=JD.create(100,0,{stage:C,name:"Overall"});bD.update(D.percent,{stage:C,name:D.message||"Overall"})}});JD.stop(),console.log(` +✅ Conversion completed successfully! +`)}catch(u){JD.stop(),console.error(` + +❌ Error during conversion:`),console.error(u),process.exit(1)} diff --git a/docs/CLI_REFERENCE.md b/docs/CLI_REFERENCE.md new file mode 100644 index 0000000..85d708e --- /dev/null +++ b/docs/CLI_REFERENCE.md @@ -0,0 +1,404 @@ +# CLI Reference — Справочник команд + +Полное руководство по использованию DASH Video Converter CLI. + +--- + +## Синтаксис + +```bash +create-vod [output-dir] [options] +``` + +## Позиционные аргументы + +| Аргумент | Описание | По умолчанию | +|----------|----------|--------------| +| `input-video` | Путь к входному видео файлу | **Обязательный** | +| `output-dir` | Директория для сохранения результата | `.` (текущая папка) | + +--- + +## Опции + +### `-r, --resolutions` — Профили разрешений + +Задает список профилей для генерации. Можно указывать разрешение и FPS. + +**Формат:** +- `` — только разрешение (FPS = 30) +- `@` — разрешение с FPS (разделитель `@`) +- `-` — разрешение с FPS (разделитель `-`) + +**Поддерживаемые разрешения:** +- `360p` (640×360) +- `480p` (854×480) +- `720p` (1280×720) +- `1080p` (1920×1080) +- `1440p` (2560×1440) +- `2160p` (3840×2160) + +**Примеры:** +```bash +# Базовые разрешения (30 FPS) +create-vod video.mp4 -r 360,720,1080 + +# С указанием FPS +create-vod video.mp4 -r 720@60,1080@60 + +# Смешанный формат +create-vod video.mp4 -r 360 720@60 1080 1440@120 +``` + +**Автоматическая коррекция FPS:** +- Если запрошенный FPS > FPS источника → используется FPS источника +- Максимальный FPS: **120** (ограничение системы) +- Система выведет предупреждение при коррекции + +**По умолчанию:** Автоматический выбор всех подходящих разрешений (≤ разрешения источника) с 30 FPS. + +--- + +### `-c, --codec` — Видео кодек + +Выбор видео кодека для кодирования. + +**Значения:** +- `h264` — только H.264 (максимальная совместимость) +- `av1` — только AV1 (лучшее сжатие, новые браузеры) +- `dual` — оба кодека (рекомендуется) + +**Примеры:** +```bash +# Только H.264 (быстрее, больше места) +create-vod video.mp4 -c h264 + +# Только AV1 (медленнее, меньше места) +create-vod video.mp4 -c av1 + +# Оба кодека (максимальная совместимость) +create-vod video.mp4 -c dual +``` + +**GPU ускорение:** +- H.264: `h264_nvenc` (NVIDIA), fallback → `libx264` (CPU) +- AV1: `av1_nvenc` (NVIDIA), `av1_qsv` (Intel), `av1_amf` (AMD), fallback → `libsvtav1` (CPU) + +**По умолчанию:** `dual` + +--- + +### `--accel` — Аппаратный ускоритель + +Выбор приоритетного ускорителя. По умолчанию выбирается лучший из доступных. + +**Значения:** +- `auto` — автоопределение по приоритету (NVENC → QSV → AMF → CPU) +- `nvenc` — NVIDIA NVENC +- `qsv` — Intel Quick Sync +- `amf` — AMD AMF +- `cpu` — принудительно без GPU + +**Примеры:** +```bash +create-vod video.mp4 --accel nvenc +create-vod video.mp4 --accel qsv +create-vod video.mp4 --accel cpu # отключить GPU +``` + +--- + +### `-f, --format` — Формат стриминга + +Выбор формата адаптивного стриминга. + +**Значения:** +- `dash` — только DASH (MPEG-DASH) +- `hls` — только HLS (HTTP Live Streaming) +- `both` — оба формата + +**Примеры:** +```bash +# Только DASH +create-vod video.mp4 -f dash + +# Только HLS (для Safari/iOS) +create-vod video.mp4 -f hls + +# Оба формата (максимальная совместимость) +create-vod video.mp4 -f both +``` + +**Особенности:** + +| Формат | Кодеки | Совместимость | Примечание | +|--------|--------|---------------|------------| +| DASH | H.264 + AV1 | Chrome, Firefox, Edge, Safari (с dash.js) | Стандарт индустрии | +| HLS | H.264 только | Safari, iOS, все браузеры | Требует H.264 | +| both | H.264 + AV1 (DASH), H.264 (HLS) | Максимальная | Рекомендуется | + +**Ограничения:** +- HLS требует `--codec h264` или `--codec dual` +- AV1 не поддерживается в HLS (Safari не поддерживает AV1) + +**Файловая структура:** +``` +output/ +└── video_name/ + ├── 720p-h264/ ← Сегменты H.264 720p + │ ├── 720p-h264_.mp4 + │ ├── 720p-h264_1.m4s + │ └── playlist.m3u8 ← HLS медиа плейлист + ├── 720p-av1/ ← Сегменты AV1 720p (только для DASH) + │ ├── 720p-av1_.mp4 + │ └── 720p-av1_1.m4s + ├── audio/ ← Аудио сегменты + │ ├── audio_.mp4 + │ ├── audio_1.m4s + │ └── playlist.m3u8 + ├── manifest.mpd ← DASH манифест (корень) + ├── master.m3u8 ← HLS мастер плейлист (корень) + ├── poster.jpg ← Общие файлы + ├── thumbnails.jpg + └── thumbnails.vtt +``` + +**Преимущества структуры:** +- Сегменты хранятся один раз (нет дублирования) +- DASH и HLS используют одни и те же .m4s файлы +- Экономия 50% места при `format=both` + +**По умолчанию:** `both` (максимальная совместимость) + +--- + +### `-p, --poster` — Тайм-код постера + +Время, с которого извлечь кадр для постера. + +**Формат:** +- Секунды: `5`, `10.5` +- Тайм-код: `00:00:05`, `00:01:30` + +**Примеры:** +```bash +# 5 секунд от начала +create-vod video.mp4 -p 5 + +# 1 минута 30 секунд +create-vod video.mp4 -p 00:01:30 +``` + +**По умолчанию:** `00:00:00` (первый кадр) + +--- + +## Примеры использования + +### Базовое использование + +```bash +# Простейший запуск (оба формата, dual codec, автопрофили) +create-vod video.mp4 + +# С указанием выходной директории +create-vod video.mp4 ./output +``` + +### Кастомные профили + +```bash +# Только 720p и 1080p +create-vod video.mp4 -r 720,1080 + +# High FPS профили +create-vod video.mp4 -r 720@60,1080@60,1440@120 + +# Один профиль 4K +create-vod video.mp4 -r 2160 +``` + +### Выбор кодека + +```bash +# Быстрое кодирование (только H.264) +create-vod video.mp4 -c h264 + +# Лучшее сжатие (только AV1) +create-vod video.mp4 -c av1 + +# Максимальная совместимость +create-vod video.mp4 -c dual +``` + +### Выбор формата + +```bash +# DASH для современных браузеров +create-vod video.mp4 -f dash + +# HLS для Safari/iOS +create-vod video.mp4 -f hls -c h264 + +# Оба формата для всех устройств +create-vod video.mp4 -f both -c dual +``` + +### Комбинированные примеры + +```bash +# Производственная конфигурация +create-vod video.mp4 ./cdn/videos -r 360,720,1080 -c dual -f both + +# High-end конфигурация (4K, high FPS) +create-vod video.mp4 -r 720@60,1080@60,1440@120,2160@60 -c dual -f both + +# Быстрая конвертация для тестов +create-vod video.mp4 -r 720 -c h264 -f dash + +# Mobile-first (низкие разрешения, HLS) +create-vod video.mp4 -r 360,480,720 -c h264 -f hls + +# Кастомный постер +create-vod video.mp4 -r 720,1080 -p 00:02:30 +``` + +--- + +## Системные требования + +### Обязательные зависимости + +- **FFmpeg** — кодирование видео +- **MP4Box (GPAC)** — упаковка DASH/HLS + +Установка: +```bash +# Arch Linux +sudo pacman -S ffmpeg gpac + +# Ubuntu/Debian +sudo apt install ffmpeg gpac + +# macOS +brew install ffmpeg gpac +``` + +### Опциональные (для GPU ускорения) + +- **NVIDIA GPU** — для H.264/AV1 кодирования через NVENC +- **Intel GPU** — для AV1 через QSV +- **AMD GPU** — для AV1 через AMF + +--- + +## Производительность + +### CPU vs GPU + +| Кодек | CPU | GPU (NVENC) | Ускорение | +|-------|-----|-------------|-----------| +| H.264 | libx264 | h264_nvenc | ~10-20x | +| AV1 | libsvtav1 | av1_nvenc | ~15-30x | + +### Параллельное кодирование + +- **GPU**: до 3 профилей одновременно +- **CPU**: до 2 профилей одновременно + +### Время конвертации (примерные данные) + +Видео 4K, 10 секунд, dual codec, 3 профиля: + +| Конфигурация | Время | +|--------------|-------| +| CPU (libx264 + libsvtav1) | ~5-10 минут | +| GPU (NVENC) | ~30-60 секунд | + +--- + +## Рекомендации + +### Для максимальной совместимости + +```bash +create-vod video.mp4 -c dual -f both +``` + +Генерирует: +- DASH с H.264 + AV1 (Chrome, Firefox, Edge) +- HLS с H.264 (Safari, iOS) +- Все современные устройства поддерживаются + +### Для быстрой разработки + +```bash +create-vod video.mp4 -r 720 -c h264 -f dash +``` + +Быстрое кодирование одного профиля. + +### Для продакшена + +```bash +create-vod video.mp4 -r 360,480,720,1080,1440 -c dual -f both +``` + +Широкий диапазон профилей для всех устройств. + +### Для 4K контента + +```bash +create-vod video.mp4 -r 720,1080,1440,2160 -c dual -f both +``` + +От HD до 4K для премиум контента. + +--- + +## Устранение проблем + +### HLS требует H.264 + +**Ошибка:** +``` +❌ Error: HLS format requires H.264 codec +``` + +**Решение:** +```bash +# Используйте h264 или dual +create-vod video.mp4 -f hls -c h264 +# или +create-vod video.mp4 -f hls -c dual +``` + +### FPS источника ниже запрошенного + +**Предупреждение:** +``` +⚠️ Requested 120 FPS, but source is 60 FPS. Using 60 FPS instead +``` + +Это нормально! Система автоматически ограничивает FPS до максимума источника. + +### MP4Box не найден + +**Ошибка:** +``` +❌ MP4Box not found +``` + +**Решение:** +```bash +sudo pacman -S gpac # Arch +sudo apt install gpac # Ubuntu +``` + +--- + +## См. также + +- [FEATURES.md](./FEATURES.md) — Возможности и технические детали +- [PUBLISHING.md](./PUBLISHING.md) — Публикация пакета в npm +- [README.md](../README.md) — Быстрый старт diff --git a/docs/FEATURES.md b/docs/FEATURES.md new file mode 100644 index 0000000..087ae42 --- /dev/null +++ b/docs/FEATURES.md @@ -0,0 +1,213 @@ +# Возможности DASH Video Converter + +## Архитектура + +Конвертация выполняется в два этапа для обеспечения стабильности и максимальной производительности: + +**Этап 1: Кодирование** - тяжелая работа по перекодированию видео во все профили качества с использованием FFmpeg и NVENC. + +**Этап 2: Упаковка DASH** - быстрая упаковка готовых MP4 файлов в DASH формат через MP4Box с генерацией манифеста. + +Преимущества подхода: +- Стабильность: MP4Box специализируется на DASH, FFmpeg - на кодирование +- Параллелизм: все профили кодируются одновременно на GPU +- Надежность: разделение ответственности между инструментами + +## Этап 1: Оптимизация и кодирование + +### Стандартные профили разрешений + +Автоматически создаются профили с частотой 30 FPS: +- 360p (640x360) - 800 kbps +- 480p (854x480) - 1200 kbps +- 720p (1280x720) - 2800 kbps +- 1080p (1920x1080) - 5000 kbps + +### Опциональные профили высокого разрешения + +Создаются только если исходное видео имеет соответствующее или более высокое разрешение: +- 2K (2560x1440) - если исходное >= 1440p +- 4K (3840x2160) - если исходное >= 2160p + +Система автоматически определяет разрешение исходного видео и создает только применимые профили без upscaling. + +### Высокочастотные профили + +Система автоматически определяет частоту кадров исходного видео и создает дополнительные высокочастотные профили только если это поддерживается оригиналом: + +- **Оригинал >= 45 FPS**: создаются профили @ 60 FPS для всех разрешений +- **Оригинал >= 75 FPS**: создаются профили @ 90 FPS для всех разрешений +- **Оригинал >= 95 FPS**: создаются профили @ 120 FPS для всех разрешений + +Стандартные 30 FPS профили создаются всегда. + +Пример: если исходное видео 60 FPS, будут созданы: +- 360p @ 30fps, 360p @ 60fps +- 480p @ 30fps, 480p @ 60fps +- 720p @ 30fps, 720p @ 60fps +- 1080p @ 30fps, 1080p @ 60fps + +Интерполяция кадров не применяется - создаются только те частоты, которые нативно поддерживаются исходным материалом. + +### Технические особенности + +- **NVENC GPU ускорение**: аппаратное кодирование на видеокарте NVIDIA +- **GOP size выравнивание**: keyframe каждые N кадров для точной сегментации (N = FPS × segment_duration) +- **VBR режим**: переменный битрейт для оптимального качества +- **Умное кодирование аудио**: автоматический выбор оптимального битрейта без upscaling + - Целевой максимум: 256 kbps AAC стерео + - Фактический битрейт: `min(source_bitrate, 256 kbps)` + - Округление до стандартных значений: 64k, 96k, 128k, 192k, 256k + - Примеры: исходник 64 kbps → выход 64 kbps | исходник 320 kbps → выход 256 kbps + +## Этап 2: Создание DASH + +### Упаковка через MP4Box + +- Создание фрагментированных MP4 сегментов длительностью 2 секунды +- Генерация единого MPD манифеста для всех профилей +- Выравнивание сегментов по Random Access Points (RAP) + +### Организация файловой структуры + +После упаковки файлы автоматически организуются в подпапки: +- Видео сегменты: `{resolution}/` +- Аудио сегменты: `audio/` +- Манифест: корень директории + +Пути в MPD манифесте обновляются для соответствия структуре подпапок. + +## Множественные аудио дорожки + +### Поддержка озвучек и языков + +Система поддерживает несколько аудио дорожек с различными источниками: + +**Извлечение из видео**: +- Автоматическое извлечение всех аудио дорожек из входного файла +- Выбор конкретных дорожек по индексу + +**Внешние файлы**: +- Добавление аудио из отдельных файлов (MP3, AAC, M4A) +- Синхронизация с видео + +### Метаданные аудио дорожек + +Каждая дорожка содержит метаданные для правильного отображения в плеере: + +- **language**: код языка (ru, en, ja) +- **label**: название озвучки ("Кубик в кубе", "LostFilm", "Original") +- **role**: тип озвучки + - `main` - основная + - `dub` - дубляж + - `commentary` - комментарии + +Пример структуры в MPD: +```xml + + + + +``` + +## Генерация постера + +### Автоматический режим + +По умолчанию постер создается из первого кадра видео (00:00:00). + +### Указание таймкода + +Возможно указание конкретного времени для извлечения постера: + +Формат: +- `MM:SS` - минуты:секунды (например, `06:32`) +- `HH:MM:SS` - часы:минуты:секунды (например, `01:23:45`) + +Команда: +```bash +--poster-time 06:32 +``` + +Постер сохраняется в формате JPEG с оптимизированным качеством. + +## Превью спрайты + +### Thumbnail спрайты + +Автоматическая генерация спрайта с миниатюрами для навигации по видео: + +- **Интервал**: 1 секунда (по умолчанию) +- **Размер миниатюры**: 160x90 пикселей +- **Сетка**: 10 колонок, динамическое количество строк +- **Формат**: JPEG sprite + +### WebVTT файл + +Генерируется VTT файл с координатами каждой миниатюры: + +```vtt +WEBVTT + +00:00:00.000 --> 00:00:01.000 +thumbnails.jpg#xywh=0,0,160,90 + +00:00:01.000 --> 00:00:02.000 +thumbnails.jpg#xywh=160,0,160,90 +``` + +Плееры используют VTT для отображения превью при наведении на timeline. + +## Выходная структура файлов + +### Организация директорий + +``` +output/ +└── video-name/ + ├── manifest.mpd # Главный DASH манифест + ├── poster.jpg # Постер видео + ├── thumbnails.jpg # Спрайт превью + ├── thumbnails.vtt # WebVTT для превью + ├── audio/ # Аудио дорожки + │ ├── audio_init.m4s # Инициализационный сегмент + │ ├── audio_1.m4s # Сегмент #1 + │ └── audio_N.m4s # Сегмент #N + ├── 1080p/ # Профиль 1080p @ 30fps + │ ├── 1080p_init.m4s + │ ├── 1080p_1.m4s + │ └── 1080p_N.m4s + ├── 1080p-60/ # Профиль 1080p @ 60fps (если применимо) + │ └── ... + ├── 720p/ # Профиль 720p @ 30fps + │ └── ... + ├── 480p/ # Профиль 480p @ 30fps + │ └── ... + └── 360p/ # Профиль 360p @ 30fps + └── ... +``` + +### Именование файлов + +**Инициализационные сегменты**: `{profile}_init.m4s` или `{profile}_.mp4` + +**Медиа сегменты**: `{profile}_{number}.m4s` + +**Аудио**: `audio_{number}.m4s` или `audio_{lang}_{number}.m4s` для множественных дорожек + +Имя выходной директории всегда соответствует имени входного видео файла (без расширения). + +## Производительность + +- Параллельное кодирование до 3 профилей одновременно (с NVENC) +- GOP size точно соответствует длительности сегмента для быстрой упаковки +- Временные файлы в `/tmp/` с автоочисткой +- Прогресс-бары в реальном времени для каждого профиля + +## Требования + +- **FFmpeg**: с поддержкой h264_nvenc (опционально), aac, scale +- **MP4Box** (GPAC): для DASH упаковки +- **NVIDIA GPU**: для NVENC ускорения (опционально, fallback на CPU) +- **Bun**: runtime окружение + diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md new file mode 100644 index 0000000..be081bb --- /dev/null +++ b/docs/PUBLISHING.md @@ -0,0 +1,135 @@ +# 📦 Инструкция по публикации в NPM + +## Подготовка к публикации + +### Шаг 1: Авторизация в NPM + +```bash +npm login +``` + +Введите credentials для аккаунта с доступом к организации `@grom13`. + +### Шаг 2: Сборка проекта + +```bash +cd /home/gromov/projects/my/create-vod +npm run build +``` + +Эта команда выполнит: +- Сборку библиотеки в `dist/` +- Генерацию TypeScript деклараций (`.d.ts`) +- Сборку CLI бинарника в `bin/cli.js` + +### Шаг 3: Проверка перед публикацией (опционально) + +```bash +# Посмотреть какие файлы будут опубликованы +npm pack --dry-run + +# Или создать тестовый архив для проверки +npm pack +# Это создаст файл gromlab-create-vod-0.1.0.tgz +``` + +## Публикация + +### Шаг 4: Публикация в NPM + +```bash +npm publish --access public +``` + +⚠️ **Важно:** Флаг `--access public` обязателен для scoped пакетов (`@grom13/...`), иначе NPM попытается опубликовать как приватный пакет (требует платную подписку). + +### Шаг 5: Проверка публикации + +```bash +# Проверить что пакет доступен +npm view @gromlab/create-vod + +# Протестировать установку через npx +npx @gromlab/create-vod --help + +# Или установить глобально и протестировать +npm install -g @gromlab/create-vod +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 @gromlab/create-vod@0.1.0 + +# Удалить весь пакет (использовать осторожно!) +npm unpublish @gromlab/create-vod --force +``` + +⚠️ **Внимание:** После unpublish нельзя повторно опубликовать ту же версию. Нужно увеличить версию. + +## Использование после публикации + +Пакет будет доступен для использования: + +```bash +# Через npx (без установки) +npx @gromlab/create-vod video.mp4 ./output + +# Глобальная установка +npm install -g @gromlab/create-vod +dvc video.mp4 ./output + +# Локальная установка в проект +npm install @gromlab/create-vod +``` + +## 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`) diff --git a/docs/VIDEO_QUALITY_TESTING.md b/docs/VIDEO_QUALITY_TESTING.md new file mode 100644 index 0000000..b04826d --- /dev/null +++ b/docs/VIDEO_QUALITY_TESTING.md @@ -0,0 +1,558 @@ +# Тестирование качества видео и сравнение кодеков + +Руководство по анализу качества видео, сравнению кодеков и измерению эффективности сжатия. + +## 📋 Содержание + +1. [Метрики качества](#метрики-качества) +2. [Протестированные кодеки](#протестированные-кодеки) +3. [Результаты тестирования](#результаты-тестирования) +4. [Команды для тестирования](#команды-для-тестирования) +5. [Интерпретация результатов](#интерпретация-результатов) +6. [Рекомендации](#рекомендации) + +--- + +## Метрики качества + +### PSNR (Peak Signal-to-Noise Ratio) + +**Что измеряет:** Отношение сигнал/шум в децибелах (dB). Показывает математическое отличие между оригиналом и сжатым видео. + +**Интерпретация:** +- `> 45 dB` - Отличное качество (практически неразличимо) +- `40-45 dB` - Очень хорошее качество +- `35-40 dB` - Хорошее качество +- `30-35 dB` - Приемлемое качество +- `< 30 dB` - Плохое качество (видимые артефакты) + +**Формула расчета:** +``` +PSNR = 10 × log₁₀(MAX²/MSE) +``` +где MAX - максимальное значение пикселя (255 для 8-bit), MSE - средняя квадратичная ошибка. + +### SSIM (Structural Similarity Index) + +**Что измеряет:** Структурное сходство изображений. Более точно отражает восприятие человеческим глазом. + +**Интерпретация:** +- `1.0` - Идентичные изображения (100%) +- `> 0.99` - Отличное качество (99%+ схожести) +- `0.95-0.99` - Хорошее качество +- `0.90-0.95` - Приемлемое качество +- `< 0.90` - Заметная потеря качества + +**Преимущества SSIM:** +- Учитывает яркость, контраст и структуру +- Лучше коррелирует с субъективной оценкой качества +- Более устойчива к локальным искажениям + +--- + +## Протестированные кодеки + +### 1. H.264 / AVC + +**Описание:** Широко распространенный кодек, поддерживается всеми устройствами. + +**Энкодеры:** +- `libx264` - CPU энкодер (отличное качество/размер) +- `h264_nvenc` - NVIDIA GPU энкодер (быстрее, но менее эффективен) + +**Параметры качества:** +- CRF: 0-51 (меньше = лучше качество) +- Рекомендуемый диапазон: 18-28 +- Пресеты: ultrafast, fast, medium, slow, slower, veryslow + +### 2. VP9 + +**Описание:** Открытый кодек от Google, часть WebM. На 20-50% эффективнее H.264. + +**Энкодеры:** +- `libvpx-vp9` - CPU энкодер +- `vp9_vaapi` - аппаратное ускорение (Intel/AMD) + +**Параметры качества:** +- CRF: 0-63 (меньше = лучше качество) +- Рекомендуемый диапазон: 28-35 +- cpu-used: 0-5 (меньше = лучше качество, медленнее) + +### 3. AV1 + +**Описание:** Современный кодек, следующее поколение после VP9. На 30-50% эффективнее H.264. + +**Энкодеры:** +- `libsvtav1` - CPU энкодер (быстрый, хорошее качество) +- `libaom-av1` - CPU энкодер (лучшее качество, очень медленный) +- `av1_nvenc` - NVIDIA GPU энкодер (быстро, но менее эффективен) +- `av1_amf` - AMD GPU энкодер +- `av1_qsv` - Intel GPU энкодер + +**Параметры качества:** +- CRF (libsvtav1): 0-63 (меньше = лучше качество) +- CQ (av1_nvenc): 0-51 (меньше = лучше качество) +- Рекомендуемый диапазон: 30-40 + +--- + +## Результаты тестирования + +### Тестовое видео + +**Параметры:** +- Файл: `tenexia.mp4` +- Разрешение: 1920×1080 (Full HD) +- FPS: 25 +- Длительность: 135 секунд (2:15) +- Оригинальный размер: 167 MB +- Оригинальный кодек: H.264 (битрейт ~10 Mbps) + +### Сводная таблица результатов + +| Кодек | Энкодер | Параметр | Размер | PSNR | SSIM | Сжатие | Скорость | +|-------|---------|----------|--------|------|------|--------|----------| +| **Оригинал** | - | - | 167 MB | - | - | 1.0x | - | +| **VP9** | libvpx-vp9 | CRF 32 | 13 MB | 47.42 dB | 0.9917 | 12.8x | ~5-10 мин | +| **AV1** | libsvtav1 | CRF 35 | 9.5 MB | 48.01 dB | 0.9921 | 17.6x | ~10-15 мин | +| **AV1** | av1_nvenc | CQ 32 | 20 MB | N/A | N/A | 8.3x | ~10 сек | +| **AV1** | av1_nvenc | CQ 40 | 9.3 MB | 47.13 dB | 0.9914 | 18.0x | ~10 сек | +| **AV1** | av1_nvenc | CQ 45 | 7.1 MB | 45.49 dB | 0.9899 | 23.5x | ~10 сек | +| **H.264** | libx264 | CRF 28 | 9.7 MB | 44.85 dB | 0.9904 | 17.2x | ~3-5 мин | +| **H.264** | h264_nvenc | CQ 28 | 20 MB | 47.88 dB | 0.9922 | 8.4x | ~10 сек | +| **H.264** | h264_nvenc | CQ 32 | 12 MB | N/A | N/A | 14.0x | ~10 сек | +| **H.264** | h264_nvenc | CQ 35 | 7.9 MB | 44.48 dB | 0.9891 | 21.1x | ~10 сек | + +### Победители по категориям + +🥇 **Лучшее качество:** AV1 CPU (CRF 35) - PSNR 48.01 dB, SSIM 0.9921 + +🥇 **Лучшее сжатие при сохранении качества:** AV1 GPU (CQ 40) - 9.3 MB, PSNR 47.13 dB + +🥇 **Максимальное сжатие:** AV1 GPU (CQ 45) - 7.1 MB, PSNR 45.49 dB (всё ещё отличное) + +⚡ **Лучший баланс скорость/качество:** AV1 GPU (CQ 40) - быстро + малый размер + хорошее качество + +--- + +## Команды для тестирования + +### 1. Анализ исходного видео + +#### Получить метаданные с помощью ffprobe + +```bash +# Полная информация в JSON формате +ffprobe -v error -show_format -show_streams -print_format json input.mp4 + +# Краткая информация о видео +ffprobe -v error -select_streams v:0 -show_entries stream=codec_name,width,height,r_frame_rate,bit_rate -of default=noprint_wrappers=1 input.mp4 + +# Информация об аудио +ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,bit_rate,channels -of default=noprint_wrappers=1 input.mp4 + +# Размер файла и битрейт +ffprobe -v error -show_entries format=size,duration,bit_rate -of default=noprint_wrappers=1:nokey=1 input.mp4 +``` + +### 2. Конвертация видео + +#### VP9 (CPU) + +```bash +# Базовая конвертация +ffmpeg -i input.mp4 \ + -c:v libvpx-vp9 \ + -crf 32 \ + -b:v 0 \ + -row-mt 1 \ + -cpu-used 2 \ + -c:a libopus \ + -b:a 128k \ + output_vp9.webm + +# Параметры: +# -crf 32 - качество (18-40, меньше = лучше) +# -b:v 0 - режим постоянного качества +# -row-mt 1 - многопоточность +# -cpu-used 2 - скорость кодирования (0-5) +``` + +#### AV1 (CPU) - libsvtav1 + +```bash +# Рекомендуемая конфигурация +ffmpeg -i input.mp4 \ + -c:v libsvtav1 \ + -crf 35 \ + -preset 6 \ + -svtav1-params tune=0 \ + -c:a libopus \ + -b:a 128k \ + output_av1_cpu.mp4 + +# Параметры: +# -crf 35 - качество (0-63, меньше = лучше) +# -preset 6 - скорость (0-13, 6 = средняя) +# -svtav1-params tune=0 - оптимизация под PSNR +``` + +#### AV1 (GPU) - NVIDIA + +```bash +# Оптимальный баланс качество/размер +ffmpeg -i input.mp4 \ + -c:v av1_nvenc \ + -preset p7 \ + -cq 40 \ + -b:v 0 \ + -c:a libopus \ + -b:a 128k \ + output_av1_gpu.mp4 + +# Параметры: +# -preset p7 - качество пресета (p1-p7, p7 = лучшее) +# -cq 40 - constant quality (0-51, меньше = лучше) +# -b:v 0 - без ограничения битрейта + +# Максимальное сжатие (хорошее качество) +ffmpeg -i input.mp4 -c:v av1_nvenc -preset p7 -cq 45 -b:v 0 -c:a libopus -b:a 128k output_av1_small.mp4 +``` + +#### AV1 (GPU) - AMD + +```bash +ffmpeg -i input.mp4 \ + -c:v av1_amf \ + -quality quality \ + -qp_i 40 -qp_p 40 \ + -c:a libopus \ + -b:a 128k \ + output_av1_amd.mp4 +``` + +#### AV1 (GPU) - Intel + +```bash +ffmpeg -i input.mp4 \ + -c:v av1_qsv \ + -preset veryslow \ + -global_quality 40 \ + -c:a libopus \ + -b:a 128k \ + output_av1_intel.mp4 +``` + +#### H.264 (CPU) + +```bash +# Лучшее качество/размер +ffmpeg -i input.mp4 \ + -c:v libx264 \ + -crf 28 \ + -preset slow \ + -c:a aac \ + -b:a 128k \ + output_h264_cpu.mp4 + +# Параметры: +# -crf 28 - качество (18-28, меньше = лучше) +# -preset slow - компромисс скорость/качество +# (ultrafast, fast, medium, slow, slower, veryslow) +``` + +#### H.264 (GPU) - NVIDIA + +```bash +# Баланс качество/размер +ffmpeg -i input.mp4 \ + -c:v h264_nvenc \ + -preset p7 \ + -cq 33 \ + -b:v 0 \ + -c:a aac \ + -b:a 128k \ + output_h264_gpu.mp4 + +# Параметры: +# -preset p7 - качество (p1-p7) +# -cq 33 - constant quality (0-51) +``` + +### 3. Измерение качества + +#### PSNR (Peak Signal-to-Noise Ratio) + +```bash +# Базовый расчет PSNR +ffmpeg -i encoded.mp4 -i original.mp4 \ + -lavfi "[0:v][1:v]psnr" \ + -f null - 2>&1 | grep "PSNR" + +# С сохранением детальной статистики в файл +ffmpeg -i encoded.mp4 -i original.mp4 \ + -lavfi "[0:v][1:v]psnr=stats_file=psnr_stats.log" \ + -f null - + +# Просмотр статистики +head -5 psnr_stats.log && echo "..." && tail -5 psnr_stats.log +``` + +**Формат вывода:** +``` +PSNR y:46.02 u:53.92 v:53.54 average:47.42 min:41.20 max:52.27 +``` + +- `y` - яркость (luminance) +- `u`, `v` - цветовые каналы (chrominance) +- `average` - средний PSNR +- `min`, `max` - минимальный и максимальный PSNR по кадрам + +#### SSIM (Structural Similarity Index) + +```bash +# Базовый расчет SSIM +ffmpeg -i encoded.mp4 -i original.mp4 \ + -lavfi "[0:v][1:v]ssim" \ + -f null - 2>&1 | grep "SSIM" + +# С сохранением детальной статистики +ffmpeg -i encoded.mp4 -i original.mp4 \ + -lavfi "[0:v][1:v]ssim=stats_file=ssim_stats.log" \ + -f null - + +# Просмотр статистики +head -5 ssim_stats.log && echo "..." && tail -5 ssim_stats.log +``` + +**Формат вывода:** +``` +SSIM Y:0.9887 (19.46 dB) U:0.9979 (26.70 dB) V:0.9979 (26.75 dB) All:0.9917 (20.83 dB) +``` + +- Значения 0.0-1.0 (1.0 = идентичные изображения) +- dB - SSIM в децибелах (для удобства сравнения) + +#### VMAF (Video Multimethod Assessment Fusion) + +VMAF - современная метрика от Netflix, лучше всего коррелирует с человеческим восприятием. + +```bash +# Установка модели VMAF (один раз) +# Скачать модель с https://github.com/Netflix/vmaf/tree/master/model + +# Расчет VMAF +ffmpeg -i encoded.mp4 -i original.mp4 \ + -lavfi "[0:v][1:v]libvmaf=model_path=/path/to/vmaf_v0.6.1.json:log_path=vmaf.json" \ + -f null - + +# Интерпретация VMAF: +# 90-100 - Отличное качество +# 75-90 - Хорошее качество +# 50-75 - Приемлемое качество +# < 50 - Плохое качество +``` + +### 4. Полный скрипт для тестирования + +Создайте файл `test_codec.sh`: + +```bash +#!/bin/bash + +# Использование: ./test_codec.sh input.mp4 encoded.mp4 output_dir + +INPUT="$1" +ENCODED="$2" +OUTPUT_DIR="${3:-.}" + +mkdir -p "$OUTPUT_DIR" + +echo "=== Анализ размеров файлов ===" +echo "Оригинал:" +ls -lh "$INPUT" | awk '{print $5, $9}' +echo "Сжатый:" +ls -lh "$ENCODED" | awk '{print $5, $9}' +echo "" + +# Рассчет сжатия +ORIG_SIZE=$(stat -f%z "$INPUT" 2>/dev/null || stat -c%s "$INPUT") +ENC_SIZE=$(stat -f%z "$ENCODED" 2>/dev/null || stat -c%s "$ENCODED") +RATIO=$(echo "scale=2; $ORIG_SIZE / $ENC_SIZE" | bc) +echo "Сжатие: ${RATIO}x" +echo "" + +echo "=== Метаданные закодированного видео ===" +ffprobe -v error -show_format -show_streams -print_format json "$ENCODED" | \ + grep -E "(codec_name|width|height|bit_rate|size)" | head -10 +echo "" + +echo "=== Расчет PSNR ===" +ffmpeg -i "$ENCODED" -i "$INPUT" \ + -lavfi "[0:v][1:v]psnr=stats_file=$OUTPUT_DIR/psnr_stats.log" \ + -f null - 2>&1 | grep "PSNR" +echo "" + +echo "=== Расчет SSIM ===" +ffmpeg -i "$ENCODED" -i "$INPUT" \ + -lavfi "[0:v][1:v]ssim=stats_file=$OUTPUT_DIR/ssim_stats.log" \ + -f null - 2>&1 | grep "SSIM" +echo "" + +echo "Детальная статистика сохранена в $OUTPUT_DIR/" +``` + +**Использование:** + +```bash +chmod +x test_codec.sh +./test_codec.sh original.mp4 encoded.mp4 ./test_results +``` + +--- + +## Интерпретация результатов + +### Матрица принятия решений + +| Сценарий | Рекомендуемый кодек | Параметры | Ожидаемый результат | +|----------|---------------------|-----------|---------------------| +| Максимальное качество | AV1 CPU | CRF 30-35 | Лучшее качество, малый размер | +| Быстрое кодирование | AV1 GPU | CQ 38-42 | Быстро, хорошее качество | +| Совместимость | H.264 CPU | CRF 23-28 | Работает везде | +| Веб-стриминг | VP9 | CRF 30-35 | Хороший баланс | +| Архивирование | AV1 CPU | CRF 25-30 | Лучшее качество | + +### Соответствие метрик и визуального качества + +| PSNR | SSIM | Визуальная оценка | +|------|------|-------------------| +| > 45 dB | > 0.99 | Практически неотличимо от оригинала | +| 40-45 dB | 0.98-0.99 | Отличное качество, артефакты незаметны | +| 35-40 dB | 0.95-0.98 | Хорошее качество, артефакты видны при внимательном просмотре | +| 30-35 dB | 0.90-0.95 | Приемлемое качество, видимые артефакты | +| < 30 dB | < 0.90 | Плохое качество, явные артефакты | + +### Факторы, влияющие на результаты + +1. **Контент видео:** + - Статичные сцены сжимаются лучше + - Быстрое движение требует больше битрейта + - Детализированные текстуры сложнее сжать + - Темные сцены могут показывать бандинг + +2. **Разрешение:** + - Высокие разрешения требуют более высокого битрейта + - При одинаковом CRF, 4K будет весить больше чем 1080p + +3. **Частота кадров:** + - 60 FPS требует ~1.5-2x больше битрейта чем 30 FPS + - Высокий FPS важнее для игрового контента + +4. **Цветовое пространство:** + - HDR (10-bit) требует ~20-30% больше битрейта + - Широкий цветовой охват увеличивает размер + +--- + +## Рекомендации + +### Для продакшена + +**Adaptive Streaming (DASH/HLS):** +```bash +# Используйте несколько профилей качества +# 360p, 480p, 720p, 1080p, 1440p, 2160p + +# Dual codec для максимальной совместимости: +# - AV1 для современных браузеров +# - H.264 для старых устройств (iOS < 14) + +# Пример: create-vod уже реализует это +create-vod input.mp4 output/ -c dual -f both -r 360,720,1080 +``` + +### Для архивирования + +```bash +# Используйте AV1 CPU с низким CRF +ffmpeg -i input.mp4 \ + -c:v libsvtav1 -crf 25 -preset 4 \ + -c:a libopus -b:a 192k \ + archive.mp4 +``` + +### Для быстрой обработки + +```bash +# Используйте GPU кодирование +ffmpeg -i input.mp4 \ + -c:v av1_nvenc -preset p7 -cq 35 \ + -c:a aac -b:a 128k \ + quick_encode.mp4 +``` + +### Подбор оптимального CRF/CQ + +**Метод бинарного поиска:** + +1. Начните с среднего значения (CRF 28 для H.264, 32 для VP9, 35 для AV1) +2. Закодируйте короткий фрагмент (30-60 сек) +3. Проверьте качество (PSNR > 45, SSIM > 0.99 для отличного качества) +4. Если качество избыточное - увеличьте CRF на 2-3 +5. Если качество недостаточное - уменьшите CRF на 2-3 +6. Повторяйте до достижения баланса + +**Быстрый тест:** + +```bash +# Извлечь 60 секунд с 30-й секунды +ffmpeg -ss 30 -i input.mp4 -t 60 -c copy sample.mp4 + +# Протестировать разные CRF +for crf in 30 32 35 38 40; do + ffmpeg -i sample.mp4 -c:v libsvtav1 -crf $crf -preset 8 -c:a copy test_crf${crf}.mp4 + + # Измерить качество + ffmpeg -i test_crf${crf}.mp4 -i sample.mp4 -lavfi "[0:v][1:v]psnr" -f null - 2>&1 | grep "PSNR" + + # Размер файла + ls -lh test_crf${crf}.mp4 +done +``` + +--- + +## Дополнительные ресурсы + +### Инструменты + +- **FFmpeg** - https://ffmpeg.org/ +- **ffprobe** - анализ медиа файлов +- **MediaInfo** - GUI инструмент для анализа +- **Handbrake** - GUI для кодирования +- **ab-av1** - инструмент для подбора оптимальных параметров AV1 + +### Документация + +- [FFmpeg Encoding Guide](https://trac.ffmpeg.org/wiki/Encode) +- [x264 Settings](https://trac.ffmpeg.org/wiki/Encode/H.264) +- [VP9 Encoding Guide](https://trac.ffmpeg.org/wiki/Encode/VP9) +- [SVT-AV1 Documentation](https://gitlab.com/AOMediaCodec/SVT-AV1) +- [NVIDIA Video Codec SDK](https://developer.nvidia.com/video-codec-sdk) + +### Научные статьи + +- "The Netflix Tech Blog: Per-Title Encode Optimization" +- "VMAF: The Journey Continues" - Netflix +- "AV1 Performance vs x265 and libvpx" - Facebook Engineering + +--- + +## Changelog + +**2025-11-12** - Создан документ на основе реальных тестов +- Протестированы кодеки: VP9, AV1 (CPU/GPU), H.264 (CPU/GPU) +- Добавлены все команды для тестирования +- Добавлены результаты сравнения на видео 1920×1080, 135 сек + diff --git a/package.json b/package.json new file mode 100644 index 0000000..6cb4ea0 --- /dev/null +++ b/package.json @@ -0,0 +1,64 @@ +{ + "name": "@gromlab/create-vod", + "version": "0.1.3", + "description": "Fast DASH video converter with NVENC acceleration and thumbnail sprites", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "bin": { + "create-vod": "./bin/cli.js" + }, + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "files": [ + "dist", + "bin", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "npm run build:lib && npm run build:cli", + "build:lib": "bun build src/index.ts --outdir dist --target node && tsc --emitDeclarationOnly", + "build:cli": "bun build src/cli.ts --outfile bin/cli.js --target node --minify", + "prepublishOnly": "npm run build", + "create-vod": "bun run src/cli.ts", + "test": "bun run src/cli.ts" + }, + "keywords": [ + "dash", + "video", + "converter", + "ffmpeg", + "nvenc", + "streaming", + "cli", + "video-processing", + "adaptive-streaming", + "thumbnails" + ], + "author": "grom13", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://gromlab.ru/gromov/create-vod.git" + }, + "bugs": { + "url": "https://gromlab.ru/gromov/create-vod/issues" + }, + "homepage": "https://gromlab.ru/gromov/create-vod#readme", + "engines": { + "node": ">=18.0.0" + }, + "devDependencies": { + "@types/bun": "^1.3.2", + "@types/cli-progress": "^3.11.6", + "typescript": "^5.3.3" + }, + "dependencies": { + "cli-progress": "^3.12.0" + } +} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..5cbfcbd --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,372 @@ +#!/usr/bin/env node + +/** + * DASH Video Converter CLI + * + * Usage: + * create-vod [output-dir] [-r resolutions] [-p poster-timecode] + * + * Example: + * create-vod ./video.mp4 ./output -r 720,1080 + */ + +import { convertToDash, checkFFmpeg, checkMP4Box, getVideoMetadata, detectHardwareEncoders } from './index'; +import cliProgress from 'cli-progress'; +import { statSync } from 'node:fs'; +import { basename, extname } from 'node:path'; +import type { CodecType, StreamingFormat, QualitySettings, HardwareAccelerationOption } 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 codecType: CodecType = 'dual'; // Default to dual codec +let formatType: StreamingFormat = 'both'; // Default to both formats (DASH + HLS) +const positionalArgs: string[] = []; + +// Quality settings +let h264CQ: number | undefined; +let h264CRF: number | undefined; +let av1CQ: number | undefined; +let av1CRF: number | undefined; +let accelerator: HardwareAccelerationOption | undefined; + +// First pass: extract flags and their values +for (let i = 0; i < args.length; i++) { + if (args[i] === '-r' || args[i] === '--resolutions') { + // Collect all arguments after -r until next flag or end + const profilesArgs: string[] = []; + for (let j = i + 1; j < args.length; j++) { + // Stop if we hit another flag (starts with -) + if (args[j].startsWith('-')) { + break; + } + profilesArgs.push(args[j]); + i = j; // Skip these args in main loop + } + + // Parse profiles + const joinedArgs = profilesArgs.join(','); + customProfiles = joinedArgs + .split(/[,\s]+/) // Split by comma or whitespace + .map(s => s.trim()) + .filter(s => s.length > 0); + } else if (args[i] === '-p' || args[i] === '--poster') { + 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' || codec === 'dual') { + codecType = codec; + } else { + console.error(`❌ Invalid codec: ${codec}. Valid options: av1, h264, dual`); + 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' || format === 'both') { + formatType = format; + } else { + console.error(`❌ Invalid format: ${format}. Valid options: dash, hls, both`); + process.exit(1); + } + i++; // Skip next arg + } else if (args[i] === '--h264-cq') { + h264CQ = parseInt(args[i + 1]); + if (isNaN(h264CQ) || h264CQ < 0 || h264CQ > 51) { + console.error(`❌ Invalid H.264 CQ value: ${args[i + 1]}. Must be 0-51`); + process.exit(1); + } + i++; // Skip next arg + } else if (args[i] === '--h264-crf') { + h264CRF = parseInt(args[i + 1]); + if (isNaN(h264CRF) || h264CRF < 0 || h264CRF > 51) { + console.error(`❌ Invalid H.264 CRF value: ${args[i + 1]}. Must be 0-51`); + process.exit(1); + } + i++; // Skip next arg + } else if (args[i] === '--av1-cq') { + av1CQ = parseInt(args[i + 1]); + if (isNaN(av1CQ) || av1CQ < 0 || av1CQ > 51) { + console.error(`❌ Invalid AV1 CQ value: ${args[i + 1]}. Must be 0-51`); + process.exit(1); + } + i++; // Skip next arg + } else if (args[i] === '--av1-crf') { + av1CRF = parseInt(args[i + 1]); + if (isNaN(av1CRF) || av1CRF < 0 || av1CRF > 63) { + console.error(`❌ Invalid AV1 CRF value: ${args[i + 1]}. Must be 0-63`); + process.exit(1); + } + i++; // Skip next arg + } else if (args[i] === '--accel' || args[i] === '--hardware') { + const acc = args[i + 1]; + const allowed: HardwareAccelerationOption[] = ['auto', 'nvenc', 'qsv', 'amf', 'cpu']; + if (!allowed.includes(acc as HardwareAccelerationOption)) { + console.error(`❌ Invalid accelerator: ${acc}. Valid: auto, nvenc, qsv, amf, cpu`); + process.exit(1); + } + accelerator = acc as HardwareAccelerationOption; + i++; + } else if (!args[i].startsWith('-')) { + // Positional argument + positionalArgs.push(args[i]); + } +} + +// Extract positional arguments +const input = positionalArgs[0]; +const outputDir = positionalArgs[1] || '.'; // Текущая директория по умолчанию + +if (!input) { + console.error('❌ Usage: create-vod [output-dir] [options]'); + console.error('\nOptions:'); + console.error(' -r, --resolutions Video resolutions (e.g., 360,480,720 or 720@60,1080@60)'); + console.error(' -c, --codec Video codec: av1, h264, or dual (default: dual)'); + console.error(' -f, --format Streaming format: dash, hls, or both (default: both)'); + console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)'); + console.error(' --accel Hardware accelerator: auto|nvenc|qsv|amf|cpu (default: auto)'); + console.error('\nQuality Options (override defaults):'); + console.error(' --h264-cq H.264 GPU CQ value (0-51, lower = better, default: auto)'); + console.error(' --h264-crf H.264 CPU CRF value (0-51, lower = better, default: auto)'); + console.error(' --av1-cq AV1 GPU CQ value (0-51, lower = better, default: auto)'); + console.error(' --av1-crf AV1 CPU CRF value (0-63, lower = better, default: auto)'); + console.error('\nExamples:'); + console.error(' create-vod video.mp4'); + console.error(' create-vod video.mp4 ./output'); + console.error(' create-vod video.mp4 -r 360,480,720'); + console.error(' create-vod video.mp4 -c av1 --av1-cq 40'); + console.error(' create-vod video.mp4 -c dual --h264-cq 30 --av1-cq 39'); + console.error(' create-vod video.mp4 -f hls'); + console.error(' create-vod video.mp4 -c dual -f both'); + console.error(' create-vod video.mp4 -r 720@60,1080@60,2160@60 -c av1 -f dash'); + console.error(' create-vod video.mp4 -p 00:00:05'); + console.error(' create-vod video.mp4 ./output -r 720,1080 -c dual -f both -p 10 --h264-cq 28 --av1-cq 37'); + process.exit(1); +} + +console.log('🔍 Checking system...\n'); + +const hasFFmpeg = await checkFFmpeg(); +const hasMP4Box = await checkMP4Box(); +const hwEncoders = await detectHardwareEncoders(); + +const accelPriority: Record = { + nvenc: 100, + qsv: 90, + amf: 80, + vaapi: 70, + videotoolbox: 65, + v4l2: 60 +}; + +const bestAccel = hwEncoders + .slice() + .sort((a, b) => (accelPriority[b.accelerator] || 0) - (accelPriority[a.accelerator] || 0))[0]; + +console.log(`FFmpeg: ${hasFFmpeg ? '✅' : '❌'}`); +console.log(`MP4Box: ${hasMP4Box ? '✅' : '❌'}`); +const accelList = Array.from(new Set(hwEncoders.map(e => e.accelerator.toUpperCase()))); +const bestAccelName = bestAccel ? bestAccel.accelerator.toUpperCase() : undefined; +const accelRest = accelList.filter(name => name !== bestAccelName); +const accelLabel = bestAccelName + ? `✅ ${bestAccelName}${accelRest.length > 0 ? ` (${accelRest.join(', ')})` : ''}` + : '❌'; +console.log(`Hardware: ${accelLabel}`); +console.log(''); + +if (!hasFFmpeg) { + console.error('❌ FFmpeg not found. Please install FFmpeg first.'); + process.exit(1); +} + +if (!hasMP4Box) { + console.error('❌ MP4Box not found. Please install: sudo pacman -S gpac'); + process.exit(1); +} + +// Validate codec selection +const hasAv1Hardware = hwEncoders.some(item => item.av1Encoder); + +if ((codecType === 'av1' || codecType === 'dual') && !hasAv1Hardware) { + console.error(`⚠️ Warning: AV1 encoding 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`); +} + +// Validate HLS requires H.264 +if ((formatType === 'hls' || formatType === 'both') && codecType === 'av1') { + console.error(`❌ Error: HLS format requires H.264 codec for Safari/iOS compatibility.`); + console.error(` Please use --codec h264 or --codec dual with --format hls\n`); + process.exit(1); +} + +// Get video metadata and file size +console.log('📊 Analyzing video...\n'); +const metadata = await getVideoMetadata(input); +const fileStats = statSync(input); +const fileSizeMB = (fileStats.size / (1024 * 1024)).toFixed(2); + +console.log('📹 Video Information:'); +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(` Codec: ${metadata.codec}`); +if (metadata.videoBitrate) { + console.log(` Video Bitrate: ${(metadata.videoBitrate / 1000).toFixed(2)} Mbps`); +} +if (metadata.audioBitrate) { + console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`); +} + +// Pre-calc profiles for display (match internal selection logic) +let displayProfiles: string[] = []; +if (customProfiles && customProfiles.length > 0) { + const profileResult = createProfilesFromStrings( + customProfiles, + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); + + if (profileResult.errors.length > 0) { + console.error('\n❌ Profile errors:'); + profileResult.errors.forEach(err => console.error(` - ${err}`)); + process.exit(1); + } + + if (profileResult.warnings.length > 0) { + console.warn('\n⚠️ Profile warnings:'); + profileResult.warnings.forEach(warn => console.warn(` - ${warn}`)); + } + + displayProfiles = profileResult.profiles.map(p => p.name); +} else { + const autoProfiles = selectProfiles( + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); + displayProfiles = autoProfiles.map(p => p.name); +} + +const manifestDesc = + formatType === 'both' ? 'DASH (manifest.mpd), HLS (master.m3u8)' : + formatType === 'dash' ? 'DASH (manifest.mpd)' : 'HLS (master.m3u8)'; + +const thumbnailsPlanned = true; +const posterPlanned = posterTimecode || '00:00:00'; + +console.log('\n📦 Parameters:'); +console.log(` Input: ${input}`); +console.log(` Output: ${outputDir}`); +console.log(` Codec: ${codecType}${codecType === 'dual' ? ' (AV1 + H.264)' : ''}`); +console.log(` Profiles: ${displayProfiles.join(', ')}`); +console.log(` Manifests: ${manifestDesc}`); +console.log(` Poster: ${posterPlanned} (will be generated)`); +console.log(` Thumbnails: ${thumbnailsPlanned ? 'yes (with VTT)' : 'no'}`); +console.log(` Accelerator: ${bestAccel ? bestAccel.accelerator.toUpperCase() : 'CPU'}`); + +// Build quality settings if any are specified +let quality: QualitySettings | undefined; +if (h264CQ !== undefined || h264CRF !== undefined || av1CQ !== undefined || av1CRF !== undefined) { + quality = {}; + + if (h264CQ !== undefined || h264CRF !== undefined) { + quality.h264 = {}; + if (h264CQ !== undefined) quality.h264.cq = h264CQ; + if (h264CRF !== undefined) quality.h264.crf = h264CRF; + console.log(`🎚️ H.264 Quality: ${h264CQ !== undefined ? `CQ ${h264CQ}` : ''}${h264CRF !== undefined ? ` CRF ${h264CRF}` : ''}`); + } + + if (av1CQ !== undefined || av1CRF !== undefined) { + quality.av1 = {}; + if (av1CQ !== undefined) quality.av1.cq = av1CQ; + if (av1CRF !== undefined) quality.av1.crf = av1CRF; + console.log(`🎚️ AV1 Quality: ${av1CQ !== undefined ? `CQ ${av1CQ}` : ''}${av1CRF !== undefined ? ` CRF ${av1CRF}` : ''}`); + } +} + +console.log('\n🚀 Starting conversion...\n'); + +// Create multibar container +const multibar = new cliProgress.MultiBar({ + format: '{stage} | {bar} | {percentage}% | {name}', + barCompleteChar: '█', + barIncompleteChar: '░', + hideCursor: true, + clearOnComplete: false, + stopOnComplete: true +}, cliProgress.Presets.shades_classic); + +// Track progress bars for each profile +const bars: Record = {}; +let overallBar: any = null; + +try { + const result = await convertToDash({ + input, + outputDir, + customProfiles, + posterTimecode, + codec: codecType, + format: formatType, + segmentDuration: 2, + hardwareAccelerator: accelerator, + quality, + generateThumbnails: true, + generatePoster: true, + parallel: true, + onProgress: (progress) => { + const stageName = progress.stage === 'encoding' ? 'Encoding' : + progress.stage === 'thumbnails' ? 'Thumbnails' : + progress.stage === 'manifest' ? 'Manifest' : + progress.stage === 'analyzing' ? 'Analyzing' : 'Complete'; + + // Stage 1: Encoding - show individual profile bars + if (progress.stage === 'encoding' && progress.currentProfile) { + if (!bars[progress.currentProfile]) { + bars[progress.currentProfile] = multibar.create(100, 0, { + stage: 'Encode', + name: progress.currentProfile + }); + } + // Use profilePercent (0-100) for individual bars, not overall percent + const profileProgress = progress.profilePercent ?? progress.percent; + bars[progress.currentProfile].update(profileProgress, { + stage: 'Encode', + name: progress.currentProfile + }); + } + + // Overall progress bar + if (!overallBar) { + overallBar = multibar.create(100, 0, { + stage: stageName, + name: 'Overall' + }); + } + + overallBar.update(progress.percent, { + stage: stageName, + name: progress.message || 'Overall' + }); + } + }); + + multibar.stop(); + + console.log('\n✅ Conversion completed successfully!\n'); + +} catch (error) { + multibar.stop(); + console.error('\n\n❌ Error during conversion:'); + console.error(error); + process.exit(1); +} diff --git a/src/config/profiles.ts b/src/config/profiles.ts new file mode 100644 index 0000000..3190df1 --- /dev/null +++ b/src/config/profiles.ts @@ -0,0 +1,274 @@ +import type { VideoProfile } from '../types'; + +/** + * Get optimal BPP (Bits Per Pixel) based on resolution + * Lower resolutions need higher BPP for good quality + * Higher resolutions can use lower BPP due to more pixels + */ +function getBPP(width: number, height: number): number { + const pixels = width * height; + + if (pixels <= 640 * 360) return 0.08; // 360p - higher quality needed + if (pixels <= 854 * 480) return 0.075; // 480p + if (pixels <= 1280 * 720) return 0.07; // 720p + if (pixels <= 1920 * 1080) return 0.065; // 1080p + if (pixels <= 2560 * 1440) return 0.06; // 1440p (2K) + return 0.055; // 4K - lower BPP but still quality +} + +/** + * Calculate optimal video bitrate based on resolution and FPS + * Formula: width × height × fps × bpp + */ +function calculateBitrate( + width: number, + height: number, + fps: number = 30, + maxBitrate?: number +): string { + const bpp = getBPP(width, height); + let bitrate = Math.round((width * height * fps * bpp) / 1000); + + // Don't exceed source bitrate (no point in upscaling quality) + if (maxBitrate && bitrate > maxBitrate) { + bitrate = maxBitrate; + } + + return `${bitrate}k`; +} + +/** + * Default video quality profiles for 30 FPS + */ +export const DEFAULT_PROFILES: VideoProfile[] = [ + { + name: '360p', + width: 640, + height: 360, + videoBitrate: calculateBitrate(640, 360, 30), + audioBitrate: '192k' + }, + { + name: '480p', + width: 854, + height: 480, + videoBitrate: calculateBitrate(854, 480, 30), + audioBitrate: '192k' + }, + { + name: '720p', + width: 1280, + height: 720, + videoBitrate: calculateBitrate(1280, 720, 30), + audioBitrate: '192k' + }, + { + name: '1080p', + width: 1920, + height: 1080, + videoBitrate: calculateBitrate(1920, 1080, 30), + audioBitrate: '256k' + }, + { + name: '1440p', + width: 2560, + height: 1440, + videoBitrate: calculateBitrate(2560, 1440, 30), + audioBitrate: '256k' + }, + { + name: '2160p', + width: 3840, + height: 2160, + videoBitrate: calculateBitrate(3840, 2160, 30), + audioBitrate: '256k' + } +]; + +/** + * Select appropriate profiles based on input video resolution + * Only creates profiles that are equal to or smaller than input resolution + * Always generates 30 FPS profiles by default + * For high FPS (>30), user must explicitly specify in customProfiles + */ +export function selectProfiles( + inputWidth: number, + inputHeight: number, + inputFPS: number = 30, + sourceBitrate?: number +): VideoProfile[] { + const profiles: VideoProfile[] = []; + + // Standard 30 FPS profiles (always created) + const baseProfiles = DEFAULT_PROFILES.filter(profile => { + return profile.width <= inputWidth && profile.height <= inputHeight; + }); + + // Add standard 30fps profiles with bitrate limit + for (const profile of baseProfiles) { + profiles.push({ + ...profile, + videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate), + fps: 30 + }); + } + + return profiles; +} + +/** + * Create high FPS profile variant + * Used for creating 60fps, 90fps, 120fps versions + */ +export function createHighFPSProfile( + baseProfile: VideoProfile, + fps: number, + maxBitrate?: number +): VideoProfile { + return { + ...baseProfile, + name: `${baseProfile.name}-${fps}`, + videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, fps, maxBitrate), + fps + }; +} + +/** + * Parse profile string into resolution and FPS + * Examples: + * '360' => { resolution: '360p', fps: 30 } + * '720@60' => { resolution: '720p', fps: 60 } + * '1080-60' => { resolution: '1080p', fps: 60 } + * '360p', '720p@60' also supported (with 'p') + */ +function parseProfileString(profileStr: string): { resolution: string; fps: number } | null { + const trimmed = profileStr.trim(); + + // Match patterns: 360, 720@60, 1080-60, 360p, 720p@60, 1080p-60 + const match = trimmed.match(/^(\d+)p?(?:[@-](\d+))?$/i); + + if (!match) { + return null; + } + + const resolution = match[1] + 'p'; // Always add 'p' + const fps = match[2] ? parseInt(match[2]) : 30; + + return { resolution, fps }; +} + +/** + * Get profile by resolution name and FPS + * Returns VideoProfile or null if not found + */ +export function getProfileByName( + resolution: string, + fps: number = 30, + maxBitrate?: number +): VideoProfile | null { + const baseProfile = DEFAULT_PROFILES.find(p => p.name === resolution); + + if (!baseProfile) { + return null; + } + + if (fps === 30) { + return { + ...baseProfile, + videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate), + fps: 30 + }; + } + + return createHighFPSProfile(baseProfile, fps, maxBitrate); +} + +/** + * Validate if profile can be created from source + * Returns object with error, warning, and adjusted FPS + */ +export function validateProfile( + profileStr: string, + sourceWidth: number, + sourceHeight: number, + sourceFPS: number +): { error?: string; warning?: string; adjustedFps?: number } { + const parsed = parseProfileString(profileStr); + + if (!parsed) { + return { error: `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60` }; + } + + const profile = getProfileByName(parsed.resolution, parsed.fps); + + if (!profile) { + return { error: `Unknown resolution: ${parsed.resolution}. Available: 360, 480, 720, 1080, 1440, 2160` }; + } + + // 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})` }; + } + + // Check if requested FPS exceeds source FPS + const MAX_FPS = 120; + let adjustedFps = parsed.fps; + let warning: string | undefined; + + if (parsed.fps > sourceFPS) { + // Cap to source FPS (but not more than MAX_FPS) + adjustedFps = Math.min(sourceFPS, MAX_FPS); + warning = `Requested ${parsed.fps} FPS in ${profileStr}, but source is ${sourceFPS} FPS. Using ${adjustedFps} FPS instead`; + } else if (parsed.fps > MAX_FPS) { + // Cap to MAX_FPS + adjustedFps = MAX_FPS; + warning = `Requested ${parsed.fps} FPS in ${profileStr} exceeds maximum ${MAX_FPS} FPS. Using ${adjustedFps} FPS instead`; + } + + return warning ? { warning, adjustedFps } : {}; // Valid +} + +/** + * Create profiles from custom string list + * Example: ['360p', '720p@60', '1080p'] => VideoProfile[] + */ +export function createProfilesFromStrings( + profileStrings: string[], + sourceWidth: number, + sourceHeight: number, + sourceFPS: number, + sourceBitrate?: number +): { profiles: VideoProfile[]; errors: string[]; warnings: string[] } { + const profiles: VideoProfile[] = []; + const errors: string[] = []; + const warnings: string[] = []; + + for (const profileStr of profileStrings) { + // Validate + const result = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS); + + if (result.error) { + errors.push(result.error); + continue; + } + + if (result.warning) { + warnings.push(result.warning); + } + + // Parse and create + const parsed = parseProfileString(profileStr); + if (!parsed) continue; // Already validated, shouldn't happen + + // Use adjusted FPS if available (when requested FPS > source FPS) + const targetFps = result.adjustedFps !== undefined ? result.adjustedFps : parsed.fps; + + const profile = getProfileByName(parsed.resolution, targetFps, sourceBitrate); + if (profile) { + profiles.push(profile); + } + } + + return { profiles, errors, warnings }; +} + diff --git a/src/core/converter.ts b/src/core/converter.ts new file mode 100644 index 0000000..e973fab --- /dev/null +++ b/src/core/converter.ts @@ -0,0 +1,527 @@ +import { join, basename, extname } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import { rm } from 'node:fs/promises'; +import type { + DashConvertOptions, + DashConvertResult, + VideoProfile, + ThumbnailConfig, + ConversionProgress, + CodecType, + StreamingFormat, + HardwareAccelerationOption, + HardwareAccelerator, + HardwareEncoderInfo +} from '../types'; +import { + checkFFmpeg, + checkMP4Box, + getVideoMetadata, + ensureDir, + setLogFile, + detectHardwareEncoders +} from '../utils'; +import { selectProfiles, createProfilesFromStrings } from '../config/profiles'; +import { generateThumbnailSprite, generatePoster } from './thumbnails'; +import { encodeProfilesToMP4 } from './encoding'; +import { packageToFormats } from './packaging'; + +/** + * Convert video to DASH format with NVENC acceleration + * Two-stage approach: FFmpeg encoding → MP4Box packaging + */ +export async function convertToDash( + options: DashConvertOptions +): Promise { + const { + input, + outputDir, + segmentDuration = 2, + profiles: userProfiles, + customProfiles, + codec = 'dual', + format = 'both', + useNvenc, + hardwareAccelerator, + quality, + generateThumbnails = true, + thumbnailConfig = {}, + generatePoster: shouldGeneratePoster = true, + posterTimecode = '00:00:00', + parallel = true, + onProgress + } = options; + + // Create unique temp directory + const tempDir = join('/tmp', `dash-converter-${randomUUID()}`); + await ensureDir(tempDir); + + // Create video output directory and initialize logging + const videoName = basename(input, extname(input)); + const videoOutputDir = join(outputDir, videoName); + await ensureDir(videoOutputDir); + + // Initialize log file + const logFile = join(videoOutputDir, 'conversion.log'); + setLogFile(logFile); + + // Write log header + const { writeFile } = await import('node:fs/promises'); + const header = `=========================================== +DASH Conversion Log +Started: ${new Date().toISOString()} +Input: ${input} +Output: ${videoOutputDir} +Codec: ${codec} +Format: ${format} +===========================================\n`; + await writeFile(logFile, header, 'utf-8'); + + try { + return await convertToDashInternal( + input, + outputDir, + tempDir, + segmentDuration, + userProfiles, + customProfiles, + codec, + format, + useNvenc, + hardwareAccelerator, + quality, + generateThumbnails, + thumbnailConfig, + shouldGeneratePoster, + posterTimecode, + parallel, + onProgress + ); + } finally { + // Write completion to log + const { appendFile } = await import('node:fs/promises'); + try { + await appendFile(logFile, `\nCompleted: ${new Date().toISOString()}\n`, 'utf-8'); + } catch (err) { + // Ignore log write errors + } + + // Cleanup temp directory + try { + await rm(tempDir, { recursive: true, force: true }); + } catch (err) { + console.warn(`Warning: Failed to cleanup temp directory: ${tempDir}`); + } + } +} + +/** + * Internal conversion logic + */ +async function convertToDashInternal( + input: string, + outputDir: string, + tempDir: string, + segmentDuration: number, + userProfiles: VideoProfile[] | undefined, + customProfiles: string[] | undefined, + codec: CodecType, + format: StreamingFormat, + useNvenc: boolean | undefined, + hardwareAccelerator: HardwareAccelerationOption | undefined, + quality: DashConvertOptions['quality'], + generateThumbnails: boolean, + thumbnailConfig: ThumbnailConfig, + generatePosterFlag: boolean, + posterTimecode: string, + parallel: boolean, + onProgress?: (progress: ConversionProgress) => void +): Promise { + + // Validate dependencies + if (!await checkFFmpeg()) { + throw new Error('FFmpeg is not installed or not in PATH'); + } + + if (!await checkMP4Box()) { + throw new Error('MP4Box is not installed or not in PATH. Install gpac package.'); + } + + // Report progress + const reportProgress = (stage: ConversionProgress['stage'], percent: number, message?: string, currentProfile?: string) => { + if (onProgress) { + onProgress({ stage, percent, message, currentProfile }); + } + }; + + reportProgress('analyzing', 0, 'Analyzing input video...'); + + // Get video metadata + const metadata = await getVideoMetadata(input); + const hasAudio = metadata.hasAudio; + + // Determine hardware accelerator (auto by default) + const preferredAccelerator: HardwareAccelerationOption = + hardwareAccelerator && hardwareAccelerator !== 'auto' + ? hardwareAccelerator + : useNvenc === true + ? 'nvenc' + : useNvenc === false + ? 'cpu' + : 'auto'; + + const hardwareEncoders = await detectHardwareEncoders(); + + const { selected, h264Encoder, av1Encoder, warnings: accelWarnings } = selectHardwareEncoders( + hardwareEncoders, + preferredAccelerator, + codec + ); + + if (accelWarnings.length > 0) { + for (const warn of accelWarnings) { + console.warn(`⚠️ ${warn}`); + } + } + + // Select profiles + let profiles: VideoProfile[]; + + if (customProfiles && customProfiles.length > 0) { + // User specified custom profiles via CLI + const result = createProfilesFromStrings( + customProfiles, + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); + + // Show errors if any + if (result.errors.length > 0) { + console.warn('\n❌ Profile errors:'); + for (const error of result.errors) { + console.warn(` - ${error}`); + } + console.warn(''); + } + + // Show warnings if any + if (result.warnings.length > 0) { + console.warn('\n⚠️ Profile warnings:'); + for (const warning of result.warnings) { + console.warn(` - ${warning}`); + } + console.warn(''); + } + + profiles = result.profiles; + + if (profiles.length === 0) { + throw new Error('No valid profiles found in custom list. Check errors above.'); + } + } else if (userProfiles) { + // Programmatic API usage + profiles = userProfiles; + } else { + // Default: auto-select based on source + profiles = selectProfiles( + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); + } + + if (profiles.length === 0) { + throw new Error('No suitable profiles found for input video resolution'); + } + + // Create video name directory + const inputBasename = basename(input, extname(input)); + const videoOutputDir = join(outputDir, inputBasename); + + // Clean up previous conversion if exists + try { + await rm(videoOutputDir, { recursive: true, force: true }); + } catch (err) { + // Directory might not exist, that's fine + } + + await ensureDir(videoOutputDir); + + // Determine which codecs to use based on codec parameter + const codecs: Array<{ type: 'h264' | 'av1'; codec: string; preset: string }> = []; + + if (codec === 'h264' || codec === 'dual') { + const h264Codec = h264Encoder || 'libx264'; + const h264Preset = resolvePresetForEncoder(h264Codec, 'h264'); + codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset }); + } + + if (codec === 'av1' || codec === 'dual') { + const av1Codec = av1Encoder || 'libsvtav1'; + const av1Preset = resolvePresetForEncoder(av1Codec, 'av1'); + codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset }); + } + + const codecNames = codecs.map(c => c.type.toUpperCase()).join(' + '); + const accelLabel = selected === 'cpu' ? 'CPU' : selected.toUpperCase(); + reportProgress('analyzing', 20, `Using ${codecNames} encoding (${accelLabel})`, undefined); + + const maxConcurrent = selected === 'cpu' ? 2 : 3; + + // STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work) + const codecMP4Paths = new Map<'h264' | 'av1', Map>(); + + for (let codecIndex = 0; codecIndex < codecs.length; codecIndex++) { + const { type, codec: videoCodec, preset: codecPreset } = codecs[codecIndex]; + const codecProgress = codecIndex / codecs.length; + const codecProgressRange = 1 / codecs.length; + + reportProgress('encoding', 25 + codecProgress * 40, `Stage 1: Encoding ${type.toUpperCase()} (${profiles.length} profiles)...`); + + // Get quality settings for this codec + const codecQuality = type === 'h264' ? quality?.h264 : quality?.av1; + + const tempMP4Paths = await encodeProfilesToMP4( + input, + tempDir, + profiles, + videoCodec, + codecPreset, + metadata.duration, + segmentDuration, + metadata.audioBitrate, + parallel, + maxConcurrent, + type, // Pass codec type to differentiate output files + codecQuality, // Pass quality settings (CQ/CRF) + undefined, // optimizations - for future use + (profileName, percent) => { + const profileIndex = profiles.findIndex(p => p.name === profileName); + const baseProgress = 25 + codecProgress * 40; + const profileProgress = (percent / 100) * (40 * codecProgressRange / profiles.length); + reportProgress('encoding', baseProgress + profileProgress, `Encoding ${type.toUpperCase()} ${profileName}...`, `${type}-${profileName}`); + + // Also report individual profile progress + if (onProgress) { + onProgress({ + stage: 'encoding', + percent: baseProgress + profileProgress, + currentProfile: `${type}-${profileName}`, + profilePercent: percent, + message: `Encoding ${type.toUpperCase()} ${profileName}...` + }); + } + } + ); + + codecMP4Paths.set(type, tempMP4Paths); + } + + reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded'); + + // STAGE 2: Package to segments and manifests (unified, no duplication) + reportProgress('encoding', 70, `Stage 2: Creating segments and manifests...`); + + const { manifestPath, hlsManifestPath } = await packageToFormats( + codecMP4Paths, + videoOutputDir, + profiles, + segmentDuration, + codec, + format, + hasAudio + ); + + // Collect all video paths from all codecs + const videoPaths: string[] = []; + for (const mp4Paths of codecMP4Paths.values()) { + videoPaths.push(...Array.from(mp4Paths.values())); + } + + reportProgress('encoding', 80, 'Stage 2 complete: All formats packaged'); + + // Generate thumbnails + let thumbnailSpritePath: string | undefined; + let thumbnailVttPath: string | undefined; + + if (generateThumbnails) { + reportProgress('thumbnails', 80, 'Generating thumbnail sprites...'); + + const thumbConfig: Required = { + width: thumbnailConfig.width || 160, + height: thumbnailConfig.height || 90, + interval: thumbnailConfig.interval || 1, // 1 секунда по умолчанию + columns: thumbnailConfig.columns || 10 + }; + + const thumbResult = await generateThumbnailSprite( + input, + videoOutputDir, + metadata.duration, + thumbConfig + ); + + thumbnailSpritePath = thumbResult.spritePath; + thumbnailVttPath = thumbResult.vttPath; + + reportProgress('thumbnails', 90, 'Thumbnails generated'); + } + + // Generate poster + let posterPath: string | undefined; + + if (generatePosterFlag) { + reportProgress('thumbnails', 92, 'Generating poster image...'); + + posterPath = await generatePoster( + input, + videoOutputDir, + posterTimecode + ); + + reportProgress('thumbnails', 95, 'Poster generated'); + } + + // Finalize + reportProgress('manifest', 95, 'Finalizing...'); + + // Note: manifestPath/hlsManifestPath are already created by MP4Box in packageToDash/packageToHLS + // No need for separate generateManifest function + + reportProgress('complete', 100, 'Conversion complete!'); + + return { + manifestPath, + hlsManifestPath, + videoPaths, + thumbnailSpritePath, + thumbnailVttPath, + posterPath, + duration: metadata.duration, + profiles, + usedNvenc: codecs.some(c => c.codec.includes('nvenc')), + selectedAccelerator: selected, + codecType: codec, + format + }; +} + +const ACCEL_PRIORITY: Record = { + nvenc: 100, + qsv: 90, + amf: 80, + vaapi: 70, + videotoolbox: 65, + v4l2: 60, + cpu: 1 +}; + +function selectHardwareEncoders( + available: HardwareEncoderInfo[], + preferred: HardwareAccelerationOption, + codec: CodecType +): { + selected: HardwareAccelerator; + h264Encoder?: string; + av1Encoder?: string; + warnings: string[]; +} { + const needsH264 = codec === 'h264' || codec === 'dual'; + const needsAV1 = codec === 'av1' || codec === 'dual'; + const warnings: string[] = []; + + const supportedForAuto = new Set(['nvenc', 'qsv', 'amf']); + const relevant = available.filter(info => + (needsH264 && info.h264Encoder) || (needsAV1 && info.av1Encoder) + ); + const autoRelevant = relevant.filter(info => supportedForAuto.has(info.accelerator)); + + const pickByAccel = (acc: HardwareAccelerator) => + relevant.find(item => item.accelerator === acc); + + let base: HardwareEncoderInfo | undefined; + + if (preferred !== 'auto') { + if (preferred === 'cpu') { + base = undefined; + } else if (!supportedForAuto.has(preferred)) { + warnings.push(`Ускоритель "${preferred}" пока не поддерживается, использую CPU`); + } else { + base = pickByAccel(preferred); + if (!base) { + throw new Error(`Аппаратный ускоритель "${preferred}" недоступен в системе`); + } + } + } else { + const pool = autoRelevant.length > 0 ? autoRelevant : []; + base = pool.sort( + (a, b) => (ACCEL_PRIORITY[b.accelerator] || 0) - (ACCEL_PRIORITY[a.accelerator] || 0) + )[0]; + + if (!base && relevant.length > 0) { + warnings.push('Доступен аппаратный ускоритель, но он пока не поддерживается пайплайном, использую CPU'); + } + } + + const fallbackPool = autoRelevant.length > 0 ? autoRelevant : []; + const fallbackList = fallbackPool.sort( + (a, b) => (ACCEL_PRIORITY[b.accelerator] || 0) - (ACCEL_PRIORITY[a.accelerator] || 0) + ); + + const pickEncoder = (codecType: 'h264' | 'av1') => { + const direct = codecType === 'h264' ? base?.h264Encoder : base?.av1Encoder; + if (direct) return { encoder: direct, accel: base?.accelerator }; + + const alt = fallbackList.find(info => (codecType === 'h264' ? info.h264Encoder : info.av1Encoder)); + if (alt) { + if (preferred !== 'auto' && base) { + warnings.push( + `Выбранный ускоритель "${base.accelerator}" не поддерживает ${codecType.toUpperCase()}, использую ${alt.accelerator}` + ); + } + return { + encoder: codecType === 'h264' ? alt.h264Encoder! : alt.av1Encoder!, + accel: alt.accelerator + }; + } + + if (preferred !== 'auto' && preferred !== 'cpu') { + warnings.push( + `Ускоритель "${preferred}" не поддерживает ${codecType.toUpperCase()}, использую CPU` + ); + } + + return { encoder: undefined, accel: 'cpu' as HardwareAccelerator }; + }; + + const h264Result = needsH264 ? pickEncoder('h264') : { encoder: undefined, accel: base?.accelerator }; + const av1Result = needsAV1 ? pickEncoder('av1') : { encoder: undefined, accel: base?.accelerator }; + + const selectedAccel = (base?.accelerator || h264Result.accel || av1Result.accel || 'cpu') as HardwareAccelerator; + + return { + selected: selectedAccel, + h264Encoder: h264Result.encoder, + av1Encoder: av1Result.encoder, + warnings + }; +} + +function resolvePresetForEncoder(encoder: string, codecType: 'h264' | 'av1'): string { + if (encoder.includes('nvenc')) return 'p4'; + if (encoder.includes('qsv')) return 'medium'; + if (encoder.includes('amf')) return 'balanced'; + if (encoder.includes('vaapi')) return '5'; + if (encoder.includes('videotoolbox')) return 'medium'; + if (encoder.includes('v4l2')) return 'medium'; + + // CPU fallback presets + if (encoder === 'libsvtav1') return '8'; + if (encoder === 'libx264') return 'medium'; + + // Default safe preset + return codecType === 'av1' ? '8' : 'medium'; +} diff --git a/src/core/encoding.ts b/src/core/encoding.ts new file mode 100644 index 0000000..2106bf9 --- /dev/null +++ b/src/core/encoding.ts @@ -0,0 +1,261 @@ +import { join } from 'node:path'; +import { execFFmpeg, selectAudioBitrate } from '../utils'; +import type { VideoProfile, VideoOptimizations, CodecQualitySettings } from '../types'; + +/** + * Get default CQ/CRF value based on resolution and codec + */ +function getDefaultQuality(height: number, codecType: 'h264' | 'av1', isGPU: boolean): number { + if (isGPU) { + // GPU encoders use CQ - ФИКСИРОВАННЫЕ ЗНАЧЕНИЯ ДЛЯ ТЕСТИРОВАНИЯ + if (codecType === 'h264') { + // H.264 NVENC CQ = 32 (для всех разрешений) + return 32; + } else { + // AV1 NVENC CQ = 42 (для всех разрешений) + return 42; + } + } else { + // CPU encoders use CRF + if (codecType === 'h264') { + // libx264 CRF (на ~3-5 ниже чем NVENC CQ) + if (height <= 360) return 25; + if (height <= 480) return 24; + if (height <= 720) return 23; + if (height <= 1080) return 22; + if (height <= 1440) return 21; + return 20; // 4K + } else { + // libsvtav1 CRF (шкала 0-63, на ~20% выше чем NVENC CQ) + if (height <= 360) return 40; + if (height <= 480) return 38; + if (height <= 720) return 35; + if (height <= 1080) return 32; + if (height <= 1440) return 30; + return 28; // 4K + } + } +} + +/** + * Encode single profile to MP4 + * Stage 1: Heavy work - video encoding with optional optimizations + */ +export async function encodeProfileToMP4( + input: string, + tempDir: string, + profile: VideoProfile, + videoCodec: string, + preset: string, + duration: number, + segmentDuration: number, + sourceAudioBitrate: number | undefined, + codecType: 'h264' | 'av1', + qualitySettings?: CodecQualitySettings, + optimizations?: VideoOptimizations, + onProgress?: (percent: number) => void +): Promise { + const outputPath = join(tempDir, `video_${codecType}_${profile.name}.mp4`); + + const args = [ + '-y', + '-i', input, + '-c:v', videoCodec + ]; + + // Determine if using GPU or CPU encoder + const isGPU = videoCodec.includes('nvenc') || videoCodec.includes('qsv') || videoCodec.includes('amf') || videoCodec.includes('vaapi') || videoCodec.includes('videotoolbox') || videoCodec.includes('v4l2'); + + // Determine quality value (CQ for GPU, CRF for CPU) + let qualityValue: number; + if (isGPU && qualitySettings?.cq !== undefined) { + qualityValue = qualitySettings.cq; + } else if (!isGPU && qualitySettings?.crf !== undefined) { + qualityValue = qualitySettings.crf; + } else { + // Use default quality based on resolution + qualityValue = getDefaultQuality(profile.height, codecType, isGPU); + } + + // Add codec-specific options with CQ/CRF + if (videoCodec === 'h264_nvenc') { + // NVIDIA H.264 with CQ + args.push('-rc:v', 'vbr'); + args.push('-cq', String(qualityValue)); + args.push('-preset', preset); + args.push('-2pass', '0'); + } else if (videoCodec === 'av1_nvenc') { + // NVIDIA AV1 with CQ + args.push('-rc:v', 'vbr'); + args.push('-cq', String(qualityValue)); + args.push('-preset', preset); + args.push('-2pass', '0'); + } else if (videoCodec === 'av1_qsv') { + // Intel QSV AV1 + args.push('-preset', preset); + args.push('-global_quality', String(qualityValue)); + } else if (videoCodec === 'h264_qsv') { + // Intel QSV H.264 + args.push('-preset', preset); + args.push('-global_quality', String(qualityValue)); + } else if (videoCodec === 'av1_amf') { + // AMD AMF AV1 + args.push('-quality', 'balanced'); + args.push('-rc', 'cqp'); + args.push('-qp_i', String(qualityValue)); + args.push('-qp_p', String(qualityValue)); + } else if (videoCodec === 'h264_amf') { + // AMD AMF H.264 + args.push('-quality', 'balanced'); + args.push('-rc', 'cqp'); + args.push('-qp_i', String(qualityValue)); + args.push('-qp_p', String(qualityValue)); + } else if (videoCodec === 'libsvtav1') { + // CPU-based SVT-AV1 with CRF + args.push('-crf', String(qualityValue)); + args.push('-preset', preset); // 0-13, 8 is medium speed + args.push('-svtav1-params', 'tune=0:enable-overlays=1'); + } else if (videoCodec === 'libx264') { + // CPU-based x264 with CRF + args.push('-crf', String(qualityValue)); + args.push('-preset', preset); + } else { + // Default fallback + args.push('-preset', preset); + } + + // Add maxrate as safety limit (optional but recommended for streaming) + // This prevents extreme bitrate spikes on complex scenes + const bitrateMultiplier = codecType === 'av1' ? 0.6 : 1.0; + const maxBitrate = Math.round(parseInt(profile.videoBitrate) * bitrateMultiplier * 1.5); // +50% headroom + args.push('-maxrate', `${maxBitrate}k`); + args.push('-bufsize', `${maxBitrate * 2}k`); + + // Set GOP size for DASH segments + // Keyframes must align with segment boundaries + const fps = profile.fps || 30; + const gopSize = Math.round(fps * segmentDuration); + args.push( + '-g', String(gopSize), // GOP size (e.g., 25 fps * 2 sec = 50 frames) + '-keyint_min', String(gopSize), // Minimum interval between keyframes + '-sc_threshold', '0' // Disable scene change detection (keeps GOP consistent) + ); + + // Build video filter chain + const filters: string[] = [`scale=${profile.width}:${profile.height}`]; + + // Apply optimizations (for future use) + if (optimizations) { + if (optimizations.deinterlace) { + filters.push('yadif'); + } + if (optimizations.denoise) { + filters.push('hqdn3d'); + } + if (optimizations.customFilters) { + filters.push(...optimizations.customFilters); + } + } + + args.push('-vf', filters.join(',')); + + // Audio encoding + // Select optimal bitrate based on source (don't upscale) + const targetAudioBitrate = parseInt(profile.audioBitrate) || 256; + const optimalAudioBitrate = selectAudioBitrate(sourceAudioBitrate, targetAudioBitrate); + args.push('-c:a', 'aac', '-b:a', optimalAudioBitrate); + + // Audio optimizations + if (optimizations?.audioNormalize) { + args.push('-af', 'loudnorm'); + } + + // Output + args.push('-f', 'mp4', outputPath); + + await execFFmpeg(args, onProgress, duration); + + return outputPath; +} + +/** + * Encode all profiles to MP4 (parallel or sequential) + * Stage 1: Main encoding work + */ +export async function encodeProfilesToMP4( + input: string, + tempDir: string, + profiles: VideoProfile[], + videoCodec: string, + preset: string, + duration: number, + segmentDuration: number, + sourceAudioBitrate: number | undefined, + parallel: boolean, + maxConcurrent: number, + codecType: 'h264' | 'av1', + qualitySettings?: CodecQualitySettings, + optimizations?: VideoOptimizations, + onProgress?: (profileName: string, percent: number) => void +): Promise> { + const mp4Files = new Map(); + + if (parallel && profiles.length > 1) { + // Parallel encoding with batching + for (let i = 0; i < profiles.length; i += maxConcurrent) { + const batch = profiles.slice(i, i + maxConcurrent); + const batchPromises = batch.map((profile) => + encodeProfileToMP4( + input, + tempDir, + profile, + videoCodec, + preset, + duration, + segmentDuration, + sourceAudioBitrate, + codecType, + qualitySettings, + optimizations, + (percent) => { + if (onProgress) { + onProgress(profile.name, percent); + } + } + ) + ); + + const batchResults = await Promise.all(batchPromises); + batchResults.forEach((mp4Path, idx) => { + const profile = batch[idx]; + mp4Files.set(profile.name, mp4Path); + }); + } + } else { + // Sequential encoding + for (const profile of profiles) { + const mp4Path = await encodeProfileToMP4( + input, + tempDir, + profile, + videoCodec, + preset, + duration, + segmentDuration, + sourceAudioBitrate, + codecType, + qualitySettings, + optimizations, + (percent) => { + if (onProgress) { + onProgress(profile.name, percent); + } + } + ); + + mp4Files.set(profile.name, mp4Path); + } + } + + return mp4Files; +} diff --git a/src/core/manifest.ts b/src/core/manifest.ts new file mode 100644 index 0000000..f3c72cd --- /dev/null +++ b/src/core/manifest.ts @@ -0,0 +1,256 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import type { VideoProfile, CodecType } from '../types'; + +/** + * DASH MPD Manifest Generator + * Handles creation and manipulation of MPEG-DASH manifests + */ + +/** + * Validate and fix MPD manifest XML structure + * Ensures all Representation tags are properly closed + */ +export async function validateAndFixManifest(manifestPath: string): Promise { + let mpd = await readFile(manifestPath, 'utf-8'); + + // Fix 1: Remove double slashes in self-closing tags: "//> → "/> + mpd = mpd.replace(/\/\/>/g, '/>'); + + // Fix 2: Fix malformed self-closing tags with extra space: "/ /> → "/> + mpd = mpd.replace(/\/\s+\/>/g, '/>'); + + // Fix 3: Normalize Representation self-closing tags - remove extra spaces before /> + mpd = mpd.replace(/(]+)\s+\/>/g, '$1/>'); + + // Fix 4: Remove orphaned closing tags after self-closing Representation tags + mpd = mpd.replace(/]+)\/>\s*<\/Representation>/g, ''); + + // Fix 5: Convert self-closing Representation tags that have child elements to properly opened tags + mpd = mpd.replace( + /]+)\/>\s*(]*\/>)/g, + '\n $2\n ' + ); + + // Fix 6: Convert unclosed Representation tags to self-closing (if no children) + mpd = mpd.replace( + /]+)>\s*(?=<(?:Representation|\/AdaptationSet))/g, + '\n' + ); + + await writeFile(manifestPath, mpd, 'utf-8'); +} + +/** + * Update MPD manifest paths to reflect subdirectory structure + * Changes: $RepresentationID$_$Number$ → $RepresentationID$/$RepresentationID$_$Number$ + */ +export async function updateManifestPaths( + manifestPath: string, + profiles: VideoProfile[], + codecType: CodecType +): Promise { + let mpd = await readFile(manifestPath, 'utf-8'); + + // MP4Box uses $RepresentationID$ template variable + // Replace: media="$RepresentationID$_$Number$.m4s" + // With: media="$RepresentationID$/$RepresentationID$_$Number$.m4s" + + mpd = mpd.replace( + /media="\$RepresentationID\$_\$Number\$\.m4s"/g, + 'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"' + ); + + // Replace: initialization="$RepresentationID$_.mp4" + // With: initialization="$RepresentationID$/$RepresentationID$_.mp4" + + mpd = mpd.replace( + /initialization="\$RepresentationID\$_\.mp4"/g, + 'initialization="$RepresentationID$/$RepresentationID$_.mp4"' + ); + + await writeFile(manifestPath, mpd, 'utf-8'); +} + +/** + * Separate H.264 and AV1 representations into different AdaptationSets + * This allows DASH players to prefer AV1 when supported, with H.264 fallback + */ +export async function separateCodecAdaptationSets(manifestPath: string): Promise { + let mpd = await readFile(manifestPath, 'utf-8'); + + // Simple string-based approach: look for mixed codec patterns + // Find patterns like: + while (i < lines.length && !lines[i].includes('')) { + const currentLine = lines[i]; + + if (currentLine.includes('')) { + inSegmentTemplate = false; + } + } else if (currentLine.includes(' 0 && av1Reps.length > 0) { + // Split into two AdaptationSets + + // H.264 AdaptationSet + result.push(line); // Opening tag + segmentTemplateLines.forEach(l => result.push(l)); + h264Reps.forEach(l => result.push(l)); + result.push(' '); + + // AV1 AdaptationSet + result.push(line); // Same opening tag + segmentTemplateLines.forEach(l => result.push(l)); + av1Reps.forEach(l => result.push(l)); + result.push(' '); + } else { + // No mixed codecs, keep original + result.push(line); + for (let j = adaptationSetStart + 1; j < i; j++) { + result.push(lines[j]); + } + result.push(lines[i]); // closing tag + } + + i++; + } else { + result.push(line); + i++; + } + } + + await writeFile(manifestPath, result.join('\n'), 'utf-8'); +} + +/** + * Generate MPD manifest from scratch (alternative to MP4Box) + * TODO: Implement full MPD generation without external tools + */ +export async function generateMPDManifest( + profiles: VideoProfile[], + codecType: CodecType, + duration: number, + segmentDuration: number +): Promise { + // TODO: Implement manual MPD generation + // This will be used when we want full control over manifest + throw new Error('Manual MPD generation not yet implemented. Use Bento4 or MP4Box for now.'); +} + +/** + * Update HLS master manifest to reflect subdirectory structure + */ +export async function updateHLSManifestPaths( + manifestPath: string, + profiles: VideoProfile[] +): Promise { + let m3u8 = await readFile(manifestPath, 'utf-8'); + + // MP4Box uses $RepresentationID$ template variable + // Replace: media="$RepresentationID$_$Number$.m4s" + // With: media="$RepresentationID$/$RepresentationID$_$Number$.m4s" + + m3u8 = m3u8.replace( + /media="\$RepresentationID\$_\$Number\$\.m4s"/g, + 'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"' + ); + + // Replace: initialization="$RepresentationID$_.mp4" + // With: initialization="$RepresentationID$/$RepresentationID$_.mp4" + + m3u8 = m3u8.replace( + /initialization="\$RepresentationID\$_\.mp4"/g, + 'initialization="$RepresentationID$/$RepresentationID$_.mp4"' + ); + + await writeFile(manifestPath, m3u8, 'utf-8'); +} + +/** + * Generate HLS media playlist content + */ +export function generateHLSMediaPlaylist( + segmentFiles: string[], + initFile: string, + segmentDuration: number +): string { + let content = '#EXTM3U\n'; + content += `#EXT-X-VERSION:6\n`; + content += `#EXT-X-TARGETDURATION:${Math.ceil(segmentDuration)}\n`; + content += `#EXT-X-MEDIA-SEQUENCE:1\n`; + content += `#EXT-X-INDEPENDENT-SEGMENTS\n`; + content += `#EXT-X-MAP:URI="${initFile}"\n`; + + for (const segmentFile of segmentFiles) { + content += `#EXTINF:${segmentDuration},\n`; + content += `${segmentFile}\n`; + } + + content += `#EXT-X-ENDLIST\n`; + + return content; +} + +/** + * Generate HLS master playlist content + */ +export function generateHLSMasterPlaylist( + variants: Array<{ path: string; bandwidth: number; resolution: string; fps: number }>, + hasAudio: boolean +): string { + let content = '#EXTM3U\n'; + content += '#EXT-X-VERSION:6\n'; + content += '#EXT-X-INDEPENDENT-SEGMENTS\n\n'; + + // Add audio reference + if (hasAudio) { + content += `#EXT-X-MEDIA:TYPE=AUDIO,GROUP-ID="audio",NAME="audio",AUTOSELECT=YES,URI="audio/playlist.m3u8",CHANNELS="2"\n\n`; + } + + // Add video variants + for (const variant of variants) { + const codecs = hasAudio ? 'avc1.4D4020,mp4a.40.2' : 'avc1.4D4020'; + content += `#EXT-X-STREAM-INF:BANDWIDTH=${variant.bandwidth},CODECS="${codecs}",RESOLUTION=${variant.resolution},FRAME-RATE=${variant.fps}`; + if (hasAudio) { + content += `,AUDIO="audio"`; + } + content += `\n`; + content += `${variant.path}\n\n`; + } + + return content; +} diff --git a/src/core/packaging.ts b/src/core/packaging.ts new file mode 100644 index 0000000..d48290b --- /dev/null +++ b/src/core/packaging.ts @@ -0,0 +1,395 @@ +import { join } from 'node:path'; +import { execMP4Box } from '../utils'; +import type { VideoProfile, CodecType, StreamingFormat } from '../types'; +import { readdir, rename, mkdir, writeFile } from 'node:fs/promises'; +import { + validateAndFixManifest, + updateManifestPaths, + separateCodecAdaptationSets, + updateHLSManifestPaths, + generateHLSMediaPlaylist, + generateHLSMasterPlaylist +} from './manifest'; + +/** + * Package MP4 files into DASH format using MP4Box + * Stage 2: Light work - just packaging, no encoding + * Creates one master MPD manifest with all profiles and codecs + */ +export async function packageToDash( + codecMP4Files: Map<'h264' | 'av1', Map>, + outputDir: string, + profiles: VideoProfile[], + segmentDuration: number, + codecType: CodecType, + hasAudio: boolean +): Promise { + const manifestPath = join(outputDir, 'manifest.mpd'); + + // Build MP4Box command + const args = [ + '-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds + '-frag', String(segmentDuration * 1000), + '-rap', // Force segments to start with random access points + '-segment-timeline', // Use SegmentTimeline for accurate segment durations + '-segment-name', '$RepresentationID$_$Number$', + '-out', manifestPath + ]; + + // Add all MP4 files for each codec + let firstFile = true; + + for (const [codec, mp4Files] of codecMP4Files.entries()) { + for (const profile of profiles) { + const mp4Path = mp4Files.get(profile.name); + if (!mp4Path) { + throw new Error(`MP4 file not found for profile: ${profile.name}, codec: ${codec}`); + } + + // Representation ID includes codec: e.g., "720p-h264", "720p-av1" + const representationId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name; + + // Add video track with representation ID + args.push(`${mp4Path}#video:id=${representationId}`); + + // Add audio track only once (shared across all profiles and codecs) + if (firstFile && hasAudio) { + args.push(`${mp4Path}#audio:id=audio`); + firstFile = false; + } + } + } + + // Execute MP4Box + // Note: We separate codecs into different AdaptationSets manually via separateCodecAdaptationSets() + await execMP4Box(args); + + // MP4Box creates files in the same directory as output MPD + // Move segment files to profile subdirectories for clean structure + await organizeSegments(outputDir, profiles, codecType, hasAudio); + + // Update MPD to reflect new file structure with subdirectories + await updateManifestPaths(manifestPath, profiles, codecType); + + // For dual-codec mode, separate H.264 and AV1 into different AdaptationSets + if (codecType === 'dual') { + await separateCodecAdaptationSets(manifestPath); + } + + // Validate and fix XML structure (ensure all tags are properly closed) + await validateAndFixManifest(manifestPath); + + return manifestPath; +} + +/** + * Organize segments into profile subdirectories + * MP4Box creates all files in one directory, we organize them + */ +async function organizeSegments( + outputDir: string, + profiles: VideoProfile[], + codecType: CodecType, + hasAudio: boolean +): Promise { + const { readdir, rename, mkdir } = await import('node:fs/promises'); + + // For dual-codec mode, create codec-specific subdirectories (e.g., "720p-h264/", "720p-av1/") + // For single-codec mode, use simple profile names (e.g., "720p/") + const codecs: Array<'h264' | 'av1'> = []; + if (codecType === 'h264' || codecType === 'dual') codecs.push('h264'); + if (codecType === 'av1' || codecType === 'dual') codecs.push('av1'); + + const representationIds: string[] = []; + + for (const codec of codecs) { + for (const profile of profiles) { + const repId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name; + representationIds.push(repId); + + const profileDir = join(outputDir, repId); + await mkdir(profileDir, { recursive: true }); + } + } + + // Create audio subdirectory + const audioDir = join(outputDir, 'audio'); + if (hasAudio) { + await mkdir(audioDir, { recursive: true }); + } + + // Get all files in output directory + const files = await readdir(outputDir); + + // Move segment files to their respective directories + for (const file of files) { + // Skip manifest + if (file === 'manifest.mpd') { + continue; + } + + // Move audio files to audio/ directory + if (hasAudio && (file.startsWith('audio_') || file === 'audio_init.m4s')) { + const oldPath = join(outputDir, file); + const newPath = join(audioDir, file); + await rename(oldPath, newPath); + continue; + } + + // Move video segment files to their representation directories + for (const repId of representationIds) { + if (file.startsWith(`${repId}_`)) { + const oldPath = join(outputDir, file); + const newPath = join(outputDir, repId, file); + await rename(oldPath, newPath); + break; + } + } + } +} + +/** + * Package MP4 files into HLS format using MP4Box + * Stage 2: Light work - just packaging, no encoding + * Creates master.m3u8 playlist with H.264 profiles only (for Safari/iOS compatibility) + */ +export async function packageToHLS( + codecMP4Files: Map<'h264' | 'av1', Map>, + outputDir: string, + profiles: VideoProfile[], + segmentDuration: number, + codecType: CodecType +): Promise { + const manifestPath = join(outputDir, 'master.m3u8'); + + // Build MP4Box command for HLS + const args = [ + '-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds + '-frag', String(segmentDuration * 1000), + '-rap', // Force segments to start with random access points + '-segment-timeline', // Use SegmentTimeline for accurate segment durations + '-segment-name', '$RepresentationID$_$Number$', + '-profile', 'live', // HLS mode instead of DASH + '-out', manifestPath + ]; + + // For HLS, use only H.264 codec (Safari/iOS compatibility) + const h264Files = codecMP4Files.get('h264'); + + if (!h264Files) { + throw new Error('H.264 codec files not found. HLS requires H.264 for Safari/iOS compatibility.'); + } + + let firstFile = true; + + for (const profile of profiles) { + const mp4Path = h264Files.get(profile.name); + if (!mp4Path) { + throw new Error(`MP4 file not found for profile: ${profile.name}, codec: h264`); + } + + // Representation ID for HLS (no codec suffix since we only use H.264) + const representationId = profile.name; + + // Add video track with representation ID + args.push(`${mp4Path}#video:id=${representationId}`); + + // Add audio track only once (shared across all profiles) + if (firstFile) { + args.push(`${mp4Path}#audio:id=audio`); + firstFile = false; + } + } + + // Execute MP4Box + await execMP4Box(args); + + // MP4Box creates files in the same directory as output manifest + // Move segment files to profile subdirectories for clean structure + await organizeSegmentsHLS(outputDir, profiles); + + // Update manifest to reflect new file structure with subdirectories + await updateHLSManifestPaths(manifestPath, profiles); + + return manifestPath; +} + +/** + * Organize HLS segments into profile subdirectories + * HLS only uses H.264, so no codec suffix in directory names + */ +async function organizeSegmentsHLS( + outputDir: string, + profiles: VideoProfile[] +): Promise { + const representationIds: string[] = []; + + for (const profile of profiles) { + const repId = profile.name; // Just profile name, no codec + representationIds.push(repId); + + const profileDir = join(outputDir, repId); + await mkdir(profileDir, { recursive: true }); + } + + // Create audio subdirectory + const audioDir = join(outputDir, 'audio'); + await mkdir(audioDir, { recursive: true }); + + // Get all files in output directory + const files = await readdir(outputDir); + + // Move segment files to their respective directories + for (const file of files) { + // Skip manifest + if (file === 'master.m3u8') { + continue; + } + + // Move audio files to audio/ directory + if (file.startsWith('audio_') || file === 'audio_init.m4s') { + const oldPath = join(outputDir, file); + const newPath = join(audioDir, file); + await rename(oldPath, newPath); + continue; + } + + // Move video segment files to their representation directories + for (const repId of representationIds) { + if (file.startsWith(`${repId}_`)) { + const oldPath = join(outputDir, file); + const newPath = join(outputDir, repId, file); + await rename(oldPath, newPath); + break; + } + } + } +} + +/** + * Unified packaging: creates segments once and generates both DASH and HLS manifests + * No duplication - segments stored in {profile}-{codec}/ folders + */ +export async function packageToFormats( + codecMP4Files: Map<'h264' | 'av1', Map>, + outputDir: string, + profiles: VideoProfile[], + segmentDuration: number, + codec: CodecType, + format: StreamingFormat, + hasAudio: boolean +): Promise<{ manifestPath?: string; hlsManifestPath?: string }> { + + let manifestPath: string | undefined; + let hlsManifestPath: string | undefined; + + // Step 1: Generate DASH segments and manifest using MP4Box + if (format === 'dash' || format === 'both') { + manifestPath = await packageToDash(codecMP4Files, outputDir, profiles, segmentDuration, codec, hasAudio); + } + + // Step 2: Generate HLS playlists from existing segments + if (format === 'hls' || format === 'both') { + // HLS generation from segments + hlsManifestPath = await generateHLSPlaylists( + outputDir, + profiles, + segmentDuration, + codec, + hasAudio + ); + } + + return { manifestPath, hlsManifestPath }; +} + +/** + * Generate HLS playlists (media playlists in folders + master in root) + */ +async function generateHLSPlaylists( + outputDir: string, + profiles: VideoProfile[], + segmentDuration: number, + codecType: CodecType, + hasAudio: boolean +): Promise { + const masterPlaylistPath = join(outputDir, 'master.m3u8'); + const variants: Array<{ path: string; bandwidth: number; resolution: string; fps: number }> = []; + + // Generate media playlist for each H.264 profile + for (const profile of profiles) { + const profileDir = codecType === 'dual' ? `${profile.name}-h264` : profile.name; + const profilePath = join(outputDir, profileDir); + + // Read segment files from profile directory + const files = await readdir(profilePath); + const segmentFiles = files + .filter(f => f.endsWith('.m4s')) + .sort((a, b) => { + const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0'); + const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0'); + return numA - numB; + }); + + const initFile = files.find(f => f.endsWith('_.mp4')); + + if (!initFile || segmentFiles.length === 0) { + continue; // Skip if no segments found + } + + // Generate media playlist content using manifest module + const playlistContent = generateHLSMediaPlaylist(segmentFiles, initFile, segmentDuration); + + // Write media playlist + const playlistPath = join(profilePath, 'playlist.m3u8'); + await writeFile(playlistPath, playlistContent, 'utf-8'); + + // Add to variants list + const bandwidth = parseInt(profile.videoBitrate) * 1000; + variants.push({ + path: `${profileDir}/playlist.m3u8`, + bandwidth, + resolution: `${profile.width}x${profile.height}`, + fps: profile.fps || 30 + }); + } + + // Generate audio media playlist (only if source has audio) + let audioInit: string | undefined; + let audioSegments: string[] = []; + + if (hasAudio) { + const audioDir = join(outputDir, 'audio'); + let audioFiles: string[] = []; + + try { + audioFiles = await readdir(audioDir); + } catch { + audioFiles = []; + } + + audioSegments = audioFiles + .filter(f => f.endsWith('.m4s')) + .sort((a, b) => { + const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0'); + const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0'); + return numA - numB; + }); + + audioInit = audioFiles.find(f => f.endsWith('_.mp4')); + + if (audioInit && audioSegments.length > 0) { + const audioPlaylistContent = generateHLSMediaPlaylist(audioSegments, audioInit, segmentDuration); + await writeFile(join(audioDir, 'playlist.m3u8'), audioPlaylistContent, 'utf-8'); + } + } + + // Generate master playlist using manifest module + const masterContent = generateHLSMasterPlaylist( + variants, + hasAudio && audioInit !== undefined && audioSegments.length > 0 + ); + await writeFile(masterPlaylistPath, masterContent, 'utf-8'); + + return masterPlaylistPath; +} diff --git a/src/core/thumbnails.ts b/src/core/thumbnails.ts new file mode 100644 index 0000000..4371694 --- /dev/null +++ b/src/core/thumbnails.ts @@ -0,0 +1,139 @@ +import { join } from 'node:path'; +import type { ThumbnailConfig } from '../types'; +import { execFFmpeg, formatVttTime, ensureDir } from '../utils'; +import { readdir, unlink, rmdir, writeFile } from 'node:fs/promises'; + +/** + * Generate poster image from video at specific timecode + * @param inputPath - Path to input video + * @param outputDir - Directory to save poster + * @param timecode - Timecode in format "HH:MM:SS" or seconds (default: "00:00:01") + * @returns Path to generated poster + */ +export async function generatePoster( + inputPath: string, + outputDir: string, + timecode: string = '00:00:00' +): Promise { + const posterPath = join(outputDir, 'poster.jpg'); + + // Parse timecode: if it's a number, treat as seconds, otherwise use as-is + const timeArg = /^\d+(\.\d+)?$/.test(timecode) ? timecode : timecode; + + await execFFmpeg([ + '-ss', timeArg, // Seek to timecode + '-i', inputPath, // Input file + '-vframes', '1', // Extract 1 frame + '-q:v', '2', // High quality (2-5 range, 2 is best) + '-y', // Overwrite output + posterPath + ]); + + return posterPath; +} + +/** + * Generate thumbnail sprite and VTT file + */ +export async function generateThumbnailSprite( + inputPath: string, + outputDir: string, + duration: number, + config: Required +): Promise<{ spritePath: string; vttPath: string }> { + const { width, height, interval, columns } = config; + + // Create temp directory for individual thumbnails + const tempDir = join(outputDir, '.thumbnails_temp'); + await ensureDir(tempDir); + await writeFile(join(tempDir, '.keep'), ''); + + // Generate individual thumbnails + const thumbnailPattern = join(tempDir, 'thumb_%04d.jpg'); + + await execFFmpeg([ + '-i', inputPath, + '-vf', `fps=1/${interval},scale=${width}:${height}`, + '-q:v', '5', + thumbnailPattern + ]); + + // Get list of generated thumbnails + const files = await readdir(tempDir); + const thumbFiles = files + .filter(f => f.startsWith('thumb_') && f.endsWith('.jpg')) + .sort(); + + if (thumbFiles.length === 0) { + throw new Error('No thumbnails generated'); + } + + // Calculate grid dimensions + const totalThumbs = thumbFiles.length; + const rows = Math.ceil(totalThumbs / columns); + + // Create sprite sheet using FFmpeg + const spritePath = join(outputDir, 'thumbnails.jpg'); + + // Use pattern input for tile filter (not multiple -i inputs) + const tileFilter = `tile=${columns}x${rows}`; + + await execFFmpeg([ + '-i', thumbnailPattern, // Use pattern, not individual files + '-filter_complex', tileFilter, + '-q:v', '5', + spritePath + ]); + + // Generate VTT file + const vttPath = join(outputDir, 'thumbnails.vtt'); + const vttContent = generateVttContent( + totalThumbs, + interval, + width, + height, + columns, + 'thumbnails.jpg' + ); + + await writeFile(vttPath, vttContent); + + // Clean up temp files + for (const file of thumbFiles) { + await unlink(join(tempDir, file)); + } + await unlink(join(tempDir, '.keep')); + await rmdir(tempDir); // Remove directory + + return { spritePath, vttPath }; +} + +/** + * Generate VTT file content + */ +function generateVttContent( + totalThumbs: number, + interval: number, + thumbWidth: number, + thumbHeight: number, + columns: number, + spriteFilename: string +): string { + let vtt = 'WEBVTT\n\n'; + + for (let i = 0; i < totalThumbs; i++) { + const startTime = i * interval; + const endTime = (i + 1) * interval; + + const row = Math.floor(i / columns); + const col = i % columns; + + const x = col * thumbWidth; + const y = row * thumbHeight; + + vtt += `${formatVttTime(startTime)} --> ${formatVttTime(endTime)}\n`; + vtt += `${spriteFilename}#xywh=${x},${y},${thumbWidth},${thumbHeight}\n\n`; + } + + return vtt; +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..f5eac21 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,31 @@ +// Main exports +export { convertToDash } from './core/converter'; + +// Type exports +export type { + DashConvertOptions, + DashConvertResult, + VideoProfile, + ThumbnailConfig, + ConversionProgress, + VideoMetadata, + VideoOptimizations, + CodecType, + HardwareAccelerator, + HardwareAccelerationOption, + HardwareEncoderInfo +} from './types'; + +// Utility exports +export { + checkFFmpeg, + checkMP4Box, + checkNvenc, + checkAV1Support, + getVideoMetadata, + selectAudioBitrate, + detectHardwareEncoders +} from './utils'; + +// Profile exports +export { DEFAULT_PROFILES, selectProfiles } from './config/profiles'; diff --git a/src/types/index.ts b/src/types/index.ts new file mode 100644 index 0000000..8aaaf5e --- /dev/null +++ b/src/types/index.ts @@ -0,0 +1,238 @@ +/** + * Video codec type for encoding + */ +export type CodecType = 'av1' | 'h264' | 'dual'; + +/** + * Streaming format type + */ +export type StreamingFormat = 'dash' | 'hls' | 'both'; + +/** + * Тип аппаратного ускорителя + */ +export type HardwareAccelerator = 'nvenc' | 'qsv' | 'amf' | 'vaapi' | 'videotoolbox' | 'v4l2' | 'cpu'; + +/** + * Опция выбора ускорителя (конкретный или auto) + */ +export type HardwareAccelerationOption = HardwareAccelerator | 'auto'; + +/** + * Набор доступных энкодеров для конкретного ускорителя + */ +export interface HardwareEncoderInfo { + accelerator: HardwareAccelerator; + h264Encoder?: string; + av1Encoder?: string; +} + +/** + * Quality settings for a codec + */ +export interface CodecQualitySettings { + /** CQ (Constant Quality) for GPU encoders (0-51, lower = better quality) */ + cq?: number; + + /** CRF (Constant Rate Factor) for CPU encoders (0-51 for h264, 0-63 for av1, lower = better quality) */ + crf?: number; +} + +/** + * Quality settings for video encoding + */ +export interface QualitySettings { + /** Quality settings for H.264 codec */ + h264?: CodecQualitySettings; + + /** Quality settings for AV1 codec */ + av1?: CodecQualitySettings; +} + +/** + * Configuration options for DASH conversion + */ +export interface DashConvertOptions { + /** Input video file path */ + input: string; + + /** Output directory path */ + outputDir: string; + + /** Segment duration in seconds (default: 2) */ + segmentDuration?: number; + + /** Video quality profiles to generate */ + profiles?: VideoProfile[]; + + /** Custom resolution profiles as strings (e.g., ['360p', '480p', '720p@60']) */ + customProfiles?: string[]; + + /** Video codec to use: 'av1', 'h264', or 'dual' for both (default: 'dual') */ + codec?: CodecType; + + /** Streaming format to generate: 'dash', 'hls', or 'both' (default: 'both') */ + format?: StreamingFormat; + + /** Enable NVENC hardware acceleration (auto-detect if undefined) — устарело, используйте hardwareAccelerator */ + useNvenc?: boolean; + + /** Предпочитаемый аппаратный ускоритель (auto по умолчанию) */ + hardwareAccelerator?: HardwareAccelerationOption; + + /** Quality settings for video encoding (CQ/CRF values) */ + quality?: QualitySettings; + + /** Generate thumbnail sprite (default: true) */ + generateThumbnails?: boolean; + + /** Thumbnail sprite configuration */ + thumbnailConfig?: ThumbnailConfig; + + /** Generate poster image (default: true) */ + generatePoster?: boolean; + + /** Poster timecode in format HH:MM:SS or seconds (default: 00:00:01) */ + posterTimecode?: string; + + /** Parallel encoding (default: true) */ + parallel?: boolean; + + /** Callback for progress updates */ + onProgress?: (progress: ConversionProgress) => void; +} + +/** + * Video quality profile + */ +export interface VideoProfile { + /** Profile name (e.g., "1080p", "720p") */ + name: string; + + /** Video width in pixels */ + width: number; + + /** Video height in pixels */ + height: number; + + /** Video bitrate (e.g., "5000k") */ + videoBitrate: string; + + /** Audio bitrate (e.g., "128k") */ + audioBitrate: string; + + /** Target FPS for this profile (default: 30) */ + fps?: number; +} + +/** + * Thumbnail sprite configuration + */ +export interface ThumbnailConfig { + /** Width of each thumbnail (default: 160) */ + width?: number; + + /** Height of each thumbnail (default: 90) */ + height?: number; + + /** Interval between thumbnails in seconds (default: 1) */ + interval?: number; + + /** Number of thumbnails per row (default: 10) */ + columns?: number; +} + +/** + * Conversion progress information + */ +export interface ConversionProgress { + /** Current stage of conversion */ + stage: 'analyzing' | 'encoding' | 'thumbnails' | 'manifest' | 'complete'; + + /** Progress percentage (0-100) - overall progress */ + percent: number; + + /** Current profile being processed */ + currentProfile?: string; + + /** Progress percentage for current profile (0-100) */ + profilePercent?: number; + + /** Additional message */ + message?: string; +} + +/** + * Result of DASH conversion + */ +export interface DashConvertResult { + /** Path to generated DASH manifest (if format is 'dash' or 'both') */ + manifestPath?: string; + + /** Path to generated HLS manifest (if format is 'hls' or 'both') */ + hlsManifestPath?: string; + + /** Paths to generated video segments */ + videoPaths: string[]; + + /** Path to thumbnail sprite (if generated) */ + thumbnailSpritePath?: string; + + /** Path to thumbnail VTT file (if generated) */ + thumbnailVttPath?: string; + + /** Path to poster image (if generated) */ + posterPath?: string; + + /** Video duration in seconds */ + duration: number; + + /** Generated profiles */ + profiles: VideoProfile[]; + + /** Whether NVENC was used */ + usedNvenc: boolean; + + /** Выбранный аппаратный ускоритель */ + selectedAccelerator: HardwareAccelerator; + + /** Codec type used for encoding */ + codecType: CodecType; + + /** Streaming format generated */ + format: StreamingFormat; +} + +/** + * Video metadata + */ +export interface VideoMetadata { + width: number; + height: number; + duration: number; + fps: number; + codec: string; + hasAudio: boolean; // Есть ли аудиодорожка + audioBitrate?: number; // Битрейт аудио в kbps + videoBitrate?: number; // Битрейт видео в kbps +} + +/** + * Video optimizations (for future use) + */ +export interface VideoOptimizations { + /** Apply deinterlacing */ + deinterlace?: boolean; + + /** Apply denoising filter */ + denoise?: boolean; + + /** Color correction / LUT file path */ + colorCorrection?: string; + + /** Audio normalization */ + audioNormalize?: boolean; + + /** Custom FFmpeg filters */ + customFilters?: string[]; +} diff --git a/src/utils/fs.ts b/src/utils/fs.ts new file mode 100644 index 0000000..7d5ab5f --- /dev/null +++ b/src/utils/fs.ts @@ -0,0 +1,13 @@ +import { mkdir, access, constants } from 'node:fs/promises'; + +/** + * Ensure directory exists + */ +export async function ensureDir(dirPath: string): Promise { + try { + await access(dirPath, constants.F_OK); + } catch { + await mkdir(dirPath, { recursive: true }); + } +} + diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..a210270 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,23 @@ +// System utilities +export { + checkFFmpeg, + checkMP4Box, + checkNvenc, + checkAV1Support, + detectHardwareEncoders, + execFFmpeg, + execMP4Box, + setLogFile +} from './system'; + +// Video utilities +export { + getVideoMetadata, + selectAudioBitrate, + formatVttTime +} from './video'; + +// File system utilities +export { + ensureDir +} from './fs'; diff --git a/src/utils/system.ts b/src/utils/system.ts new file mode 100644 index 0000000..6ca1f39 --- /dev/null +++ b/src/utils/system.ts @@ -0,0 +1,261 @@ +import { spawn } from 'node:child_process'; +import { appendFile } from 'node:fs/promises'; +import type { HardwareAccelerator, HardwareEncoderInfo } from '../types'; + +// Global variable for log file path +let currentLogFile: string | null = null; + +/** + * Set log file path for FFmpeg and MP4Box output + */ +export function setLogFile(logPath: string): void { + currentLogFile = logPath; +} + +/** + * Append log entry to file + */ +async function appendLog(entry: string): Promise { + if (currentLogFile) { + try { + await appendFile(currentLogFile, entry, 'utf-8'); + } catch (err) { + // Silently ignore log errors to not break conversion + } + } +} + +/** + * Check if FFmpeg is available + */ +export async function checkFFmpeg(): Promise { + 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 { + return new Promise((resolve) => { + const proc = spawn('MP4Box', ['-version']); + proc.on('error', () => resolve(false)); + proc.on('close', (code) => resolve(code === 0)); + }); +} + +/** + * Check if Bento4 mp4dash is available + */ +export async function checkBento4(): Promise { + return new Promise((resolve) => { + const proc = spawn('mp4dash', ['--version']); + proc.on('error', () => resolve(false)); + proc.on('close', (code) => resolve(code === 0)); + }); +} + +/** + * Check if NVENC is available + */ +export async function checkNvenc(): Promise { + 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')); + } + }); + }); +} + +/** + * Check if AV1 hardware encoding is available + * Supports: NVENC (RTX 40xx), QSV (Intel 11+), AMF (AMD RX 7000) + */ +export async function checkAV1Support(): Promise<{ + available: boolean; + encoder?: 'av1_nvenc' | 'av1_qsv' | 'av1_amf'; +}> { + 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({ available: false })); + proc.on('close', (code) => { + if (code !== 0) { + resolve({ available: false }); + } else { + // Check for hardware AV1 encoders in order of preference + if (output.includes('av1_nvenc')) { + resolve({ available: true, encoder: 'av1_nvenc' }); + } else if (output.includes('av1_qsv')) { + resolve({ available: true, encoder: 'av1_qsv' }); + } else if (output.includes('av1_amf')) { + resolve({ available: true, encoder: 'av1_amf' }); + } else { + resolve({ available: false }); + } + } + }); + }); +} + +/** + * Получить список доступных аппаратных энкодеров (по выводу ffmpeg -encoders) + */ +export async function detectHardwareEncoders(): Promise { + const encodersOutput: string = await new Promise((resolve) => { + const proc = spawn('ffmpeg', ['-hide_banner', '-encoders']); + let output = ''; + + proc.stdout.on('data', (data) => { + output += data.toString(); + }); + + proc.on('error', () => resolve('')); + proc.on('close', () => resolve(output)); + }); + + const has = (name: string) => encodersOutput.includes(name); + + const detected: HardwareEncoderInfo[] = []; + + const accelerators: Array<{ acc: HardwareAccelerator; h264?: string; av1?: string }> = [ + { acc: 'nvenc', h264: has('h264_nvenc') ? 'h264_nvenc' : undefined, av1: has('av1_nvenc') ? 'av1_nvenc' : undefined }, + { acc: 'qsv', h264: has('h264_qsv') ? 'h264_qsv' : undefined, av1: has('av1_qsv') ? 'av1_qsv' : undefined }, + { acc: 'amf', h264: has('h264_amf') ? 'h264_amf' : undefined, av1: has('av1_amf') ? 'av1_amf' : undefined }, + { acc: 'vaapi', h264: has('h264_vaapi') ? 'h264_vaapi' : undefined, av1: has('av1_vaapi') ? 'av1_vaapi' : undefined }, + { acc: 'videotoolbox', h264: has('h264_videotoolbox') ? 'h264_videotoolbox' : undefined, av1: has('av1_videotoolbox') ? 'av1_videotoolbox' : undefined }, + { acc: 'v4l2', h264: has('h264_v4l2m2m') ? 'h264_v4l2m2m' : undefined, av1: has('av1_v4l2m2m') ? 'av1_v4l2m2m' : undefined } + ]; + + for (const item of accelerators) { + if (item.h264 || item.av1) { + detected.push({ + accelerator: item.acc, + h264Encoder: item.h264, + av1Encoder: item.av1 + }); + } + } + + return detected; +} + +/** + * Execute FFmpeg command with progress tracking + */ +export async function execFFmpeg( + args: string[], + onProgress?: (percent: number) => void, + duration?: number +): Promise { + const timestamp = new Date().toISOString(); + const commandLog = `\n=== FFmpeg Command [${timestamp}] ===\nffmpeg ${args.join(' ')}\n`; + await appendLog(commandLog); + + 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) => { + appendLog(`ERROR: ${err.message}\n`); + reject(new Error(`FFmpeg error: ${err.message}`)); + }); + + proc.on('close', (code) => { + if (code === 0) { + // Log last 10 lines of output for successful runs + const lines = stderrData.split('\n').filter(l => l.trim()); + const lastLines = lines.slice(-10).join('\n'); + appendLog(`SUCCESS: Exit code ${code}\n--- Last 10 lines of output ---\n${lastLines}\n`); + resolve(); + } else { + // Log full output on failure + appendLog(`FAILED: Exit code ${code}\n--- Full error output ---\n${stderrData}\n`); + reject(new Error(`FFmpeg failed with exit code ${code}\n${stderrData}`)); + } + }); + }); +} + +/** + * Execute MP4Box command + */ +export async function execMP4Box(args: string[]): Promise { + const timestamp = new Date().toISOString(); + const commandLog = `\n=== MP4Box Command [${timestamp}] ===\nMP4Box ${args.join(' ')}\n`; + await appendLog(commandLog); + + 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) => { + appendLog(`ERROR: ${err.message}\n`); + reject(new Error(`MP4Box error: ${err.message}`)); + }); + + proc.on('close', (code) => { + if (code === 0) { + // Log output summary for successful runs + const output = stdoutData || stderrData; + const lines = output.split('\n').filter(l => l.trim()); + const lastLines = lines.slice(-10).join('\n'); + appendLog(`SUCCESS: Exit code ${code}\n--- Last 10 lines of output ---\n${lastLines}\n`); + resolve(); + } else { + // Log full output on failure + const output = stderrData || stdoutData; + appendLog(`FAILED: Exit code ${code}\n--- Full error output ---\n${output}\n`); + reject(new Error(`MP4Box failed with exit code ${code}\n${output}`)); + } + }); + }); +} diff --git a/src/utils/video.ts b/src/utils/video.ts new file mode 100644 index 0000000..3435e9b --- /dev/null +++ b/src/utils/video.ts @@ -0,0 +1,118 @@ +import { spawn } from 'node:child_process'; +import type { VideoMetadata } from '../types'; + +/** + * Get video metadata using ffprobe + */ +export async function getVideoMetadata(inputPath: string): Promise { + return new Promise((resolve, reject) => { + const proc = spawn('ffprobe', [ + '-v', 'error', + '-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name,codec_type,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.codec_type === 'video'); + const audioStream = data.streams.find((s: any) => s.codec_type === 'audio'); + const format = data.format; + + if (!videoStream) { + reject(new Error('No video stream found in input file')); + return; + } + + // Parse frame rate (handle missing or malformed r_frame_rate) + let fps = 30; // default fallback + if (videoStream.r_frame_rate) { + const [num, den] = videoStream.r_frame_rate.split('/').map(Number); + if (num && den && den !== 0) { + fps = num / den; + } + } + + // Get duration from stream or format + const duration = parseFloat(videoStream.duration || format.duration || '0'); + + // Get audio bitrate in kbps + const audioBitrateSource = data.streams.find((s: any) => s.codec_type === 'audio' && s.bit_rate); + const audioBitrate = audioBitrateSource?.bit_rate + ? Math.round(parseInt(audioBitrateSource.bit_rate) / 1000) + : undefined; + + // Get video bitrate in kbps + const videoBitrate = videoStream.bit_rate + ? Math.round(parseInt(videoStream.bit_rate) / 1000) + : undefined; + + resolve({ + width: videoStream.width, + height: videoStream.height, + duration, + fps, + codec: videoStream.codec_name, + hasAudio: Boolean(audioStream), + audioBitrate, + videoBitrate + }); + } 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')}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d261a36 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "lib": ["ESNext"], + "moduleResolution": "bundler", + "types": ["bun-types"], + "allowImportingTsExtensions": true, + "noEmit": false, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} + diff --git a/web-test/index.html b/web-test/index.html new file mode 100644 index 0000000..5362967 --- /dev/null +++ b/web-test/index.html @@ -0,0 +1,38 @@ + + + + + + Document + + + + + + + + + +
+ +
+ + + + \ No newline at end of file