Files
dvc-cli/src/core/converter.ts

352 lines
10 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
CodecType,
StreamingFormat
} from '../types';
import {
checkFFmpeg,
checkMP4Box,
checkNvenc,
checkAV1Support,
getVideoMetadata,
ensureDir
} from '../utils';
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
import { generateThumbnailSprite, generatePoster } from './thumbnails';
import { encodeProfilesToMP4 } from './encoding';
import { packageToFormats } 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,
customProfiles,
codec = 'dual',
format = 'both',
useNvenc,
generateThumbnails = true,
thumbnailConfig = {},
generatePoster: shouldGeneratePoster = true,
posterTimecode = '00:00:01',
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,
customProfiles,
codec,
format,
useNvenc,
generateThumbnails,
thumbnailConfig,
shouldGeneratePoster,
posterTimecode,
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,
customProfiles: string[] | undefined,
codec: CodecType,
format: StreamingFormat,
useNvenc: boolean | undefined,
generateThumbnails: boolean,
thumbnailConfig: ThumbnailConfig,
generatePosterFlag: boolean,
posterTimecode: string,
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
let profiles: VideoProfile[];
if (customProfiles && customProfiles.length > 0) {
// User specified custom profiles via CLI
const result = createProfilesFromStrings(
customProfiles,
metadata.width,
metadata.height,
metadata.fps,
metadata.videoBitrate
);
// Show errors if any
if (result.errors.length > 0) {
console.warn('\n❌ Profile errors:');
for (const error of result.errors) {
console.warn(` - ${error}`);
}
console.warn('');
}
// Show warnings if any
if (result.warnings.length > 0) {
console.warn('\n⚠ Profile warnings:');
for (const warning of result.warnings) {
console.warn(` - ${warning}`);
}
console.warn('');
}
profiles = result.profiles;
if (profiles.length === 0) {
throw new Error('No valid profiles found in custom list. Check errors above.');
}
} else if (userProfiles) {
// Programmatic API usage
profiles = userProfiles;
} else {
// Default: auto-select based on source
profiles = selectProfiles(
metadata.width,
metadata.height,
metadata.fps,
metadata.videoBitrate
);
}
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);
// Clean up previous conversion if exists
try {
await rm(videoOutputDir, { recursive: true, force: true });
} catch (err) {
// Directory might not exist, that's fine
}
await ensureDir(videoOutputDir);
// Determine which codecs to use based on codec parameter
const codecs: Array<{ type: 'h264' | 'av1'; codec: string; preset: string }> = [];
if (codec === 'h264' || codec === 'dual') {
const h264Codec = willUseNvenc ? 'h264_nvenc' : 'libx264';
const h264Preset = willUseNvenc ? 'p4' : 'medium';
codecs.push({ type: 'h264', codec: h264Codec, preset: h264Preset });
}
if (codec === 'av1' || codec === 'dual') {
// Check for AV1 hardware encoder
const av1Support = await checkAV1Support();
const av1Codec = av1Support.available ? av1Support.encoder! : 'libsvtav1';
const av1Preset = av1Support.available ? (av1Codec === 'av1_nvenc' ? 'p4' : 'medium') : '8';
codecs.push({ type: 'av1', codec: av1Codec, preset: av1Preset });
}
const codecNames = codecs.map(c => c.type.toUpperCase()).join(' + ');
reportProgress('analyzing', 20, `Using ${codecNames} encoding (${willUseNvenc ? 'GPU' : 'CPU'})`, undefined);
const maxConcurrent = willUseNvenc ? 3 : 2;
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
for (let codecIndex = 0; codecIndex < codecs.length; codecIndex++) {
const { type, codec: videoCodec, preset: codecPreset } = codecs[codecIndex];
const codecProgress = codecIndex / codecs.length;
const codecProgressRange = 1 / codecs.length;
reportProgress('encoding', 25 + codecProgress * 40, `Stage 1: Encoding ${type.toUpperCase()} (${profiles.length} profiles)...`);
const tempMP4Paths = await encodeProfilesToMP4(
input,
tempDir,
profiles,
videoCodec,
codecPreset,
metadata.duration,
segmentDuration,
metadata.audioBitrate,
parallel,
maxConcurrent,
type, // Pass codec type to differentiate output files
undefined, // optimizations - for future use
(profileName, percent) => {
const profileIndex = profiles.findIndex(p => p.name === profileName);
const baseProgress = 25 + codecProgress * 40;
const profileProgress = (percent / 100) * (40 * codecProgressRange / profiles.length);
reportProgress('encoding', baseProgress + profileProgress, `Encoding ${type.toUpperCase()} ${profileName}...`, `${type}-${profileName}`);
// Also report individual profile progress
if (onProgress) {
onProgress({
stage: 'encoding',
percent: baseProgress + profileProgress,
currentProfile: `${type}-${profileName}`,
profilePercent: percent,
message: `Encoding ${type.toUpperCase()} ${profileName}...`
});
}
}
);
codecMP4Paths.set(type, tempMP4Paths);
}
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
// STAGE 2: Package to segments and manifests (unified, no duplication)
reportProgress('encoding', 70, `Stage 2: Creating segments and manifests...`);
const { manifestPath, hlsManifestPath } = await packageToFormats(
codecMP4Paths,
videoOutputDir,
profiles,
segmentDuration,
codec,
format
);
// Collect all video paths from all codecs
const videoPaths: string[] = [];
for (const mp4Paths of codecMP4Paths.values()) {
videoPaths.push(...Array.from(mp4Paths.values()));
}
reportProgress('encoding', 80, 'Stage 2 complete: All formats packaged');
// 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 poster
let posterPath: string | undefined;
if (generatePosterFlag) {
reportProgress('thumbnails', 92, 'Generating poster image...');
posterPath = await generatePoster(
input,
videoOutputDir,
posterTimecode
);
reportProgress('thumbnails', 95, 'Poster generated');
}
// Finalize
reportProgress('manifest', 95, 'Finalizing...');
// Note: manifestPath/hlsManifestPath are already created by MP4Box in packageToDash/packageToHLS
// No need for separate generateManifest function
reportProgress('complete', 100, 'Conversion complete!');
return {
manifestPath,
hlsManifestPath,
videoPaths,
thumbnailSpritePath,
thumbnailVttPath,
posterPath,
duration: metadata.duration,
profiles,
usedNvenc: willUseNvenc,
codecType: codec,
format
};
}