diff --git a/src/cli.ts b/src/cli.ts index 8dc5c8c..7923453 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,8 +10,9 @@ * dvc ./video.mp4 ./output */ -import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box } from './index'; +import { convertToDash, checkFFmpeg, checkNvenc, checkMP4Box, getVideoMetadata } from './index'; import cliProgress from 'cli-progress'; +import { statSync } from 'node:fs'; const input = process.argv[2]; const outputDir = process.argv[3] || './output'; @@ -41,8 +42,26 @@ if (!hasMP4Box) { process.exit(1); } -console.log(`📹 Input: ${input}`); -console.log(`📁 Output: ${outputDir}\n`); +// Get video metadata and file size +console.log('📊 Analyzing video...\n'); +const metadata = await getVideoMetadata(input); +const fileStats = statSync(input); +const fileSizeMB = (fileStats.size / (1024 * 1024)).toFixed(2); + +console.log('📹 Video Information:'); +console.log(` File: ${input}`); +console.log(` Size: ${fileSizeMB} MB`); +console.log(` Resolution: ${metadata.width}x${metadata.height}`); +console.log(` FPS: ${metadata.fps.toFixed(2)}`); +console.log(` Duration: ${Math.floor(metadata.duration / 60)}m ${Math.floor(metadata.duration % 60)}s`); +console.log(` Codec: ${metadata.codec}`); +if (metadata.videoBitrate) { + console.log(` Video Bitrate: ${(metadata.videoBitrate / 1000).toFixed(2)} Mbps`); +} +if (metadata.audioBitrate) { + console.log(` Audio Bitrate: ${metadata.audioBitrate} kbps`); +} +console.log(`\n📁 Output: ${outputDir}\n`); console.log('🚀 Starting conversion...\n'); // Create multibar container diff --git a/src/config/profiles.ts b/src/config/profiles.ts index fcb6e88..229289c 100644 --- a/src/config/profiles.ts +++ b/src/config/profiles.ts @@ -1,45 +1,156 @@ import type { VideoProfile } from '../types'; /** - * Default video quality profiles + * Get optimal BPP (Bits Per Pixel) based on resolution + * Lower resolutions need higher BPP for good quality + * Higher resolutions can use lower BPP due to more pixels + */ +function getBPP(width: number, height: number): number { + const pixels = width * height; + + if (pixels <= 640 * 360) return 0.08; // 360p - higher quality needed + if (pixels <= 854 * 480) return 0.075; // 480p + if (pixels <= 1280 * 720) return 0.07; // 720p + if (pixels <= 1920 * 1080) return 0.065; // 1080p + if (pixels <= 2560 * 1440) return 0.06; // 1440p (2K) + return 0.055; // 4K - lower BPP but still quality +} + +/** + * Calculate optimal video bitrate based on resolution and FPS + * Formula: width × height × fps × bpp + */ +function calculateBitrate( + width: number, + height: number, + fps: number = 30, + maxBitrate?: number +): string { + const bpp = getBPP(width, height); + let bitrate = Math.round((width * height * fps * bpp) / 1000); + + // Don't exceed source bitrate (no point in upscaling quality) + if (maxBitrate && bitrate > maxBitrate) { + bitrate = maxBitrate; + } + + return `${bitrate}k`; +} + +/** + * Default video quality profiles for 30 FPS */ export const DEFAULT_PROFILES: VideoProfile[] = [ { - name: '1080p', - width: 1920, - height: 1080, - videoBitrate: '5000k', - audioBitrate: '256k' - }, - { - name: '720p', - width: 1280, - height: 720, - videoBitrate: '3000k', - audioBitrate: '256k' + name: '360p', + width: 640, + height: 360, + videoBitrate: calculateBitrate(640, 360, 30), + audioBitrate: '192k' }, { name: '480p', width: 854, height: 480, - videoBitrate: '1500k', + videoBitrate: calculateBitrate(854, 480, 30), + audioBitrate: '192k' + }, + { + name: '720p', + width: 1280, + height: 720, + videoBitrate: calculateBitrate(1280, 720, 30), + audioBitrate: '192k' + }, + { + name: '1080p', + width: 1920, + height: 1080, + videoBitrate: calculateBitrate(1920, 1080, 30), audioBitrate: '256k' }, { - name: '360p', - width: 640, - height: 360, - videoBitrate: '800k', + name: '1440p', + width: 2560, + height: 1440, + videoBitrate: calculateBitrate(2560, 1440, 30), + audioBitrate: '256k' + }, + { + name: '4K', + width: 3840, + height: 2160, + videoBitrate: calculateBitrate(3840, 2160, 30), audioBitrate: '256k' } ]; /** - * Select appropriate profiles based on input video resolution + * Select appropriate profiles based on input video resolution and FPS + * Only creates profiles that are equal to or smaller than input resolution + * Creates high FPS variants if source supports it (according to FEATURES.md): + * - 60 FPS versions if source >= 45 FPS + * - 90 FPS versions if source >= 75 FPS + * - 120 FPS versions if source >= 95 FPS */ -export function selectProfiles(inputWidth: number, inputHeight: number): VideoProfile[] { - return DEFAULT_PROFILES.filter(profile => { +export function selectProfiles( + inputWidth: number, + inputHeight: number, + inputFPS: number = 30, + sourceBitrate?: number +): VideoProfile[] { + const profiles: VideoProfile[] = []; + + // Standard 30 FPS profiles (always created) + const baseProfiles = DEFAULT_PROFILES.filter(profile => { return profile.width <= inputWidth && profile.height <= inputHeight; }); + + // Add standard 30fps profiles with bitrate limit + for (const profile of baseProfiles) { + profiles.push({ + ...profile, + videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate) + }); + } + + // Add 60 FPS profiles if source >= 45 FPS + if (inputFPS >= 45) { + for (const profile of baseProfiles) { + profiles.push(createHighFPSProfile(profile, 60, sourceBitrate)); + } + } + + // Add 90 FPS profiles if source >= 75 FPS + if (inputFPS >= 75) { + for (const profile of baseProfiles) { + profiles.push(createHighFPSProfile(profile, 90, sourceBitrate)); + } + } + + // Add 120 FPS profiles if source >= 95 FPS + if (inputFPS >= 95) { + for (const profile of baseProfiles) { + profiles.push(createHighFPSProfile(profile, 120, sourceBitrate)); + } + } + + return profiles; +} + +/** + * Create high FPS profile variant + * Used for creating 60fps, 90fps, 120fps versions + */ +export function createHighFPSProfile( + baseProfile: VideoProfile, + fps: number, + maxBitrate?: number +): VideoProfile { + return { + ...baseProfile, + name: `${baseProfile.name}-${fps}`, + videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, fps, maxBitrate) + }; } diff --git a/src/core/converter.ts b/src/core/converter.ts index bab19e9..ee8580f 100644 --- a/src/core/converter.ts +++ b/src/core/converter.ts @@ -112,7 +112,12 @@ async function convertToDashInternal( } // Select profiles - const profiles = userProfiles || selectProfiles(metadata.width, metadata.height); + const profiles = userProfiles || selectProfiles( + metadata.width, + metadata.height, + metadata.fps, + metadata.videoBitrate + ); if (profiles.length === 0) { throw new Error('No suitable profiles found for input video resolution'); diff --git a/src/types/index.ts b/src/types/index.ts index 9c22016..73fd54c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -123,6 +123,7 @@ export interface VideoMetadata { fps: number; codec: string; audioBitrate?: number; // Битрейт аудио в kbps + videoBitrate?: number; // Битрейт видео в kbps } /** diff --git a/src/utils/video.ts b/src/utils/video.ts index c36097a..00c70da 100644 --- a/src/utils/video.ts +++ b/src/utils/video.ts @@ -8,10 +8,7 @@ export async function getVideoMetadata(inputPath: string): Promise { const proc = spawn('ffprobe', [ '-v', 'error', - '-select_streams', 'v:0', - '-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name', - '-select_streams', 'a:0', - '-show_entries', 'stream=bit_rate', + '-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name,codec_type,bit_rate', '-show_entries', 'format=duration', '-of', 'json', inputPath @@ -36,13 +33,23 @@ export async function getVideoMetadata(inputPath: string): Promise s.width !== undefined); - const audioStream = data.streams.find((s: any) => s.bit_rate !== undefined && s.width === undefined); + const videoStream = data.streams.find((s: any) => s.codec_type === 'video'); + const audioStream = data.streams.find((s: any) => s.codec_type === 'audio' && s.bit_rate); const format = data.format; - // Parse frame rate - const [num, den] = videoStream.r_frame_rate.split('/').map(Number); - const fps = num / den; + if (!videoStream) { + reject(new Error('No video stream found in input file')); + return; + } + + // Parse frame rate (handle missing or malformed r_frame_rate) + let fps = 30; // default fallback + if (videoStream.r_frame_rate) { + const [num, den] = videoStream.r_frame_rate.split('/').map(Number); + if (num && den && den !== 0) { + fps = num / den; + } + } // Get duration from stream or format const duration = parseFloat(videoStream.duration || format.duration || '0'); @@ -52,13 +59,19 @@ export async function getVideoMetadata(inputPath: string): Promise