2025-11-08 19:41:20 +03:00
|
|
|
|
import { join, basename, extname } from 'node:path';
|
|
|
|
|
|
import { randomUUID } from 'node:crypto';
|
|
|
|
|
|
import { rm } from 'node:fs/promises';
|
|
|
|
|
|
import type {
|
|
|
|
|
|
DashConvertOptions,
|
|
|
|
|
|
DashConvertResult,
|
|
|
|
|
|
VideoProfile,
|
|
|
|
|
|
ThumbnailConfig,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
ConversionProgress,
|
|
|
|
|
|
CodecType
|
2025-11-09 01:28:42 +03:00
|
|
|
|
} from '../types';
|
2025-11-08 19:41:20 +03:00
|
|
|
|
import {
|
|
|
|
|
|
checkFFmpeg,
|
|
|
|
|
|
checkMP4Box,
|
|
|
|
|
|
checkNvenc,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
checkAV1Support,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
getVideoMetadata,
|
|
|
|
|
|
ensureDir
|
2025-11-09 01:28:42 +03:00
|
|
|
|
} from '../utils';
|
2025-11-09 10:40:35 +03:00
|
|
|
|
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
|
|
|
|
|
|
import { generateThumbnailSprite, generatePoster } from './thumbnails';
|
2025-11-08 19:41:20 +03:00
|
|
|
|
import { encodeProfilesToMP4 } from './encoding';
|
|
|
|
|
|
import { packageToDash } from './packaging';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Convert video to DASH format with NVENC acceleration
|
|
|
|
|
|
* Two-stage approach: FFmpeg encoding → MP4Box packaging
|
|
|
|
|
|
*/
|
|
|
|
|
|
export async function convertToDash(
|
|
|
|
|
|
options: DashConvertOptions
|
|
|
|
|
|
): Promise<DashConvertResult> {
|
|
|
|
|
|
const {
|
|
|
|
|
|
input,
|
|
|
|
|
|
outputDir,
|
|
|
|
|
|
segmentDuration = 2,
|
|
|
|
|
|
profiles: userProfiles,
|
2025-11-09 10:40:35 +03:00
|
|
|
|
customProfiles,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
codec = 'dual',
|
2025-11-08 19:41:20 +03:00
|
|
|
|
useNvenc,
|
|
|
|
|
|
generateThumbnails = true,
|
|
|
|
|
|
thumbnailConfig = {},
|
2025-11-09 10:40:35 +03:00
|
|
|
|
generatePoster: shouldGeneratePoster = true,
|
|
|
|
|
|
posterTimecode = '00:00:01',
|
2025-11-08 19:41:20 +03:00
|
|
|
|
parallel = true,
|
|
|
|
|
|
onProgress
|
|
|
|
|
|
} = options;
|
|
|
|
|
|
|
|
|
|
|
|
// Create unique temp directory
|
|
|
|
|
|
const tempDir = join('/tmp', `dash-converter-${randomUUID()}`);
|
|
|
|
|
|
await ensureDir(tempDir);
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
return await convertToDashInternal(
|
|
|
|
|
|
input,
|
|
|
|
|
|
outputDir,
|
|
|
|
|
|
tempDir,
|
|
|
|
|
|
segmentDuration,
|
|
|
|
|
|
userProfiles,
|
2025-11-09 10:40:35 +03:00
|
|
|
|
customProfiles,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
codec,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
useNvenc,
|
|
|
|
|
|
generateThumbnails,
|
|
|
|
|
|
thumbnailConfig,
|
2025-11-09 10:40:35 +03:00
|
|
|
|
shouldGeneratePoster,
|
|
|
|
|
|
posterTimecode,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
parallel,
|
|
|
|
|
|
onProgress
|
|
|
|
|
|
);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
// 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,
|
2025-11-09 10:40:35 +03:00
|
|
|
|
customProfiles: string[] | undefined,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
codec: CodecType,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
useNvenc: boolean | undefined,
|
|
|
|
|
|
generateThumbnails: boolean,
|
|
|
|
|
|
thumbnailConfig: ThumbnailConfig,
|
2025-11-09 10:40:35 +03:00
|
|
|
|
generatePosterFlag: boolean,
|
|
|
|
|
|
posterTimecode: string,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
parallel: boolean,
|
|
|
|
|
|
onProgress?: (progress: ConversionProgress) => void
|
|
|
|
|
|
): Promise<DashConvertResult> {
|
|
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
|
|
|
|
|
|
|
// Check NVENC availability
|
|
|
|
|
|
const nvencAvailable = useNvenc !== false ? await checkNvenc() : false;
|
|
|
|
|
|
const willUseNvenc = useNvenc === true ? true : (useNvenc === false ? false : nvencAvailable);
|
|
|
|
|
|
|
|
|
|
|
|
if (useNvenc === true && !nvencAvailable) {
|
|
|
|
|
|
throw new Error('NVENC requested but not available. Check NVIDIA drivers and GPU support.');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Select profiles
|
2025-11-09 10:40:35 +03:00
|
|
|
|
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) {
|
2025-11-11 21:25:37 +03:00
|
|
|
|
console.warn('\n❌ Profile errors:');
|
2025-11-09 10:40:35 +03:00
|
|
|
|
for (const error of result.errors) {
|
|
|
|
|
|
console.warn(` - ${error}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
console.warn('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-11 21:25:37 +03:00
|
|
|
|
// 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('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-09 10:40:35 +03:00
|
|
|
|
profiles = result.profiles;
|
|
|
|
|
|
|
|
|
|
|
|
if (profiles.length === 0) {
|
2025-11-11 21:25:37 +03:00
|
|
|
|
throw new Error('No valid profiles found in custom list. Check errors above.');
|
2025-11-09 10:40:35 +03:00
|
|
|
|
}
|
|
|
|
|
|
} 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
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
|
|
|
|
|
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);
|
2025-11-09 10:40:35 +03:00
|
|
|
|
|
|
|
|
|
|
// Clean up previous conversion if exists
|
|
|
|
|
|
try {
|
|
|
|
|
|
await rm(videoOutputDir, { recursive: true, force: true });
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
// Directory might not exist, that's fine
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-08 19:41:20 +03:00
|
|
|
|
await ensureDir(videoOutputDir);
|
|
|
|
|
|
|
2025-11-11 21:07:51 +03:00
|
|
|
|
// 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 = willUseNvenc ? 'h264_nvenc' : 'libx264';
|
|
|
|
|
|
const h264Preset = willUseNvenc ? 'p4' : 'medium';
|
|
|
|
|
|
codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (codec === 'av1' || codec === 'dual') {
|
|
|
|
|
|
// Check for AV1 hardware encoder
|
|
|
|
|
|
const av1Support = await checkAV1Support();
|
|
|
|
|
|
const av1Codec = av1Support.available ? av1Support.encoder! : 'libsvtav1';
|
|
|
|
|
|
const av1Preset = av1Support.available ? (av1Codec === 'av1_nvenc' ? 'p4' : 'medium') : '8';
|
|
|
|
|
|
codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const codecNames = codecs.map(c => c.type.toUpperCase()).join(' + ');
|
|
|
|
|
|
reportProgress('analyzing', 20, `Using ${codecNames} encoding (${willUseNvenc ? 'GPU' : 'CPU'})`, undefined);
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
|
|
|
|
|
const maxConcurrent = willUseNvenc ? 3 : 2;
|
|
|
|
|
|
|
2025-11-11 21:07:51 +03:00
|
|
|
|
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
|
|
|
|
|
|
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
2025-11-11 21:07:51 +03:00
|
|
|
|
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)...`);
|
|
|
|
|
|
|
|
|
|
|
|
const tempMP4Paths = await encodeProfilesToMP4(
|
|
|
|
|
|
input,
|
|
|
|
|
|
tempDir,
|
|
|
|
|
|
profiles,
|
|
|
|
|
|
videoCodec,
|
|
|
|
|
|
codecPreset,
|
|
|
|
|
|
metadata.duration,
|
|
|
|
|
|
segmentDuration,
|
|
|
|
|
|
metadata.audioBitrate,
|
|
|
|
|
|
parallel,
|
|
|
|
|
|
maxConcurrent,
|
|
|
|
|
|
type, // Pass codec type to differentiate output files
|
|
|
|
|
|
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}...`
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
2025-11-08 19:41:20 +03:00
|
|
|
|
}
|
2025-11-11 21:07:51 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
codecMP4Paths.set(type, tempMP4Paths);
|
|
|
|
|
|
}
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
2025-11-11 21:07:51 +03:00
|
|
|
|
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
|
|
|
|
|
// STAGE 2: Package to DASH using MP4Box (light work, fast)
|
|
|
|
|
|
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
|
|
|
|
|
|
|
|
|
|
|
|
const manifestPath = await packageToDash(
|
2025-11-11 21:07:51 +03:00
|
|
|
|
codecMP4Paths,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
videoOutputDir,
|
|
|
|
|
|
profiles,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
segmentDuration,
|
|
|
|
|
|
codec
|
2025-11-08 19:41:20 +03:00
|
|
|
|
);
|
|
|
|
|
|
|
2025-11-11 21:07:51 +03:00
|
|
|
|
// Collect all video paths from all codecs
|
|
|
|
|
|
const videoPaths: string[] = [];
|
|
|
|
|
|
for (const mp4Paths of codecMP4Paths.values()) {
|
|
|
|
|
|
videoPaths.push(...Array.from(mp4Paths.values()));
|
|
|
|
|
|
}
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
|
|
|
|
|
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
|
|
|
|
|
|
|
|
|
|
|
|
// Generate thumbnails
|
|
|
|
|
|
let thumbnailSpritePath: string | undefined;
|
|
|
|
|
|
let thumbnailVttPath: string | undefined;
|
|
|
|
|
|
|
|
|
|
|
|
if (generateThumbnails) {
|
|
|
|
|
|
reportProgress('thumbnails', 80, 'Generating thumbnail sprites...');
|
|
|
|
|
|
|
|
|
|
|
|
const thumbConfig: Required<ThumbnailConfig> = {
|
|
|
|
|
|
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');
|
|
|
|
|
|
}
|
2025-11-09 10:40:35 +03:00
|
|
|
|
|
|
|
|
|
|
// 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');
|
|
|
|
|
|
}
|
2025-11-08 19:41:20 +03:00
|
|
|
|
|
|
|
|
|
|
// Generate MPD manifest
|
|
|
|
|
|
reportProgress('manifest', 95, 'Finalizing manifest...');
|
|
|
|
|
|
|
|
|
|
|
|
// Note: manifestPath is already created by MP4Box in packageToDash
|
|
|
|
|
|
// No need for separate generateManifest function
|
|
|
|
|
|
|
|
|
|
|
|
reportProgress('complete', 100, 'Conversion complete!');
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
manifestPath,
|
|
|
|
|
|
videoPaths,
|
|
|
|
|
|
thumbnailSpritePath,
|
|
|
|
|
|
thumbnailVttPath,
|
2025-11-09 10:40:35 +03:00
|
|
|
|
posterPath,
|
2025-11-08 19:41:20 +03:00
|
|
|
|
duration: metadata.duration,
|
|
|
|
|
|
profiles,
|
2025-11-11 21:07:51 +03:00
|
|
|
|
usedNvenc: willUseNvenc,
|
|
|
|
|
|
codecType: codec
|
2025-11-08 19:41:20 +03:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|