import type { MediaCheck, MediaCheckIssue, MediaFit, PlatformProfile } from "@rakazo/contracts"; import type { LoudnessMeasurement } from "./ffmpeg.js"; import type { ExportTarget } from "./video-edit-plan.js"; import { AUDIO_BITRATE_KBPS } from "./video-edit-plan.js"; /** * Pure rules of a platform export: what to render, whether the result fits the platform, and * which one-time fix to try when it does not. */ /** Container durations differ from the cut by a few audio frames. */ export const DURATION_TOLERANCE_MS = 100; /** Average video bitrate above the platform's cap by more than this share earns a warning. */ export const BITRATE_TOLERANCE = 0.1; /** Frame rate above the platform's cap by more than this share earns a warning. */ export const FPS_TOLERANCE = 0.01; /** Integrated loudness this far from the target is re-normalized once, then warned about. */ export const LOUDNESS_TOLERANCE_LU = 2; /** True peak above the platform's ceiling by more than this earns a warning (AAC adds a little). */ export const TRUE_PEAK_TOLERANCE_DB = 0.5; /** Integrated loudness below this is silence: nothing to normalize or check. */ export const SILENCE_LUFS = -60; /** Share of the file size limit the first encode budgets for, and a re-encode after an overshoot. */ export const SIZE_HEADROOM = 0.95; export const SIZE_FIX_HEADROOM = 0.9; /** Below this the picture falls apart; a file that still does not fit fails the check instead. */ export const MIN_VIDEO_KBPS = 300; const BYTES_PER_MB = 1_000_000; const FAILING_CODES = new Set([ "too_long", "too_short", "file_too_large", "codec_mismatch", ]); /** What a rendered file measured. */ export interface ExportMeasurement { width: number; height: number; /** 0 when unknown. */ fps: number; codec: string; durationMs: number; sizeBytes: number; videoKbps?: number; /** Null without an audio track. */ loudness: LoudnessMeasurement | null; } /** Highest video bitrate that keeps a file of this length under the platform's size limit. */ export function sizeBudgetKbps( profile: PlatformProfile, durationMs: number, headroom = SIZE_HEADROOM, ): number { const totalKbps = (profile.video.maxFileSizeMb * BYTES_PER_MB * 8) / Math.max(1, durationMs); return Math.max(MIN_VIDEO_KBPS, Math.floor(totalKbps * headroom - AUDIO_BITRATE_KBPS)); } export function platformTarget( profile: PlatformProfile, fit: MediaFit, durationMs: number, ): ExportTarget { return { width: profile.video.width, height: profile.video.height, fit, maxFps: profile.video.fps, maxBitrateKbps: Math.min(profile.video.maxBitrateKbps, sizeBudgetKbps(profile, durationMs)), integratedLufs: profile.audio.targetLufs, truePeakDb: profile.audio.truePeakDb, }; } /** Why a video of this length cannot go on the platform; empty when it can. */ export function durationIssues(profile: PlatformProfile, durationMs: number): MediaCheckIssue[] { const { hardMinMs, hardMaxMs } = profile.duration; if (durationMs > hardMaxMs + DURATION_TOLERANCE_MS) { return [{ code: "too_long", maxMs: hardMaxMs, actualMs: Math.round(durationMs) }]; } if (durationMs < hardMinMs - DURATION_TOLERANCE_MS) { return [{ code: "too_short", minMs: hardMinMs, actualMs: Math.round(durationMs) }]; } return []; } function audible(loudness: LoudnessMeasurement | null): loudness is LoudnessMeasurement { return loudness !== null && loudness.integratedLufs > SILENCE_LUFS; } /** Compares a rendered file with the platform's limits; failing issues come first. */ export function checkExport(profile: PlatformProfile, measured: ExportMeasurement): MediaCheck { const { video, audio } = profile; const issues: MediaCheckIssue[] = [...durationIssues(profile, measured.durationMs)]; const maxBytes = video.maxFileSizeMb * BYTES_PER_MB; if (measured.sizeBytes > maxBytes) { issues.push({ code: "file_too_large", maxMb: video.maxFileSizeMb, actualMb: round(measured.sizeBytes / BYTES_PER_MB, 1), }); } if (measured.codec !== video.codec) issues.push({ code: "codec_mismatch" }); if (measured.width !== video.width || measured.height !== video.height) { issues.push({ code: "resolution_mismatch", width: video.width, height: video.height, actualWidth: measured.width, actualHeight: measured.height, }); } if (measured.fps > video.fps * (1 + FPS_TOLERANCE)) { issues.push({ code: "fps_mismatch", maxFps: video.fps, actualFps: round(measured.fps, 2) }); } if ( measured.videoKbps !== undefined && measured.videoKbps > video.maxBitrateKbps * (1 + BITRATE_TOLERANCE) ) { issues.push({ code: "bitrate_high", maxKbps: video.maxBitrateKbps, actualKbps: Math.round(measured.videoKbps), }); } if (audible(measured.loudness)) { const { integratedLufs, truePeakDb } = measured.loudness; if (Math.abs(integratedLufs - audio.targetLufs) > LOUDNESS_TOLERANCE_LU) { issues.push({ code: "loudness_off", targetLufs: audio.targetLufs, actualLufs: round(integratedLufs, 1), }); } if (truePeakDb > audio.truePeakDb + TRUE_PEAK_TOLERANCE_DB) { issues.push({ code: "true_peak_high", maxDb: audio.truePeakDb, actualDb: round(truePeakDb, 1), }); } } const failing = issues.filter((issue) => FAILING_CODES.has(issue.code)); const warning = issues.filter((issue) => !FAILING_CODES.has(issue.code)); return { status: failing.length ? "fail" : warning.length ? "warn" : "pass", issues: [...failing, ...warning], }; } /** * The lower bitrate cap for one re-encode of a file over the size limit, scaled from what the * file actually averaged; null when it fits or a lower cap would not help. */ export function sizeFixKbps( profile: PlatformProfile, measured: ExportMeasurement, currentKbps: number, ): number | null { const maxBytes = profile.video.maxFileSizeMb * BYTES_PER_MB; if (measured.sizeBytes <= maxBytes) return null; const averageKbps = measured.videoKbps ?? (measured.sizeBytes * 8) / Math.max(1, measured.durationMs) - AUDIO_BITRATE_KBPS; const scaled = Math.min(currentKbps, averageKbps) * (maxBytes / measured.sizeBytes); const next = Math.max(MIN_VIDEO_KBPS, Math.floor(scaled * SIZE_FIX_HEADROOM)); return next < currentKbps ? next : null; } /** Whether one more loudness pass is worth it: the audio is audible and off the target. */ export function needsLoudnessFix(profile: PlatformProfile, measured: ExportMeasurement): boolean { return ( audible(measured.loudness) && Math.abs(measured.loudness.integratedLufs - profile.audio.targetLufs) > LOUDNESS_TOLERANCE_LU ); } /** An issue in a few English words for the bot's summary; the web words them itself. */ export function describeIssue(issue: MediaCheckIssue): string { switch (issue.code) { case "too_long": return `${seconds(issue.actualMs)} is longer than its ${seconds(issue.maxMs)} limit`; case "too_short": return `${seconds(issue.actualMs)} is shorter than its ${seconds(issue.minMs)} minimum`; case "file_too_large": return `${issue.actualMb} MB is larger than its ${issue.maxMb} MB limit`; case "bitrate_high": return `video bitrate ${issue.actualKbps} kbps is above its ${issue.maxKbps} kbps`; case "loudness_off": return `loudness ${issue.actualLufs} LUFS, target ${issue.targetLufs} LUFS`; case "true_peak_high": return `true peak ${issue.actualDb} dBTP is above its ${issue.maxDb} dBTP`; case "resolution_mismatch": return `frame ${issue.actualWidth}×${issue.actualHeight} instead of ${issue.width}×${issue.height}`; case "fps_mismatch": return `${issue.actualFps} fps is above its ${issue.maxFps} fps`; case "codec_mismatch": return "video is not H.264"; } } /** "Not exported for TikTok: 720 s is longer than its 600 s limit; trim the video first." */ export function describeSkipped( profile: Pick, issues: readonly MediaCheckIssue[], ): string { const advice = issues.some((issue) => issue.code === "too_short") ? "use a longer video" : "trim the video first"; return `Not exported for ${profile.name}: ${issues.map(describeIssue).join("; ")}; ${advice}.`; } function seconds(ms: number): string { return `${Number((ms / 1000).toFixed(1))} s`; } function round(value: number, digits: number): number { return Number(value.toFixed(digits)); }