Дефолтный ФПС 30 + ограничения максимального в 120 и целевого видео

This commit is contained in:
2025-11-11 21:25:37 +03:00
parent 2da2b584fa
commit 8cf4210d20
4 changed files with 56 additions and 50 deletions

View File

@@ -86,12 +86,10 @@ export const DEFAULT_PROFILES: VideoProfile[] = [
]; ];
/** /**
* Select appropriate profiles based on input video resolution and FPS * Select appropriate profiles based on input video resolution
* Only creates profiles that are equal to or smaller than input resolution * Only creates profiles that are equal to or smaller than input resolution
* Creates high FPS variants if source supports it (according to FEATURES.md): * Always generates 30 FPS profiles by default
* - 60 FPS versions if source >= 45 FPS * For high FPS (>30), user must explicitly specify in customProfiles
* - 90 FPS versions if source >= 75 FPS
* - 120 FPS versions if source >= 95 FPS
*/ */
export function selectProfiles( export function selectProfiles(
inputWidth: number, inputWidth: number,
@@ -110,31 +108,11 @@ export function selectProfiles(
for (const profile of baseProfiles) { for (const profile of baseProfiles) {
profiles.push({ profiles.push({
...profile, ...profile,
videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate) videoBitrate: calculateBitrate(profile.width, profile.height, 30, sourceBitrate),
fps: 30
}); });
} }
// 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; return profiles;
} }
@@ -150,7 +128,8 @@ export function createHighFPSProfile(
return { return {
...baseProfile, ...baseProfile,
name: `${baseProfile.name}-${fps}`, name: `${baseProfile.name}-${fps}`,
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, fps, maxBitrate) videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, fps, maxBitrate),
fps
}; };
} }
@@ -196,7 +175,8 @@ export function getProfileByName(
if (fps === 30) { if (fps === 30) {
return { return {
...baseProfile, ...baseProfile,
videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate) videoBitrate: calculateBitrate(baseProfile.width, baseProfile.height, 30, maxBitrate),
fps: 30
}; };
} }
@@ -205,37 +185,47 @@ export function getProfileByName(
/** /**
* Validate if profile can be created from source * Validate if profile can be created from source
* Returns error message or null if valid * Returns object with error, warning, and adjusted FPS
*/ */
export function validateProfile( export function validateProfile(
profileStr: string, profileStr: string,
sourceWidth: number, sourceWidth: number,
sourceHeight: number, sourceHeight: number,
sourceFPS: number sourceFPS: number
): string | null { ): { error?: string; warning?: string; adjustedFps?: number } {
const parsed = parseProfileString(profileStr); const parsed = parseProfileString(profileStr);
if (!parsed) { if (!parsed) {
return `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60`; return { error: `Invalid profile format: ${profileStr}. Use format like: 360, 720@60, 1080-60` };
} }
const profile = getProfileByName(parsed.resolution, parsed.fps); const profile = getProfileByName(parsed.resolution, parsed.fps);
if (!profile) { if (!profile) {
return `Unknown resolution: ${parsed.resolution}. Available: 360, 480, 720, 1080, 1440, 2160`; return { error: `Unknown resolution: ${parsed.resolution}. Available: 360, 480, 720, 1080, 1440, 2160` };
} }
// Check if source supports this resolution // Check if source supports this resolution
if (profile.width > sourceWidth || profile.height > sourceHeight) { if (profile.width > sourceWidth || profile.height > sourceHeight) {
return `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})`; return { error: `Source resolution (${sourceWidth}x${sourceHeight}) is lower than ${profileStr} (${profile.width}x${profile.height})` };
} }
// Check if source supports this FPS // Check if requested FPS exceeds source FPS
const MAX_FPS = 120;
let adjustedFps = parsed.fps;
let warning: string | undefined;
if (parsed.fps > sourceFPS) { if (parsed.fps > sourceFPS) {
return `Source FPS (${sourceFPS}) is lower than requested ${parsed.fps} FPS in ${profileStr}`; // 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 null; // Valid return warning ? { warning, adjustedFps } : {}; // Valid
} }
/** /**
@@ -248,29 +238,37 @@ export function createProfilesFromStrings(
sourceHeight: number, sourceHeight: number,
sourceFPS: number, sourceFPS: number,
sourceBitrate?: number sourceBitrate?: number
): { profiles: VideoProfile[]; errors: string[] } { ): { profiles: VideoProfile[]; errors: string[]; warnings: string[] } {
const profiles: VideoProfile[] = []; const profiles: VideoProfile[] = [];
const errors: string[] = []; const errors: string[] = [];
const warnings: string[] = [];
for (const profileStr of profileStrings) { for (const profileStr of profileStrings) {
// Validate // Validate
const error = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS); const result = validateProfile(profileStr, sourceWidth, sourceHeight, sourceFPS);
if (error) { if (result.error) {
errors.push(error); errors.push(result.error);
continue; continue;
} }
if (result.warning) {
warnings.push(result.warning);
}
// Parse and create // Parse and create
const parsed = parseProfileString(profileStr); const parsed = parseProfileString(profileStr);
if (!parsed) continue; // Already validated, shouldn't happen if (!parsed) continue; // Already validated, shouldn't happen
const profile = getProfileByName(parsed.resolution, parsed.fps, sourceBitrate); // 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) { if (profile) {
profiles.push(profile); profiles.push(profile);
} }
} }
return { profiles, errors }; return { profiles, errors, warnings };
} }

View File

@@ -140,17 +140,26 @@ async function convertToDashInternal(
// Show errors if any // Show errors if any
if (result.errors.length > 0) { if (result.errors.length > 0) {
console.warn('\n⚠️ Profile warnings:'); console.warn('\n Profile errors:');
for (const error of result.errors) { for (const error of result.errors) {
console.warn(` - ${error}`); console.warn(` - ${error}`);
} }
console.warn(''); console.warn('');
} }
// Show warnings if any
if (result.warnings.length > 0) {
console.warn('\n⚠ Profile warnings:');
for (const warning of result.warnings) {
console.warn(` - ${warning}`);
}
console.warn('');
}
profiles = result.profiles; profiles = result.profiles;
if (profiles.length === 0) { if (profiles.length === 0) {
throw new Error('No valid profiles found in custom list. Check warnings above.'); throw new Error('No valid profiles found in custom list. Check errors above.');
} }
} else if (userProfiles) { } else if (userProfiles) {
// Programmatic API usage // Programmatic API usage
@@ -222,7 +231,6 @@ async function convertToDashInternal(
codecPreset, codecPreset,
metadata.duration, metadata.duration,
segmentDuration, segmentDuration,
metadata.fps || 25,
metadata.audioBitrate, metadata.audioBitrate,
parallel, parallel,
maxConcurrent, maxConcurrent,

View File

@@ -14,7 +14,6 @@ export async function encodeProfileToMP4(
preset: string, preset: string,
duration: number, duration: number,
segmentDuration: number, segmentDuration: number,
fps: number,
sourceAudioBitrate: number | undefined, sourceAudioBitrate: number | undefined,
codecType: 'h264' | 'av1', codecType: 'h264' | 'av1',
optimizations?: VideoOptimizations, optimizations?: VideoOptimizations,
@@ -70,6 +69,7 @@ export async function encodeProfileToMP4(
// Set GOP size for DASH segments // Set GOP size for DASH segments
// Keyframes must align with segment boundaries // Keyframes must align with segment boundaries
const fps = profile.fps || 30;
const gopSize = Math.round(fps * segmentDuration); const gopSize = Math.round(fps * segmentDuration);
args.push( args.push(
'-g', String(gopSize), // GOP size (e.g., 25 fps * 2 sec = 50 frames) '-g', String(gopSize), // GOP size (e.g., 25 fps * 2 sec = 50 frames)
@@ -126,7 +126,6 @@ export async function encodeProfilesToMP4(
preset: string, preset: string,
duration: number, duration: number,
segmentDuration: number, segmentDuration: number,
fps: number,
sourceAudioBitrate: number | undefined, sourceAudioBitrate: number | undefined,
parallel: boolean, parallel: boolean,
maxConcurrent: number, maxConcurrent: number,
@@ -149,7 +148,6 @@ export async function encodeProfilesToMP4(
preset, preset,
duration, duration,
segmentDuration, segmentDuration,
fps,
sourceAudioBitrate, sourceAudioBitrate,
codecType, codecType,
optimizations, optimizations,
@@ -178,7 +176,6 @@ export async function encodeProfilesToMP4(
preset, preset,
duration, duration,
segmentDuration, segmentDuration,
fps,
sourceAudioBitrate, sourceAudioBitrate,
codecType, codecType,
optimizations, optimizations,

View File

@@ -65,6 +65,9 @@ export interface VideoProfile {
/** Audio bitrate (e.g., "128k") */ /** Audio bitrate (e.g., "128k") */
audioBitrate: string; audioBitrate: string;
/** Target FPS for this profile (default: 30) */
fps?: number;
} }
/** /**