fix: Исправление выбора энкодер/декодер

This commit is contained in:
2026-01-20 14:24:51 +03:00
parent 88fc443cb6
commit 69b3a4804f
15 changed files with 457 additions and 250 deletions

View File

@@ -10,19 +10,19 @@
* create-vod ./video.mp4 ./output -r 720,1080
*/
import { convertToDash, checkFFmpeg, checkMP4Box, getVideoMetadata, detectHardwareEncoders, detectHardwareDecoders } from './index';
import { convertToDash, checkFFmpeg, checkMP4Box, getVideoMetadata, detectHardwareEncoders, detectHardwareDecoders, testEncoder, testDecoder } 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 type { CodecChoice, StreamingFormatChoice, QualitySettings, HardwareAccelerationOption, HardwareAccelerator } 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)
let codecChoice: CodecChoice = 'auto'; // h264 + AV1 if HW
let formatChoice: StreamingFormatChoice = 'auto'; // DASH + HLS
const positionalArgs: string[] = [];
// Quality settings
@@ -58,19 +58,19 @@ for (let i = 0; i < args.length; i++) {
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;
if (codec === 'av1' || codec === 'h264') {
codecChoice = codec;
} else {
console.error(`❌ Invalid codec: ${codec}. Valid options: av1, h264, dual`);
console.error(`❌ Invalid codec: ${codec}. Valid options: av1, h264`);
process.exit(1);
}
i++; // Skip next arg
} else if (args[i] === '-f' || args[i] === '--format') {
const format = args[i + 1];
if (format === 'dash' || format === 'hls' || format === 'both') {
formatType = format;
if (format === 'dash' || format === 'hls') {
formatChoice = format;
} else {
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls, both`);
console.error(`❌ Invalid format: ${format}. Valid options: dash, hls`);
process.exit(1);
}
i++; // Skip next arg
@@ -102,7 +102,7 @@ for (let i = 0; i < args.length; i++) {
process.exit(1);
}
i++; // Skip next arg
} else if (args[i] === '--accel' || args[i] === '--hardware' || args[i] === '-e' || args[i] === '--encoder') {
} else if (args[i] === '-e' || args[i] === '--encoder') {
const acc = args[i + 1];
const allowed: HardwareAccelerationOption[] = ['auto', 'nvenc', 'qsv', 'amf', 'cpu', 'vaapi', 'videotoolbox', 'v4l2'];
if (!allowed.includes(acc as HardwareAccelerationOption)) {
@@ -134,8 +134,8 @@ if (!input) {
console.error('❌ Usage: create-vod <input-video> [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(' -c, --codec Video codec: av1 or h264 (default: auto = h264 + AV1 if HW)');
console.error(' -f, --format Streaming format: dash or hls (default: auto = dash + hls)');
console.error(' -p, --poster Poster timecode (e.g., 00:00:05 or 10)');
console.error(' -e, --encoder <type> Hardware encoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)');
console.error(' -d, --decoder <type> Hardware decoder: auto|nvenc|qsv|amf|vaapi|videotoolbox|v4l2|cpu (default: auto)');
@@ -149,12 +149,11 @@ if (!input) {
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 -c h264 --h264-cq 30');
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');
console.error(' create-vod video.mp4 ./output -r 720,1080 -p 10 --h264-cq 28');
process.exit(1);
}
@@ -163,8 +162,8 @@ console.log('🔍 Checking system...\n');
const hasFFmpeg = await checkFFmpeg();
const hasMP4Box = await checkMP4Box();
const hwEncoders = await detectHardwareEncoders();
const hasAv1Hardware = hwEncoders.some(item => item.av1Encoder);
const hwDecoders = await detectHardwareDecoders();
const hasAv1Hardware = hwEncoders.some(item => item.av1Encoder);
const accelPriority: Record<string, number> = {
nvenc: 100,
@@ -172,31 +171,77 @@ const accelPriority: Record<string, number> = {
amf: 80,
vaapi: 70,
videotoolbox: 65,
v4l2: 60
v4l2: 60,
cpu: 1
};
const bestAccel = hwEncoders
const encoderMap: Record<string, string> = {
nvenc: 'h264_nvenc',
qsv: 'h264_qsv',
amf: 'h264_amf',
vaapi: 'h264_vaapi',
videotoolbox: 'h264_videotoolbox',
v4l2: 'h264_v4l2m2m',
cpu: 'libx264'
};
const encoderCandidates = Array.from(new Set([...hwEncoders.map(e => e.accelerator), 'cpu']));
const decoderCandidates = Array.from(new Set([...hwDecoders.map(d => d.accelerator), 'cpu']));
async function filterEncoders() {
const result: HardwareAccelerationOption[] = [];
for (const acc of encoderCandidates) {
if (acc === 'amf') {
continue;
}
const encoderName = encoderMap[acc] || 'libx264';
const ok = await testEncoder(encoderName);
if (ok) result.push(acc as HardwareAccelerationOption);
}
return result;
}
async function filterDecoders() {
const result: HardwareAccelerationOption[] = [];
for (const acc of decoderCandidates) {
if (acc === 'cpu') {
result.push('cpu');
continue;
}
const ok = await testDecoder(acc as HardwareAccelerator, input);
if (ok) result.push(acc as HardwareAccelerationOption);
}
return result;
}
const availableEncoders = await filterEncoders();
const availableDecoders = await filterDecoders();
const bestAccel = availableEncoders
.slice()
.sort((a, b) => (accelPriority[b.accelerator] || 0) - (accelPriority[a.accelerator] || 0))[0];
.sort((a, b) => (accelPriority[b] || 0) - (accelPriority[a] || 0))[0];
const bestDecoder = availableDecoders
.slice()
.sort((a, b) => (accelPriority[b] || 0) - (accelPriority[a] || 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 accelList = Array.from(new Set(availableEncoders.map(e => e.toUpperCase())));
const decList = Array.from(new Set(availableDecoders.map(d => d.toUpperCase())));
const encoderSelectedPlanned = accelerator
? accelerator.toUpperCase()
: (bestAccelName || 'CPU');
: ((bestAccel && bestAccel.toUpperCase()) || 'CPU');
const encoderAll = accelList.length > 0 ? accelList : ['CPU'];
const decList = Array.from(new Set(hwDecoders.map((d) => d.accelerator.toUpperCase())));
const decoderSelectedPlanned = decoder
? decoder.toUpperCase()
: (decList[0] || 'CPU');
: ((bestDecoder && bestDecoder.toUpperCase()) || 'CPU');
const decoderAll = decList.length > 0 ? decList : ['CPU'];
console.log(`Encoder: ${encoderSelectedPlanned === 'AUTO' ? (bestAccelName || 'CPU') : encoderSelectedPlanned} (${encoderAll.join(', ')})`);
console.log(`Decoder: ${decoderSelectedPlanned === 'AUTO' ? (decList[0] || 'CPU') : decoderSelectedPlanned} (${decoderAll.join(', ')})`);
console.log(`Encoder: ${encoderSelectedPlanned === 'AUTO' ? ((bestAccel && bestAccel.toUpperCase()) || 'CPU') : encoderSelectedPlanned} (${encoderAll.join(', ')})`);
console.log(`Decoder: ${decoderSelectedPlanned === 'AUTO' ? ((bestDecoder && bestDecoder.toUpperCase()) || 'CPU') : decoderSelectedPlanned} (${decoderAll.join(', ')})`);
console.log('');
if (!hasFFmpeg) {
@@ -209,22 +254,28 @@ if (!hasMP4Box) {
process.exit(1);
}
// Validate codec selection
if ((codecType === 'av1' || codecType === 'dual') && !hasAv1Hardware) {
if (codecType === 'av1') {
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`);
} else if (codecType === 'dual') {
console.warn(`⚠️ AV1 hardware encoder not detected. Using H.264 only (disable AV1).`);
codecType = 'h264';
}
// Resolve codec selection
let includeH264 = codecChoice === 'h264' || codecChoice === 'auto';
let includeAv1 = codecChoice === 'av1' || codecChoice === 'auto';
if (includeAv1 && !hasAv1Hardware && codecChoice === 'auto') {
includeAv1 = false;
}
if (codecChoice === 'av1' && !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`);
}
// Resolve formats
const wantDash = formatChoice === 'dash' || formatChoice === 'auto';
const wantHls = formatChoice === 'hls' || formatChoice === 'auto';
// Validate HLS requires H.264
if ((formatType === 'hls' || formatType === 'both') && codecType === 'av1') {
if (wantHls && !includeH264) {
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`);
console.error(` Please use --codec h264 or omit --codec to keep H.264.\n`);
process.exit(1);
}
@@ -281,27 +332,31 @@ if (customProfiles && customProfiles.length > 0) {
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 manifestDesc = [
wantDash ? 'DASH (manifest.mpd)' : null,
wantHls ? 'HLS (master.m3u8)' : null
].filter(Boolean).join(', ');
const thumbnailsPlanned = true;
const posterPlanned = posterTimecode || '00:00:00';
const codecDisplay = codecType === 'dual' ? 'dual (AV1 + H.264)' : codecType;
const codecNote = codecType === 'h264' && !hasAv1Hardware ? ' (AV1 disabled: no HW)' : '';
const plannedAccel = accelerator ? accelerator.toUpperCase() : (bestAccelName || 'CPU');
const plannedDecoder = decoder ? decoder.toUpperCase() : (hwDecoders[0]?.accelerator.toUpperCase() || 'CPU');
const acceleratorDisplay = plannedAccel === 'AUTO' ? (bestAccelName || 'CPU') : plannedAccel;
const decoderDisplay = plannedDecoder === 'AUTO'
? (hwDecoders[0]?.accelerator.toUpperCase() || 'CPU')
: plannedDecoder;
const codecListDisplay = [
includeH264 ? 'h264' : null,
includeAv1 ? 'av1' : null
].filter(Boolean).join(', ');
const codecNote = (!includeAv1 && codecChoice === 'auto' && !hasAv1Hardware) ? ' (AV1 disabled: no HW)' : '';
const bestAccelName = (bestAccel && bestAccel.toUpperCase()) || 'CPU';
const bestDecoderName = (bestDecoder && bestDecoder.toUpperCase()) || 'CPU';
const plannedAccel = accelerator ? accelerator.toUpperCase() : bestAccelName;
const plannedDecoder = decoder ? decoder.toUpperCase() : bestDecoderName;
const acceleratorDisplay = plannedAccel === 'AUTO' ? bestAccelName : plannedAccel;
const decoderDisplay = plannedDecoder === 'AUTO' ? bestDecoderName : plannedDecoder;
const encoderListDisplay = encoderAll.join(', ');
const decoderListDisplay = decoderAll.join(', ');
console.log('\n📦 Parameters:');
console.log(` Input: ${input}`);
console.log(` Output: ${outputDir}`);
console.log(` Codec: ${codecDisplay}${codecNote}`);
console.log(` Codec: ${codecListDisplay}${codecNote}`);
console.log(` Profiles: ${displayProfiles.join(', ')}`);
console.log(` Manifests: ${manifestDesc}`);
console.log(` Poster: ${posterPlanned} (will be generated)`);
@@ -352,8 +407,8 @@ try {
outputDir,
customProfiles,
posterTimecode,
codec: codecType,
format: formatType,
codec: codecChoice,
format: formatChoice,
segmentDuration: 2,
hardwareAccelerator: accelerator,
hardwareDecoder: decoder,

View File

@@ -8,7 +8,9 @@ import type {
ThumbnailConfig,
ConversionProgress,
CodecType,
CodecChoice,
StreamingFormat,
StreamingFormatChoice,
HardwareAccelerationOption,
HardwareAccelerator,
HardwareEncoderInfo,
@@ -41,8 +43,8 @@ export async function convertToDash(
segmentDuration = 2,
profiles: userProfiles,
customProfiles,
codec = 'dual',
format = 'both',
codec = 'auto',
format = 'auto',
hardwareDecoder,
hardwareAccelerator,
quality,
@@ -127,8 +129,8 @@ async function convertToDashInternal(
segmentDuration: number,
userProfiles: VideoProfile[] | undefined,
customProfiles: string[] | undefined,
codec: CodecType,
format: StreamingFormat,
codec: CodecChoice,
format: StreamingFormatChoice,
hardwareAccelerator: HardwareAccelerationOption | undefined,
hardwareDecoder: HardwareAccelerationOption | undefined,
quality: DashConvertOptions['quality'],
@@ -171,10 +173,20 @@ async function convertToDashInternal(
const hardwareEncoders = await detectHardwareEncoders();
const hardwareDecoders = await detectHardwareDecoders();
const av1HardwareAvailable = hardwareEncoders.some(info => info.av1Encoder);
let wantH264 = codec === 'h264' || codec === 'auto';
let wantAv1 = codec === 'av1' || codec === 'auto';
if (codec === 'auto' && !av1HardwareAvailable) {
wantAv1 = false;
}
const { selected, h264Encoder, av1Encoder, warnings: accelWarnings } = selectHardwareEncoders(
hardwareEncoders,
preferredAccelerator,
codec
wantH264,
wantAv1
);
if (accelWarnings.length > 0) {
@@ -188,14 +200,20 @@ async function convertToDashInternal(
hardwareDecoder || 'auto'
);
const av1HardwareAvailable = hardwareEncoders.some(info => info.av1Encoder);
let effectiveCodec: CodecType = codec;
if (codec === 'dual' && !av1HardwareAvailable) {
console.warn('⚠️ AV1 hardware encoder not detected. Switching to H.264 only.');
effectiveCodec = 'h264';
if (codec === 'av1' && !av1HardwareAvailable) {
console.warn('⚠️ AV1 hardware encoder not detected. AV1 will use CPU encoder (slow).');
}
const codecsSelected: Array<'h264' | 'av1'> = [];
if (wantH264) codecsSelected.push('h264');
if (wantAv1) codecsSelected.push('av1');
if (codecsSelected.length === 0) codecsSelected.push('h264');
const formatsSelected: StreamingFormat[] = [];
if (format === 'dash' || format === 'auto') formatsSelected.push('dash');
if (format === 'hls' || format === 'auto') formatsSelected.push('hls');
if (formatsSelected.length === 0) formatsSelected.push('dash');
// Select profiles
let profiles: VideoProfile[];
@@ -264,14 +282,12 @@ async function convertToDashInternal(
// Determine which codecs to use based on codec parameter
const codecs: Array<{ type: 'h264' | 'av1'; codec: string; preset: string }> = [];
if (effectiveCodec === 'h264' || effectiveCodec === 'dual') {
if (codecsSelected.includes('h264')) {
const h264Codec = h264Encoder || 'libx264';
const h264Preset = resolvePresetForEncoder(h264Codec, 'h264');
codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset });
}
if (effectiveCodec === 'av1' || effectiveCodec === 'dual') {
if (codecsSelected.includes('av1')) {
const av1Codec = av1Encoder || 'libsvtav1';
const av1Preset = resolvePresetForEncoder(av1Codec, 'av1');
codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset });
@@ -343,8 +359,8 @@ async function convertToDashInternal(
videoOutputDir,
profiles,
segmentDuration,
effectiveCodec,
format,
codecsSelected,
formatsSelected,
hasAudio
);
@@ -418,8 +434,8 @@ async function convertToDashInternal(
usedNvenc: codecs.some(c => c.codec.includes('nvenc')),
selectedAccelerator: selected,
selectedDecoder,
codecType: effectiveCodec,
format
codecs: codecsSelected,
formats: formatsSelected
};
}
@@ -436,15 +452,14 @@ const ACCEL_PRIORITY: Record<HardwareAccelerator, number> = {
function selectHardwareEncoders(
available: HardwareEncoderInfo[],
preferred: HardwareAccelerationOption,
codec: CodecType
needsH264: boolean,
needsAV1: boolean
): {
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<HardwareAccelerator>(['nvenc', 'qsv', 'amf']);
@@ -456,12 +471,20 @@ function selectHardwareEncoders(
const pickByAccel = (acc: HardwareAccelerator) =>
relevant.find(item => item.accelerator === acc);
// Явное указание CPU: никакого fallback на железо
if (preferred === 'cpu') {
return {
selected: 'cpu',
h264Encoder: undefined,
av1Encoder: undefined,
warnings
};
}
let base: HardwareEncoderInfo | undefined;
if (preferred !== 'auto') {
if (preferred === 'cpu') {
base = undefined;
} else if (!supportedForAuto.has(preferred)) {
if (!supportedForAuto.has(preferred)) {
warnings.push(`Ускоритель "${preferred}" пока не поддерживается, использую CPU`);
} else {
base = pickByAccel(preferred);
@@ -502,7 +525,7 @@ function selectHardwareEncoders(
};
}
if (preferred !== 'auto' && preferred !== 'cpu') {
if (preferred !== 'auto') {
warnings.push(
`Ускоритель "${preferred}" не поддерживает ${codecType.toUpperCase()}, использую CPU`
);

View File

@@ -156,7 +156,14 @@ export async function encodeProfileToMP4(
);
// Build video filter chain
const filters: string[] = [`scale=${profile.width}:${profile.height}`];
const filters: string[] = [];
if (decoderAccel === 'nvenc') {
// CUDA path: keep frames on GPU
filters.push(`scale_cuda=${profile.width}:${profile.height}`);
} else {
filters.push(`scale=${profile.width}:${profile.height}`);
}
// Apply optimizations (for future use)
if (optimizations) {

View File

@@ -45,9 +45,7 @@ export async function validateAndFixManifest(manifestPath: string): Promise<void
* Changes: $RepresentationID$_$Number$ → $RepresentationID$/$RepresentationID$_$Number$
*/
export async function updateManifestPaths(
manifestPath: string,
profiles: VideoProfile[],
codecType: CodecType
manifestPath: string
): Promise<void> {
let mpd = await readFile(manifestPath, 'utf-8');

View File

@@ -1,6 +1,6 @@
import { join } from 'node:path';
import { execMP4Box } from '../utils';
import type { VideoProfile, CodecType, StreamingFormat } from '../types';
import type { VideoProfile, StreamingFormat } from '../types';
import { readdir, rename, mkdir, writeFile } from 'node:fs/promises';
import {
validateAndFixManifest,
@@ -21,10 +21,11 @@ export async function packageToDash(
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType,
codecs: Array<'h264' | 'av1'>,
hasAudio: boolean
): Promise<string> {
const manifestPath = join(outputDir, 'manifest.mpd');
const useCodecSuffix = codecs.length > 1;
// Build MP4Box command
const args = [
@@ -47,7 +48,7 @@ export async function packageToDash(
}
// Representation ID includes codec: e.g., "720p-h264", "720p-av1"
const representationId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
const representationId = useCodecSuffix ? `${profile.name}-${codec}` : profile.name;
// Add video track with representation ID
args.push(`${mp4Path}#video:id=${representationId}`);
@@ -66,13 +67,13 @@ export async function packageToDash(
// 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);
await organizeSegments(outputDir, profiles, codecs, hasAudio);
// Update MPD to reflect new file structure with subdirectories
await updateManifestPaths(manifestPath, profiles, codecType);
await updateManifestPaths(manifestPath);
// For dual-codec mode, separate H.264 and AV1 into different AdaptationSets
if (codecType === 'dual') {
if (useCodecSuffix) {
await separateCodecAdaptationSets(manifestPath);
}
@@ -89,22 +90,18 @@ export async function packageToDash(
async function organizeSegments(
outputDir: string,
profiles: VideoProfile[],
codecType: CodecType,
codecs: Array<'h264' | 'av1'>,
hasAudio: boolean
): Promise<void> {
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 useCodecSuffix = codecs.length > 1;
const representationIds: string[] = [];
for (const codec of codecs) {
for (const profile of profiles) {
const repId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name;
const repId = useCodecSuffix ? `${profile.name}-${codec}` : profile.name;
representationIds.push(repId);
const profileDir = join(outputDir, repId);
@@ -158,7 +155,7 @@ export async function packageToHLS(
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType
useCodecSuffix: boolean
): Promise<string> {
const manifestPath = join(outputDir, 'master.m3u8');
@@ -188,8 +185,8 @@ export async function packageToHLS(
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;
// Representation ID for HLS (добавляем суффикс, если есть несколько кодеков)
const representationId = useCodecSuffix ? `${profile.name}-h264` : profile.name;
// Add video track with representation ID
args.push(`${mp4Path}#video:id=${representationId}`);
@@ -206,7 +203,7 @@ export async function packageToHLS(
// MP4Box creates files in the same directory as output manifest
// Move segment files to profile subdirectories for clean structure
await organizeSegmentsHLS(outputDir, profiles);
await organizeSegmentsHLS(outputDir, profiles, useCodecSuffix);
// Update manifest to reflect new file structure with subdirectories
await updateHLSManifestPaths(manifestPath, profiles);
@@ -220,12 +217,13 @@ export async function packageToHLS(
*/
async function organizeSegmentsHLS(
outputDir: string,
profiles: VideoProfile[]
profiles: VideoProfile[],
useCodecSuffix: boolean
): Promise<void> {
const representationIds: string[] = [];
for (const profile of profiles) {
const repId = profile.name; // Just profile name, no codec
const repId = useCodecSuffix ? `${profile.name}-h264` : profile.name;
representationIds.push(repId);
const profileDir = join(outputDir, repId);
@@ -275,27 +273,33 @@ export async function packageToFormats(
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codec: CodecType,
format: StreamingFormat,
codecs: Array<'h264' | 'av1'>,
formats: 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);
const needSegments = formats.length > 0;
const needDash = formats.includes('dash');
const needHls = formats.includes('hls');
// Step 1: Generate DASH segments and manifest using MP4Box (segments нужны для обоих форматов)
if (needSegments) {
manifestPath = await packageToDash(codecMP4Files, outputDir, profiles, segmentDuration, codecs, hasAudio);
if (!needDash) {
manifestPath = undefined;
}
}
// Step 2: Generate HLS playlists from existing segments
if (format === 'hls' || format === 'both') {
// HLS generation from segments
if (needHls) {
hlsManifestPath = await generateHLSPlaylists(
outputDir,
profiles,
segmentDuration,
codec,
codecs.length > 1,
hasAudio
);
}
@@ -310,7 +314,7 @@ async function generateHLSPlaylists(
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType,
useCodecSuffix: boolean,
hasAudio: boolean
): Promise<string> {
const masterPlaylistPath = join(outputDir, 'master.m3u8');
@@ -318,7 +322,7 @@ async function generateHLSPlaylists(
// Generate media playlist for each H.264 profile
for (const profile of profiles) {
const profileDir = codecType === 'dual' ? `${profile.name}-h264` : profile.name;
const profileDir = useCodecSuffix ? `${profile.name}-h264` : profile.name;
const profilePath = join(outputDir, profileDir);
// Read segment files from profile directory

View File

@@ -11,6 +11,9 @@ export type {
VideoMetadata,
VideoOptimizations,
CodecType,
CodecChoice,
StreamingFormat,
StreamingFormatChoice,
HardwareAccelerator,
HardwareAccelerationOption,
HardwareEncoderInfo,
@@ -26,7 +29,9 @@ export {
getVideoMetadata,
selectAudioBitrate,
detectHardwareEncoders,
detectHardwareDecoders
detectHardwareDecoders,
testEncoder,
testDecoder
} from './utils';
// Profile exports

View File

@@ -1,12 +1,22 @@
/**
* Video codec type for encoding
*/
export type CodecType = 'av1' | 'h264' | 'dual';
export type CodecType = 'av1' | 'h264';
/**
* Streaming format type
*/
export type StreamingFormat = 'dash' | 'hls' | 'both';
export type StreamingFormat = 'dash' | 'hls';
/**
* Пользовательский выбор кодека (auto = h264 + av1 при наличии HW)
*/
export type CodecChoice = CodecType | 'auto';
/**
* Пользовательский выбор форматов (auto = dash + hls)
*/
export type StreamingFormatChoice = StreamingFormat | 'auto';
/**
* Тип аппаратного ускорителя
@@ -75,11 +85,11 @@ export interface DashConvertOptions {
/** 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;
/** Video codec selection: h264, av1, or auto (default: auto = h264 + AV1 if HW) */
codec?: CodecChoice;
/** Streaming format to generate: 'dash', 'hls', or 'both' (default: 'both') */
format?: StreamingFormat;
/** Streaming formats: dash, hls, or auto (default: auto = оба) */
format?: StreamingFormatChoice;
/** Предпочитаемый аппаратный ускоритель (auto по умолчанию) */
hardwareAccelerator?: HardwareAccelerationOption;
@@ -172,10 +182,10 @@ export interface ConversionProgress {
* Result of DASH conversion
*/
export interface DashConvertResult {
/** Path to generated DASH manifest (if format is 'dash' or 'both') */
/** Path to generated DASH manifest (если форматы включают DASH) */
manifestPath?: string;
/** Path to generated HLS manifest (if format is 'hls' or 'both') */
/** Path to generated HLS manifest (если форматы включают HLS) */
hlsManifestPath?: string;
/** Paths to generated video segments */
@@ -204,11 +214,11 @@ export interface DashConvertResult {
/** Выбранный аппаратный декодер */
selectedDecoder: HardwareAccelerator;
/** Codec type used for encoding */
codecType: CodecType;
/** Список использованных кодеков */
codecs: CodecType[];
/** Streaming format generated */
format: StreamingFormat;
/** Список сгенерированных форматов */
formats: StreamingFormat[];
}
/**

View File

@@ -6,6 +6,8 @@ export {
checkAV1Support,
detectHardwareEncoders,
detectHardwareDecoders,
testEncoder,
testDecoder,
execFFmpeg,
execMP4Box,
setLogFile

View File

@@ -193,6 +193,57 @@ export async function detectHardwareDecoders(): Promise<HardwareDecoderInfo[]> {
return decoders;
}
/**
* Простой smoke-тест энкодера FFmpeg (1 кадр из testsrc)
*/
export async function testEncoder(encoder: string): Promise<boolean> {
const args = [
'-v', 'error',
'-f', 'lavfi',
'-i', 'testsrc=size=320x240:rate=1',
'-frames:v', '1',
'-an',
'-c:v', encoder,
'-f', 'null', '-'
];
return new Promise((resolve) => {
const proc = spawn('ffmpeg', args);
proc.on('error', () => resolve(false));
proc.on('close', (code) => resolve(code === 0));
});
}
/**
* Простой smoke-тест декодера FFmpeg (1 кадр входного файла)
*/
export async function testDecoder(accel: HardwareAccelerator, input: string): Promise<boolean> {
const args = ['-v', 'error'];
if (accel === 'nvenc') {
args.push('-hwaccel', 'cuda', '-hwaccel_output_format', 'cuda');
} else if (accel === 'qsv') {
args.push('-hwaccel', 'qsv');
} else if (accel === 'vaapi') {
args.push('-hwaccel', 'vaapi', '-vaapi_device', '/dev/dri/renderD128');
} else if (accel === 'videotoolbox') {
args.push('-hwaccel', 'videotoolbox');
} else if (accel === 'v4l2') {
args.push('-hwaccel', 'v4l2m2m');
} else if (accel === 'amf') {
// AMF декод чаще всего не используется напрямую; считаем недоступным
return false;
}
args.push('-i', input, '-frames:v', '1', '-f', 'null', '-');
return new Promise((resolve) => {
const proc = spawn('ffmpeg', args);
proc.on('error', () => resolve(false));
proc.on('close', (code) => resolve(code === 0));
});
}
/**
* Execute FFmpeg command with progress tracking
*/