import { join } from 'node:path'; import { execMP4Box } from '../utils'; import type { VideoProfile, CodecType, StreamingFormat } from '../types'; import { readdir, rename, mkdir, writeFile } from 'node:fs/promises'; import { validateAndFixManifest, updateManifestPaths, separateCodecAdaptationSets, updateHLSManifestPaths, generateHLSMediaPlaylist, generateHLSMasterPlaylist } from './manifest'; /** * Package MP4 files into DASH format using MP4Box * Stage 2: Light work - just packaging, no encoding * Creates one master MPD manifest with all profiles and codecs */ export async function packageToDash( codecMP4Files: Map<'h264' | 'av1', Map>, outputDir: string, profiles: VideoProfile[], segmentDuration: number, codecType: CodecType, hasAudio: boolean ): Promise { const manifestPath = join(outputDir, 'manifest.mpd'); // Build MP4Box command const args = [ '-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds '-frag', String(segmentDuration * 1000), '-rap', // Force segments to start with random access points '-segment-timeline', // Use SegmentTimeline for accurate segment durations '-segment-name', '$RepresentationID$_$Number$', '-out', manifestPath ]; // Add all MP4 files for each codec let firstFile = true; for (const [codec, mp4Files] of codecMP4Files.entries()) { for (const profile of profiles) { const mp4Path = mp4Files.get(profile.name); if (!mp4Path) { throw new Error(`MP4 file not found for profile: ${profile.name}, codec: ${codec}`); } // Representation ID includes codec: e.g., "720p-h264", "720p-av1" const representationId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name; // Add video track with representation ID args.push(`${mp4Path}#video:id=${representationId}`); // Add audio track only once (shared across all profiles and codecs) if (firstFile && hasAudio) { args.push(`${mp4Path}#audio:id=audio`); firstFile = false; } } } // Execute MP4Box // Note: We separate codecs into different AdaptationSets manually via separateCodecAdaptationSets() await execMP4Box(args); // 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); // Update MPD to reflect new file structure with subdirectories await updateManifestPaths(manifestPath, profiles, codecType); // For dual-codec mode, separate H.264 and AV1 into different AdaptationSets if (codecType === 'dual') { await separateCodecAdaptationSets(manifestPath); } // Validate and fix XML structure (ensure all tags are properly closed) await validateAndFixManifest(manifestPath); return manifestPath; } /** * Organize segments into profile subdirectories * MP4Box creates all files in one directory, we organize them */ async function organizeSegments( outputDir: string, profiles: VideoProfile[], codecType: CodecType, hasAudio: boolean ): Promise { 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 representationIds: string[] = []; for (const codec of codecs) { for (const profile of profiles) { const repId = codecType === 'dual' ? `${profile.name}-${codec}` : profile.name; representationIds.push(repId); const profileDir = join(outputDir, repId); await mkdir(profileDir, { recursive: true }); } } // Create audio subdirectory const audioDir = join(outputDir, 'audio'); if (hasAudio) { await mkdir(audioDir, { recursive: true }); } // Get all files in output directory const files = await readdir(outputDir); // Move segment files to their respective directories for (const file of files) { // Skip manifest if (file === 'manifest.mpd') { continue; } // Move audio files to audio/ directory if (hasAudio && (file.startsWith('audio_') || file === 'audio_init.m4s')) { const oldPath = join(outputDir, file); const newPath = join(audioDir, file); await rename(oldPath, newPath); continue; } // Move video segment files to their representation directories for (const repId of representationIds) { if (file.startsWith(`${repId}_`)) { const oldPath = join(outputDir, file); const newPath = join(outputDir, repId, file); await rename(oldPath, newPath); break; } } } } /** * Package MP4 files into HLS format using MP4Box * Stage 2: Light work - just packaging, no encoding * Creates master.m3u8 playlist with H.264 profiles only (for Safari/iOS compatibility) */ export async function packageToHLS( codecMP4Files: Map<'h264' | 'av1', Map>, outputDir: string, profiles: VideoProfile[], segmentDuration: number, codecType: CodecType ): Promise { const manifestPath = join(outputDir, 'master.m3u8'); // Build MP4Box command for HLS const args = [ '-dash', String(segmentDuration * 1000), // MP4Box expects milliseconds '-frag', String(segmentDuration * 1000), '-rap', // Force segments to start with random access points '-segment-timeline', // Use SegmentTimeline for accurate segment durations '-segment-name', '$RepresentationID$_$Number$', '-profile', 'live', // HLS mode instead of DASH '-out', manifestPath ]; // For HLS, use only H.264 codec (Safari/iOS compatibility) const h264Files = codecMP4Files.get('h264'); if (!h264Files) { throw new Error('H.264 codec files not found. HLS requires H.264 for Safari/iOS compatibility.'); } let firstFile = true; for (const profile of profiles) { const mp4Path = h264Files.get(profile.name); if (!mp4Path) { 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; // Add video track with representation ID args.push(`${mp4Path}#video:id=${representationId}`); // Add audio track only once (shared across all profiles) if (firstFile) { args.push(`${mp4Path}#audio:id=audio`); firstFile = false; } } // Execute MP4Box await execMP4Box(args); // MP4Box creates files in the same directory as output manifest // Move segment files to profile subdirectories for clean structure await organizeSegmentsHLS(outputDir, profiles); // Update manifest to reflect new file structure with subdirectories await updateHLSManifestPaths(manifestPath, profiles); return manifestPath; } /** * Organize HLS segments into profile subdirectories * HLS only uses H.264, so no codec suffix in directory names */ async function organizeSegmentsHLS( outputDir: string, profiles: VideoProfile[] ): Promise { const representationIds: string[] = []; for (const profile of profiles) { const repId = profile.name; // Just profile name, no codec representationIds.push(repId); const profileDir = join(outputDir, repId); await mkdir(profileDir, { recursive: true }); } // Create audio subdirectory const audioDir = join(outputDir, 'audio'); await mkdir(audioDir, { recursive: true }); // Get all files in output directory const files = await readdir(outputDir); // Move segment files to their respective directories for (const file of files) { // Skip manifest if (file === 'master.m3u8') { continue; } // Move audio files to audio/ directory if (file.startsWith('audio_') || file === 'audio_init.m4s') { const oldPath = join(outputDir, file); const newPath = join(audioDir, file); await rename(oldPath, newPath); continue; } // Move video segment files to their representation directories for (const repId of representationIds) { if (file.startsWith(`${repId}_`)) { const oldPath = join(outputDir, file); const newPath = join(outputDir, repId, file); await rename(oldPath, newPath); break; } } } } /** * Unified packaging: creates segments once and generates both DASH and HLS manifests * No duplication - segments stored in {profile}-{codec}/ folders */ export async function packageToFormats( codecMP4Files: Map<'h264' | 'av1', Map>, outputDir: string, profiles: VideoProfile[], segmentDuration: number, codec: CodecType, format: 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); } // Step 2: Generate HLS playlists from existing segments if (format === 'hls' || format === 'both') { // HLS generation from segments hlsManifestPath = await generateHLSPlaylists( outputDir, profiles, segmentDuration, codec, hasAudio ); } return { manifestPath, hlsManifestPath }; } /** * Generate HLS playlists (media playlists in folders + master in root) */ async function generateHLSPlaylists( outputDir: string, profiles: VideoProfile[], segmentDuration: number, codecType: CodecType, hasAudio: boolean ): Promise { const masterPlaylistPath = join(outputDir, 'master.m3u8'); const variants: Array<{ path: string; bandwidth: number; resolution: string; fps: number }> = []; // Generate media playlist for each H.264 profile for (const profile of profiles) { const profileDir = codecType === 'dual' ? `${profile.name}-h264` : profile.name; const profilePath = join(outputDir, profileDir); // Read segment files from profile directory const files = await readdir(profilePath); const segmentFiles = files .filter(f => f.endsWith('.m4s')) .sort((a, b) => { const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0'); const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0'); return numA - numB; }); const initFile = files.find(f => f.endsWith('_.mp4')); if (!initFile || segmentFiles.length === 0) { continue; // Skip if no segments found } // Generate media playlist content using manifest module const playlistContent = generateHLSMediaPlaylist(segmentFiles, initFile, segmentDuration); // Write media playlist const playlistPath = join(profilePath, 'playlist.m3u8'); await writeFile(playlistPath, playlistContent, 'utf-8'); // Add to variants list const bandwidth = parseInt(profile.videoBitrate) * 1000; variants.push({ path: `${profileDir}/playlist.m3u8`, bandwidth, resolution: `${profile.width}x${profile.height}`, fps: profile.fps || 30 }); } // Generate audio media playlist (only if source has audio) let audioInit: string | undefined; let audioSegments: string[] = []; if (hasAudio) { const audioDir = join(outputDir, 'audio'); let audioFiles: string[] = []; try { audioFiles = await readdir(audioDir); } catch { audioFiles = []; } audioSegments = audioFiles .filter(f => f.endsWith('.m4s')) .sort((a, b) => { const numA = parseInt(a.match(/_(\d+)\.m4s$/)?.[1] || '0'); const numB = parseInt(b.match(/_(\d+)\.m4s$/)?.[1] || '0'); return numA - numB; }); audioInit = audioFiles.find(f => f.endsWith('_.mp4')); if (audioInit && audioSegments.length > 0) { const audioPlaylistContent = generateHLSMediaPlaylist(audioSegments, audioInit, segmentDuration); await writeFile(join(audioDir, 'playlist.m3u8'), audioPlaylistContent, 'utf-8'); } } // Generate master playlist using manifest module const masterContent = generateHLSMasterPlaylist( variants, hasAudio && audioInit !== undefined && audioSegments.length > 0 ); await writeFile(masterPlaylistPath, masterContent, 'utf-8'); return masterPlaylistPath; }