Compare commits

...

2 Commits

Author SHA1 Message Date
3086d6907c sync 2025-11-09 03:28:43 +03:00
79bab28e0c sync 2025-11-09 01:30:52 +03:00
6 changed files with 186 additions and 35 deletions

2
.gitignore vendored
View File

@@ -21,3 +21,5 @@ test-output/
.idea/ .idea/
*.swp *.swp
*.swo *.swo
/data/

View File

@@ -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

View File

@@ -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)
};
} }

View File

@@ -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');

View File

@@ -123,6 +123,7 @@ export interface VideoMetadata {
fps: number; fps: number;
codec: string; codec: string;
audioBitrate?: number; // Битрейт аудио в kbps audioBitrate?: number; // Битрейт аудио в kbps
videoBitrate?: number; // Битрейт видео в kbps
} }
/** /**

View File

@@ -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}`));