feat: Обновленая реализация CLI
This commit is contained in:
274
src/config/profiles.ts
Normal file
274
src/config/profiles.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { VideoProfile } from '../types';
|
||||
|
||||
/**
|
||||
* 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: '360p',
|
||||
width: 640,
|
||||
height: 360,
|
||||
videoBitrate: calculateBitrate(640, 360, 30),
|
||||
audioBitrate: '192k'
|
||||
},
|
||||
{
|
||||
name: '480p',
|
||||
width: 854,
|
||||
height: 480,
|
||||
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: '1440p',
|
||||
width: 2560,
|
||||
height: 1440,
|
||||
videoBitrate: calculateBitrate(2560, 1440, 30),
|
||||
audioBitrate: '256k'
|
||||
},
|
||||
{
|
||||
name: '2160p',
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
videoBitrate: calculateBitrate(3840, 2160, 30),
|
||||
audioBitrate: '256k'
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Select appropriate profiles based on input video resolution
|
||||
* Only creates profiles that are equal to or smaller than input resolution
|
||||
* Always generates 30 FPS profiles by default
|
||||
* For high FPS (>30), user must explicitly specify in customProfiles
|
||||
*/
|
||||
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),
|
||||
fps: 30
|
||||
});
|
||||
}
|
||||
|
||||
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),
|
||||
fps
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse profile string into resolution and FPS
|
||||
* Examples:
|
||||
* '360' => { resolution: '360p', fps: 30 }
|
||||
* '720@60' => { resolution: '720p', fps: 60 }
|
||||
* '1080-60' => { resolution: '1080p', fps: 60 }
|
||||
* '360p', '720p@60' also supported (with 'p')
|
||||
*/
|
||||
function parseProfileString(profileStr: string): { resolution: string; fps: number } | null {
|
||||
const trimmed = profileStr.trim();
|
||||
|
||||
// Match patterns: 360, 720@60, 1080-60, 360p, 720p@60, 1080p-60
|
||||
const match = trimmed.match(/^(\d+)p?(?:[@-](\d+))?$/i);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolution = match[1] + 'p'; // Always add 'p'
|
||||
const fps = match[2] ? parseInt(match[2]) : 30;
|
||||
|
||||
return { resolution, fps };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile by resolution name and FPS
|
||||
* Returns VideoProfile or null if not found
|
||||
*/
|
||||
export function getProfileByName(
|
||||
resolution: string,
|
||||
fps: number = 30,
|
||||
maxBitrate?: number
|
||||
): VideoProfile | null {
|
||||
const baseProfile = DEFAULT_PROFILES.find(p => p.name === resolution);
|
||||
|
||||
if (!baseProfile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (fps === 30) {
|
||||
return {
|
||||
...baseProfile,
|
||||
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate),
|
||||
fps: 30
|
||||
};
|
||||
}
|
||||
|
||||
return createHighFPSProfile(baseProfile, fps, maxBitrate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate if profile can be created from source
|
||||
* Returns object with error, warning, and adjusted FPS
|
||||
*/
|
||||
export function validateProfile(
|
||||
profileStr: string,
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
sourceFPS: number
|
||||
): { error?: string; warning?: string; adjustedFps?: number } {
|
||||
const parsed = parseProfileString(profileStr);
|
||||
|
||||
if (!parsed) {
|
||||
return { error: `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60` };
|
||||
}
|
||||
|
||||
const profile = getProfileByName(parsed.resolution, parsed.fps);
|
||||
|
||||
if (!profile) {
|
||||
return { error: `Unknown resolution: ${parsed.resolution}. Available: 360, 480, 720, 1080, 1440, 2160` };
|
||||
}
|
||||
|
||||
// Check if source supports this resolution
|
||||
if (profile.width > sourceWidth || profile.height > sourceHeight) {
|
||||
return { error: `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})` };
|
||||
}
|
||||
|
||||
// Check if requested FPS exceeds source FPS
|
||||
const MAX_FPS = 120;
|
||||
let adjustedFps = parsed.fps;
|
||||
let warning: string | undefined;
|
||||
|
||||
if (parsed.fps > sourceFPS) {
|
||||
// Cap to source FPS (but not more than MAX_FPS)
|
||||
adjustedFps = Math.min(sourceFPS, MAX_FPS);
|
||||
warning = `Requested ${parsed.fps} FPS in ${profileStr}, but source is ${sourceFPS} FPS. Using ${adjustedFps} FPS instead`;
|
||||
} else if (parsed.fps > MAX_FPS) {
|
||||
// Cap to MAX_FPS
|
||||
adjustedFps = MAX_FPS;
|
||||
warning = `Requested ${parsed.fps} FPS in ${profileStr} exceeds maximum ${MAX_FPS} FPS. Using ${adjustedFps} FPS instead`;
|
||||
}
|
||||
|
||||
return warning ? { warning, adjustedFps } : {}; // Valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Create profiles from custom string list
|
||||
* Example: ['360p', '720p@60', '1080p'] => VideoProfile[]
|
||||
*/
|
||||
export function createProfilesFromStrings(
|
||||
profileStrings: string[],
|
||||
sourceWidth: number,
|
||||
sourceHeight: number,
|
||||
sourceFPS: number,
|
||||
sourceBitrate?: number
|
||||
): { profiles: VideoProfile[]; errors: string[]; warnings: string[] } {
|
||||
const profiles: VideoProfile[] = [];
|
||||
const errors: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const profileStr of profileStrings) {
|
||||
// Validate
|
||||
const result = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS);
|
||||
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result.warning) {
|
||||
warnings.push(result.warning);
|
||||
}
|
||||
|
||||
// Parse and create
|
||||
const parsed = parseProfileString(profileStr);
|
||||
if (!parsed) continue; // Already validated, shouldn't happen
|
||||
|
||||
// Use adjusted FPS if available (when requested FPS > source FPS)
|
||||
const targetFps = result.adjustedFps !== undefined ? result.adjustedFps : parsed.fps;
|
||||
|
||||
const profile = getProfileByName(parsed.resolution, targetFps, sourceBitrate);
|
||||
if (profile) {
|
||||
profiles.push(profile);
|
||||
}
|
||||
}
|
||||
|
||||
return { profiles, errors, warnings };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user