396 lines
12 KiB
TypeScript
396 lines
12 KiB
TypeScript
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;
|
|
}
|