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 } from '../types'; import { checkFFmpeg, checkMP4Box, checkNvenc, checkAV1Support, getVideoMetadata, ensureDir } from '../utils'; import { selectProfiles, createProfilesFromStrings } from '../config/profiles'; import { generateThumbnailSprite, generatePoster } from './thumbnails'; 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 { const { input, outputDir, segmentDuration = 2, profiles: userProfiles, customProfiles, codec = 'dual', useNvenc, generateThumbnails = true, thumbnailConfig = {}, generatePoster: shouldGeneratePoster = true, posterTimecode = '00:00:01', 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, customProfiles, codec, useNvenc, generateThumbnails, thumbnailConfig, shouldGeneratePoster, posterTimecode, 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, customProfiles: string[] | undefined, codec: CodecType, useNvenc: boolean | undefined, 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); // 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 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 = 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); const maxConcurrent = willUseNvenc ? 3 : 2; // 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)...`); 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}...` }); } } ); codecMP4Paths.set(type, tempMP4Paths); } reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded'); // STAGE 2: Package to DASH using MP4Box (light work, fast) reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`); const manifestPath = await packageToDash( codecMP4Paths, videoOutputDir, profiles, segmentDuration, codec ); // 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: DASH created'); // 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'); } // 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, posterPath, duration: metadata.duration, profiles, usedNvenc: willUseNvenc, codecType: codec }; }