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

345 lines
9.9 KiB
TypeScript
Raw Normal View History

2025-11-08 19:41:20 +03:00
import { join, basename, extname } from 'node:path';
import { randomUUID } from 'node:crypto';
import { rm } from 'node:fs/promises';
import type {
DashConvertOptions,
DashConvertResult,
VideoProfile,
ThumbnailConfig,
2025-11-11 21:07:51 +03:00
ConversionProgress,
CodecType
2025-11-09 01:28:42 +03:00
} from '../types';
2025-11-08 19:41:20 +03:00
import {
checkFFmpeg,
checkMP4Box,
checkNvenc,
2025-11-11 21:07:51 +03:00
checkAV1Support,
2025-11-08 19:41:20 +03:00
getVideoMetadata,
ensureDir
2025-11-09 01:28:42 +03:00
} from '../utils';
2025-11-09 10:40:35 +03:00
import { selectProfiles, createProfilesFromStrings } from '../config/profiles';
import { generateThumbnailSprite, generatePoster } from './thumbnails';
2025-11-08 19:41:20 +03:00
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,
2025-11-09 10:40:35 +03:00
customProfiles,
2025-11-11 21:07:51 +03:00
codec = 'dual',
2025-11-08 19:41:20 +03:00
useNvenc,
generateThumbnails = true,
thumbnailConfig = {},
2025-11-09 10:40:35 +03:00
generatePoster: shouldGeneratePoster = true,
posterTimecode = '00:00:01',
2025-11-08 19:41:20 +03:00
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,
2025-11-09 10:40:35 +03:00
customProfiles,
2025-11-11 21:07:51 +03:00
codec,
2025-11-08 19:41:20 +03:00
useNvenc,
generateThumbnails,
thumbnailConfig,
2025-11-09 10:40:35 +03:00
shouldGeneratePoster,
posterTimecode,
2025-11-08 19:41:20 +03:00
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,
2025-11-09 10:40:35 +03:00
customProfiles: string[] | undefined,
2025-11-11 21:07:51 +03:00
codec: CodecType,
2025-11-08 19:41:20 +03:00
useNvenc: boolean | undefined,
generateThumbnails: boolean,
thumbnailConfig: ThumbnailConfig,
2025-11-09 10:40:35 +03:00
generatePosterFlag: boolean,
posterTimecode: string,
2025-11-08 19:41:20 +03:00
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
2025-11-09 10:40:35 +03:00
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:');
2025-11-09 10:40:35 +03:00
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('');
}
2025-11-09 10:40:35 +03:00
profiles = result.profiles;
if (profiles.length === 0) {
throw new Error('No valid profiles found in custom list. Check errors above.');
2025-11-09 10:40:35 +03:00
}
} 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
);
}
2025-11-08 19:41:20 +03:00
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);
2025-11-09 10:40:35 +03:00
// Clean up previous conversion if exists
try {
await rm(videoOutputDir, { recursive: true, force: true });
} catch (err) {
// Directory might not exist, that's fine
}
2025-11-08 19:41:20 +03:00
await ensureDir(videoOutputDir);
2025-11-11 21:07:51 +03:00
// 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);
2025-11-08 19:41:20 +03:00
const maxConcurrent = willUseNvenc ? 3 : 2;
2025-11-11 21:07:51 +03:00
// STAGE 1: Encode profiles to MP4 for each codec (parallel - heavy work)
const codecMP4Paths = new Map<'h264' | 'av1', Map<string, string>>();
2025-11-08 19:41:20 +03:00
2025-11-11 21:07:51 +03:00
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}...`
});
}
2025-11-08 19:41:20 +03:00
}
2025-11-11 21:07:51 +03:00
);
codecMP4Paths.set(type, tempMP4Paths);
}
2025-11-08 19:41:20 +03:00
2025-11-11 21:07:51 +03:00
reportProgress('encoding', 65, 'Stage 1 complete: All codecs and profiles encoded');
2025-11-08 19:41:20 +03:00
// STAGE 2: Package to DASH using MP4Box (light work, fast)
reportProgress('encoding', 70, `Stage 2: Creating DASH with MP4Box...`);
const manifestPath = await packageToDash(
2025-11-11 21:07:51 +03:00
codecMP4Paths,
2025-11-08 19:41:20 +03:00
videoOutputDir,
profiles,
2025-11-11 21:07:51 +03:00
segmentDuration,
codec
2025-11-08 19:41:20 +03:00
);
2025-11-11 21:07:51 +03:00
// Collect all video paths from all codecs
const videoPaths: string[] = [];
for (const mp4Paths of codecMP4Paths.values()) {
videoPaths.push(...Array.from(mp4Paths.values()));
}
2025-11-08 19:41:20 +03:00
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');
}
2025-11-09 10:40:35 +03:00
// 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');
}
2025-11-08 19:41:20 +03:00
// 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,
2025-11-09 10:40:35 +03:00
posterPath,
2025-11-08 19:41:20 +03:00
duration: metadata.duration,
profiles,
2025-11-11 21:07:51 +03:00
usedNvenc: willUseNvenc,
codecType: codec
2025-11-08 19:41:20 +03:00
};
}