feat: Обновленая реализация CLI
This commit is contained in:
13
src/utils/fs.ts
Normal file
13
src/utils/fs.ts
Normal 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
23
src/utils/index.ts
Normal 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
261
src/utils/system.ts
Normal 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
118
src/utils/video.ts
Normal 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')}`;
|
||||
}
|
||||
Reference in New Issue
Block a user