init
This commit is contained in:
229
src/converter.ts
Normal file
229
src/converter.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
import { join, basename, extname } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import type {
|
||||
DashConvertOptions,
|
||||
DashConvertResult,
|
||||
VideoProfile,
|
||||
ThumbnailConfig,
|
||||
ConversionProgress
|
||||
} from './types';
|
||||
import {
|
||||
checkFFmpeg,
|
||||
checkMP4Box,
|
||||
checkNvenc,
|
||||
getVideoMetadata,
|
||||
ensureDir
|
||||
} from './utils';
|
||||
import { selectProfiles } from './profiles';
|
||||
import { generateThumbnailSprite } from './thumbnails';
|
||||
import { encodeProfilesToMP4 } from './encoding';
|
||||
import { packageToDash } from './packaging';
|
||||
|
||||
/**
|
||||
* Convert video to DASH format with NVENC acceleration
|
||||
* Two-stage approach: FFmpeg encoding → MP4Box packaging
|
||||
*/
|
||||
export async function convertToDash(
|
||||
options: DashConvertOptions
|
||||
): Promise<DashConvertResult> {
|
||||
const {
|
||||
input,
|
||||
outputDir,
|
||||
segmentDuration = 2,
|
||||
profiles: userProfiles,
|
||||
useNvenc,
|
||||
generateThumbnails = true,
|
||||
thumbnailConfig = {},
|
||||
parallel = true,
|
||||
onProgress
|
||||
} = options;
|
||||
|
||||
// Create unique temp directory
|
||||
const tempDir = join('/tmp', `dash-converter-${randomUUID()}`);
|
||||
await ensureDir(tempDir);
|
||||
|
||||
try {
|
||||
return await convertToDashInternal(
|
||||
input,
|
||||
outputDir,
|
||||
tempDir,
|
||||
segmentDuration,
|
||||
userProfiles,
|
||||
useNvenc,
|
||||
generateThumbnails,
|
||||
thumbnailConfig,
|
||||
parallel,
|
||||
onProgress
|
||||
);
|
||||
} finally {
|
||||
// Cleanup temp directory
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
} catch (err) {
|
||||
console.warn(`Warning: Failed to cleanup temp directory: ${tempDir}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal conversion logic
|
||||
*/
|
||||
async function convertToDashInternal(
|
||||
input: string,
|
||||
outputDir: string,
|
||||
tempDir: string,
|
||||
segmentDuration: number,
|
||||
userProfiles: VideoProfile[] | undefined,
|
||||
useNvenc: boolean | undefined,
|
||||
generateThumbnails: boolean,
|
||||
thumbnailConfig: ThumbnailConfig,
|
||||
parallel: boolean,
|
||||
onProgress?: (progress: ConversionProgress) => void
|
||||
): Promise<DashConvertResult> {
|
||||
|
||||
// Validate dependencies
|
||||
if (!await checkFFmpeg()) {
|
||||
throw new Error('FFmpeg is not installed or not in PATH');
|
||||
}
|
||||
|
||||
if (!await checkMP4Box()) {
|
||||
throw new Error('MP4Box is not installed or not in PATH. Install gpac package.');
|
||||
}
|
||||
|
||||
// Report progress
|
||||
const reportProgress = (stage: ConversionProgress['stage'], percent: number, message?: string, currentProfile?: string) => {
|
||||
if (onProgress) {
|
||||
onProgress({ stage, percent, message, currentProfile });
|
||||
}
|
||||
};
|
||||
|
||||
reportProgress('analyzing', 0, 'Analyzing input video...');
|
||||
|
||||
// Get video metadata
|
||||
const metadata = await getVideoMetadata(input);
|
||||
|
||||
// Check NVENC availability
|
||||
const nvencAvailable = useNvenc !== false ? await checkNvenc() : false;
|
||||
const willUseNvenc = useNvenc === true ? true : (useNvenc === false ? false : nvencAvailable);
|
||||
|
||||
if (useNvenc === true && !nvencAvailable) {
|
||||
throw new Error('NVENC requested but not available. Check NVIDIA drivers and GPU support.');
|
||||
}
|
||||
|
||||
// Select profiles
|
||||
const profiles = userProfiles || selectProfiles(metadata.width, metadata.height);
|
||||
|
||||
if (profiles.length === 0) {
|
||||
throw new Error('No suitable profiles found for input video resolution');
|
||||
}
|
||||
|
||||
// Create video name directory
|
||||
const inputBasename = basename(input, extname(input));
|
||||
const videoOutputDir = join(outputDir, inputBasename);
|
||||
await ensureDir(videoOutputDir);
|
||||
|
||||
reportProgress('analyzing', 20, `Using ${willUseNvenc ? 'NVENC' : 'CPU'} encoding`, undefined);
|
||||
|
||||
// Video codec selection
|
||||
const videoCodec = willUseNvenc ? 'h264_nvenc' : 'libx264';
|
||||
const codecPreset = willUseNvenc ? 'p4' : 'medium';
|
||||
const maxConcurrent = willUseNvenc ? 3 : 2;
|
||||
|
||||
// STAGE 1: Encode profiles to MP4 (parallel - heavy work)
|
||||
reportProgress('encoding', 25, `Stage 1: Encoding ${profiles.length} profiles to MP4...`);
|
||||
|
||||
const tempMP4Paths = await encodeProfilesToMP4(
|
||||
input,
|
||||
tempDir,
|
||||
profiles,
|
||||
videoCodec,
|
||||
codecPreset,
|
||||
metadata.duration,
|
||||
segmentDuration,
|
||||
metadata.fps || 25, // Use detected FPS or default to 25
|
||||
metadata.audioBitrate, // Source audio bitrate for smart selection
|
||||
parallel,
|
||||
maxConcurrent,
|
||||
undefined, // optimizations - for future use
|
||||
(profileName, percent) => {
|
||||
const profileIndex = profiles.findIndex(p => p.name === profileName);
|
||||
const baseProgress = 25 + (profileIndex / profiles.length) * 40;
|
||||
const profileProgress = (percent / 100) * (40 / profiles.length);
|
||||
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${profileName}...`, profileName);
|
||||
|
||||
// Also report individual profile progress
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
stage: 'encoding',
|
||||
percent: baseProgress + profileProgress,
|
||||
currentProfile: profileName,
|
||||
profilePercent: percent, // Actual profile progress 0-100
|
||||
message: `Encoding ${profileName}...`
|
||||
});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
reportProgress('encoding', 65, 'Stage 1 complete: All profiles encoded');
|
||||
|
||||
// STAGE 2: Package to DASH using MP4Box (light work, fast)
|
||||
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
|
||||
|
||||
const manifestPath = await packageToDash(
|
||||
tempMP4Paths,
|
||||
videoOutputDir,
|
||||
profiles,
|
||||
segmentDuration
|
||||
);
|
||||
|
||||
const videoPaths = Array.from(tempMP4Paths.values());
|
||||
|
||||
reportProgress('encoding', 80, 'Stage 2 complete: DASH created');
|
||||
|
||||
// Generate thumbnails
|
||||
let thumbnailSpritePath: string | undefined;
|
||||
let thumbnailVttPath: string | undefined;
|
||||
|
||||
if (generateThumbnails) {
|
||||
reportProgress('thumbnails', 80, 'Generating thumbnail sprites...');
|
||||
|
||||
const thumbConfig: Required<ThumbnailConfig> = {
|
||||
width: thumbnailConfig.width || 160,
|
||||
height: thumbnailConfig.height || 90,
|
||||
interval: thumbnailConfig.interval || 1, // 1 секунда по умолчанию
|
||||
columns: thumbnailConfig.columns || 10
|
||||
};
|
||||
|
||||
const thumbResult = await generateThumbnailSprite(
|
||||
input,
|
||||
videoOutputDir,
|
||||
metadata.duration,
|
||||
thumbConfig
|
||||
);
|
||||
|
||||
thumbnailSpritePath = thumbResult.spritePath;
|
||||
thumbnailVttPath = thumbResult.vttPath;
|
||||
|
||||
reportProgress('thumbnails', 90, 'Thumbnails generated');
|
||||
}
|
||||
|
||||
// Generate MPD manifest
|
||||
reportProgress('manifest', 95, 'Finalizing manifest...');
|
||||
|
||||
// Note: manifestPath is already created by MP4Box in packageToDash
|
||||
// No need for separate generateManifest function
|
||||
|
||||
reportProgress('complete', 100, 'Conversion complete!');
|
||||
|
||||
return {
|
||||
manifestPath,
|
||||
videoPaths,
|
||||
thumbnailSpritePath,
|
||||
thumbnailVttPath,
|
||||
duration: metadata.duration,
|
||||
profiles,
|
||||
usedNvenc: willUseNvenc
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user