feat: Обновленая реализация CLI

This commit is contained in:
2026-01-20 00:25:55 +03:00
commit b93554f0cd
25 changed files with 4618 additions and 0 deletions

13
src/utils/fs.ts Normal file
View File

@@ -0,0 +1,13 @@
import { mkdir, access, constants } from 'node:fs/promises';
/**
* Ensure directory exists
*/
export async function ensureDir(dirPath: string): Promise<void> {
try {
await access(dirPath, constants.F_OK);
} catch {
await mkdir(dirPath, { recursive: true });
}
}

23
src/utils/index.ts Normal file
View File

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

261
src/utils/system.ts Normal file
View File

@@ -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<void> {
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<boolean> {
return new Promise((resolve) => {
const proc = spawn('ffmpeg', ['-version']);
proc.on('error', () => resolve(false));
proc.on('close', (code) => resolve(code === 0));
});
}
/**
* Check if MP4Box is available
*/
export async function checkMP4Box(): Promise<boolean> {
return new Promise((resolve) => {
const proc = spawn('MP4Box', ['-version']);
proc.on('error', () => resolve(false));
proc.on('close', (code) => resolve(code === 0));
});
}
/**
* Check if Bento4 mp4dash is available
*/
export async function checkBento4(): Promise<boolean> {
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<boolean> {
return new Promise((resolve) => {
const proc = spawn('ffmpeg', ['-hide_banner', '-encoders']);
let output = '';
proc.stdout.on('data', (data) => {
output += data.toString();
});
proc.on('error', () => resolve(false));
proc.on('close', (code) => {
if (code !== 0) {
resolve(false);
} else {
resolve(output.includes('h264_nvenc') || output.includes('hevc_nvenc'));
}
});
});
}
/**
* 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<HardwareEncoderInfo[]> {
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<void> {
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<void> {
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}`));
}
});
});
}

118
src/utils/video.ts Normal file
View File

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