feat: Обновленая реализация CLI
This commit is contained in:
395
src/core/packaging.ts
Normal file
395
src/core/packaging.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user