291 lines
8.1 KiB
TypeScript
291 lines
8.1 KiB
TypeScript
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
|
||
* Oriented by height: only profiles with height <= source height
|
||
* 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[] = [];
|
||
const aspect = inputWidth / inputHeight;
|
||
const isNear = (value: number, target: number, eps: number = 0.01) =>
|
||
Math.abs(value - target) < eps;
|
||
const isNear16x9 = isNear(aspect, 16 / 9);
|
||
const isNear4x3 = isNear(aspect, 4 / 3);
|
||
|
||
// Standard 30 FPS profiles (always created)
|
||
const baseProfiles = DEFAULT_PROFILES.filter(profile => {
|
||
return profile.height <= inputHeight;
|
||
});
|
||
|
||
// Add standard 30fps profiles with bitrate limit
|
||
for (const profile of baseProfiles) {
|
||
// Сохраняем соотношение сторон: при типовом 16:9 оставляем штатную ширину,
|
||
// иначе рассчитываем по исходному AR (и делаем ширину чётной)
|
||
const height = profile.height;
|
||
let width = profile.width;
|
||
if (!isNear16x9 && !isNear4x3) {
|
||
width = Math.round(height * aspect);
|
||
if (width % 2 !== 0) width -= 1; // чётные для кодека
|
||
if (width < 2) width = 2;
|
||
}
|
||
|
||
profiles.push({
|
||
...profile,
|
||
width,
|
||
height,
|
||
videoBitrate: calculateBitrate(width, 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.height > sourceHeight) {
|
||
return { error: `Source height (${sourceHeight}px) is lower than requested ${profileStr} height (${profile.height}px)` };
|
||
}
|
||
|
||
// 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 };
|
||
}
|