2026-01-20 00:25:55 +03:00
|
|
|
|
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
|
2026-01-21 10:13:20 +03:00
|
|
|
|
* Oriented by height: only profiles with height <= source height
|
2026-01-20 00:25:55 +03:00
|
|
|
|
* 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 => {
|
2026-01-21 10:13:20 +03:00
|
|
|
|
return profile.height <= inputHeight;
|
2026-01-20 00:25:55 +03:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 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
|
2026-01-21 10:13:20 +03:00
|
|
|
|
if (profile.height > sourceHeight) {
|
|
|
|
|
|
return { error: `Source height (${sourceHeight}px) is lower than requested ${profileStr} height (${profile.height}px)` };
|
2026-01-20 00:25:55 +03:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 };
|
|
|
|
|
|
}
|