sync
This commit is contained in:
25
src/cli.ts
25
src/cli.ts
@@ -10,8 +10,9 @@
|
|||||||
* dvc ./video.mp4 ./output
|
* 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 cliProgress from 'cli-progress';
|
||||||
|
import { statSync } from 'node:fs';
|
||||||
|
|
||||||
const input = process.argv[2];
|
const input = process.argv[2];
|
||||||
const outputDir = process.argv[3] || './output';
|
const outputDir = process.argv[3] || './output';
|
||||||
@@ -41,8 +42,26 @@ if (!hasMP4Box) {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(`📹 Input: ${input}`);
|
// Get video metadata and file size
|
||||||
console.log(`📁 Output: ${outputDir}\n`);
|
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');
|
console.log('🚀 Starting conversion...\n');
|
||||||
|
|
||||||
// Create multibar container
|
// Create multibar container
|
||||||
|
|||||||
@@ -1,45 +1,156 @@
|
|||||||
import type { VideoProfile } from '../types';
|
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[] = [
|
export const DEFAULT_PROFILES: VideoProfile[] = [
|
||||||
{
|
{
|
||||||
name: '1080p',
|
name: '360p',
|
||||||
width: 1920,
|
width: 640,
|
||||||
height: 1080,
|
height: 360,
|
||||||
videoBitrate: '5000k',
|
videoBitrate: calculateBitrate(640, 360, 30),
|
||||||
audioBitrate: '256k'
|
audioBitrate: '192k'
|
||||||
},
|
|
||||||
{
|
|
||||||
name: '720p',
|
|
||||||
width: 1280,
|
|
||||||
height: 720,
|
|
||||||
videoBitrate: '3000k',
|
|
||||||
audioBitrate: '256k'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '480p',
|
name: '480p',
|
||||||
width: 854,
|
width: 854,
|
||||||
height: 480,
|
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'
|
audioBitrate: '256k'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '360p',
|
name: '1440p',
|
||||||
width: 640,
|
width: 2560,
|
||||||
height: 360,
|
height: 1440,
|
||||||
videoBitrate: '800k',
|
videoBitrate: calculateBitrate(2560, 1440, 30),
|
||||||
|
audioBitrate: '256k'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: '4K',
|
||||||
|
width: 3840,
|
||||||
|
height: 2160,
|
||||||
|
videoBitrate: calculateBitrate(3840, 2160, 30),
|
||||||
audioBitrate: '256k'
|
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[] {
|
export function selectProfiles(
|
||||||
return DEFAULT_PROFILES.filter(profile => {
|
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;
|
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)
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ async function convertToDashInternal(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Select profiles
|
// 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) {
|
if (profiles.length === 0) {
|
||||||
throw new Error('No suitable profiles found for input video resolution');
|
throw new Error('No suitable profiles found for input video resolution');
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ export interface VideoMetadata {
|
|||||||
fps: number;
|
fps: number;
|
||||||
codec: string;
|
codec: string;
|
||||||
audioBitrate?: number; // Битрейт аудио в kbps
|
audioBitrate?: number; // Битрейт аудио в kbps
|
||||||
|
videoBitrate?: number; // Битрейт видео в kbps
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -8,10 +8,7 @@ export async function getVideoMetadata(inputPath: string): Promise<VideoMetadata
|
|||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const proc = spawn('ffprobe', [
|
const proc = spawn('ffprobe', [
|
||||||
'-v', 'error',
|
'-v', 'error',
|
||||||
'-select_streams', 'v:0',
|
'-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name,codec_type,bit_rate',
|
||||||
'-show_entries', 'stream=width,height,duration,r_frame_rate,codec_name',
|
|
||||||
'-select_streams', 'a:0',
|
|
||||||
'-show_entries', 'stream=bit_rate',
|
|
||||||
'-show_entries', 'format=duration',
|
'-show_entries', 'format=duration',
|
||||||
'-of', 'json',
|
'-of', 'json',
|
||||||
inputPath
|
inputPath
|
||||||
@@ -36,13 +33,23 @@ export async function getVideoMetadata(inputPath: string): Promise<VideoMetadata
|
|||||||
try {
|
try {
|
||||||
const data = JSON.parse(output);
|
const data = JSON.parse(output);
|
||||||
|
|
||||||
const videoStream = data.streams.find((s: any) => s.width !== undefined);
|
const videoStream = data.streams.find((s: any) => s.codec_type === 'video');
|
||||||
const audioStream = data.streams.find((s: any) => s.bit_rate !== undefined && s.width === undefined);
|
const audioStream = data.streams.find((s: any) => s.codec_type === 'audio' && s.bit_rate);
|
||||||
const format = data.format;
|
const format = data.format;
|
||||||
|
|
||||||
// Parse frame rate
|
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);
|
const [num, den] = videoStream.r_frame_rate.split('/').map(Number);
|
||||||
const fps = num / den;
|
if (num && den && den !== 0) {
|
||||||
|
fps = num / den;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Get duration from stream or format
|
// Get duration from stream or format
|
||||||
const duration = parseFloat(videoStream.duration || format.duration || '0');
|
const duration = parseFloat(videoStream.duration || format.duration || '0');
|
||||||
@@ -52,13 +59,19 @@ export async function getVideoMetadata(inputPath: string): Promise<VideoMetadata
|
|||||||
? Math.round(parseInt(audioStream.bit_rate) / 1000)
|
? Math.round(parseInt(audioStream.bit_rate) / 1000)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Get video bitrate in kbps
|
||||||
|
const videoBitrate = videoStream.bit_rate
|
||||||
|
? Math.round(parseInt(videoStream.bit_rate) / 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
resolve({
|
resolve({
|
||||||
width: videoStream.width,
|
width: videoStream.width,
|
||||||
height: videoStream.height,
|
height: videoStream.height,
|
||||||
duration,
|
duration,
|
||||||
fps,
|
fps,
|
||||||
codec: videoStream.codec_name,
|
codec: videoStream.codec_name,
|
||||||
audioBitrate
|
audioBitrate,
|
||||||
|
videoBitrate
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
reject(new Error(`Failed to parse ffprobe output: ${err}`));
|
reject(new Error(`Failed to parse ffprobe output: ${err}`));
|
||||||
|
|||||||
Reference in New Issue
Block a user