Files
dvc-cli/src/packaging.ts

136 lines
4.0 KiB
TypeScript
Raw Normal View History

2025-11-08 19:41:20 +03:00
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<string, string>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number
): Promise<string> {
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<void> {
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<void> {
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');
}