Files
create-vod/src/core/packaging.ts

396 lines
12 KiB
TypeScript
Raw Normal View History

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<string, string>>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType,
hasAudio: boolean
): 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-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<void> {
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<string, string>>,
outputDir: string,
profiles: VideoProfile[],
segmentDuration: number,
codecType: CodecType
): Promise<string> {
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<void> {
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<string, string>>,
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<string> {
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;
}