import { join } from 'node:path'; import { execMP4Box } from './utils'; import type { VideoProfile } from './types'; /** * Package MP4 files into DASH format using MP4Box * Stage 2: Light work - just packaging, no encoding * Creates one master MPD manifest with all profiles */ export async function packageToDash( mp4Files: Map, outputDir: string, profiles: VideoProfile[], segmentDuration: number ): 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-name', '$RepresentationID$_$Number$', '-out', manifestPath ]; // Add all MP4 files with their profile IDs for (const profile of profiles) { const mp4Path = mp4Files.get(profile.name); if (!mp4Path) { throw new Error(`MP4 file not found for profile: ${profile.name}`); } // Add video track with representation ID args.push(`${mp4Path}#video:id=${profile.name}`); // Add audio track (shared across all profiles) if (profile === profiles[0]) { args.push(`${mp4Path}#audio:id=audio`); } } // Execute MP4Box 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); // Update MPD to reflect new file structure with subdirectories await updateManifestPaths(manifestPath, profiles); return manifestPath; } /** * Organize segments into profile subdirectories * MP4Box creates all files in one directory, we organize them */ async function organizeSegments( outputDir: string, profiles: VideoProfile[] ): Promise { const { readdir, rename, mkdir } = await import('node:fs/promises'); // Create profile subdirectories for (const profile of profiles) { const profileDir = join(outputDir, profile.name); 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 === 'manifest.mpd') { 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 profile directories for (const profile of profiles) { if (file.startsWith(`${profile.name}_`)) { const oldPath = join(outputDir, file); const newPath = join(outputDir, profile.name, file); await rename(oldPath, newPath); break; } } } } /** * Update MPD manifest to reflect subdirectory structure */ async function updateManifestPaths( manifestPath: string, profiles: VideoProfile[] ): Promise { const { readFile, writeFile } = await import('node:fs/promises'); let mpd = await readFile(manifestPath, 'utf-8'); // MP4Box uses $RepresentationID$ template variable // Replace: media="$RepresentationID$_$Number$.m4s" // With: media="$RepresentationID$/$RepresentationID$_$Number$.m4s" mpd = mpd.replace( /media="\$RepresentationID\$_\$Number\$\.m4s"/g, 'media="$RepresentationID$/$RepresentationID$_$Number$.m4s"' ); // Replace: initialization="$RepresentationID$_.mp4" // With: initialization="$RepresentationID$/$RepresentationID$_.mp4" mpd = mpd.replace( /initialization="\$RepresentationID\$_\.mp4"/g, 'initialization="$RepresentationID$/$RepresentationID$_.mp4"' ); await writeFile(manifestPath, mpd, 'utf-8'); }