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 } from '../types'; import { checkFFmpeg, checkMP4Box, checkNvenc, 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, 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, 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, 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 warnings:'); for (const error of result.errors) { console.warn(` - ${error}`); } console.warn(''); } profiles = result.profiles; if (profiles.length === 0) { throw new Error('No valid profiles found in custom list. Check warnings 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); reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined); // Video codec selection const videoCodec = willUseNvenc ? 'h264_nvenc' : 'libx264'; const codecPreset = willUseNvenc ? 'p4' : 'medium'; const maxConcurrent = willUseNvenc ? 3 : 2; // STAGE 1: Encode profiles to MP4 (parallel - heavy work) reportProgress('encoding', 25, `Stage 1: Encoding ${profiles.length} profiles to MP4...`); const tempMP4Paths = await encodeProfilesToMP4( input, tempDir, profiles, videoCodec, codecPreset, metadata.duration, segmentDuration, metadata.fps || 25, // Use detected FPS or default to 25 metadata.audioBitrate, // Source audio bitrate for smart selection parallel, maxConcurrent, undefined, // optimizations - for future use (profileName, percent) => { const profileIndex = profiles.findIndex(p => p.name === profileName); const baseProgress = 25 + (profileIndex / profiles.length) * 40; const profileProgress = (percent / 100) * (40 / profiles.length); reportProgress('encoding', baseProgress + profileProgress, `Encoding ${profileName}...`, profileName); // Also report individual profile progress if (onProgress) { onProgress({ stage: 'encoding', percent: baseProgress + profileProgress, currentProfile: profileName, profilePercent: percent, // Actual profile progress 0-100 message: `Encoding ${profileName}...` }); } } ); reportProgress('encoding', 65, 'Stage 1 complete: All 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( tempMP4Paths, videoOutputDir, profiles, segmentDuration ); const videoPaths = Array.from(tempMP4Paths.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 }; }