* Export videos for named platforms and check each file against its limits Exports only knew aspect ratios, so a cut could still be rejected or degraded on upload: too long for the platform, over its size limit, or far off its loudness. export_video now takes platforms (TikTok, Instagram Reels and Feed, YouTube Shorts and YouTube, Facebook Reels, LinkedIn, X, Pinterest, Snapchat Spotlight), up to six targets with the presets. Each platform is rendered to its frame with the export's fit, its frame rate cap, a bitrate cap that also keeps the file under the size limit, and its loudness target in one loudnorm pass. Every render is probed and measured afterwards. A file over the size limit is encoded once more at a bitrate scaled from what it averaged, and audio more than 2 LU off the target gets one linear loudness pass. The result is stored on the output as a pass, warn or fail check with language-neutral issue codes. A platform the video is too long or too short for is not rendered: the card lists it with the reason, the other targets still render, and the bot is told to trim first. The limits come from the earlier profile research, with its sources, review date and unverified flag; content, disclosure and caption rules are left out because they need inference or a review flow. Preset definitions move to their own module so the platform schemas can use their aspect ratios without an import cycle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * Offer platforms in the media card's export menu and show their checks The card's Export menu now lists the platforms first, by brand name and untranslated, then the aspect ratios after a separator; the platform an output was made for is left out, like its own shape. A platform export shows the platform's name under the player, or, when its check did not pass, one line for the first issue instead ("Too long for TikTok (max 10:00)"), in the warning or destructive color. Skipped platforms get the same kind of line, and they stand in for the error of a job that skipped every platform, so the card does not say the same thing twice in two languages. The E2E spec seeds a platform export with a failed check and a skipped platform, and captures the card and the open menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u * fix(media): keep one skipped entry per platform on the card A job that recovers after a crash runs again and reports the same skipped platforms; the newest reason per platform now replaces the earlier one instead of listing it twice. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u --------- Co-authored-by: Claude <noreply@anthropic.com>
222 lines
8.3 KiB
TypeScript
222 lines
8.3 KiB
TypeScript
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<MediaCheckIssue["code"]>([
|
||
"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<PlatformProfile, "name">,
|
||
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));
|
||
}
|