Files
create-vod/src/config/profiles.ts
S.Gromov 07746c7bd5 fix: скейлинг
fix: скейлинг
2026-01-22 16:34:56 +03:00

291 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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