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
189 lines
6.5 KiB
TypeScript
189 lines
6.5 KiB
TypeScript
import { MEDIA_PLATFORM_PROFILES } from "@rakazo/contracts";
|
|
import { describe, expect, it } from "vitest";
|
|
import type { ExportMeasurement } from "./platform-export.js";
|
|
import {
|
|
BITRATE_TOLERANCE,
|
|
checkExport,
|
|
DURATION_TOLERANCE_MS,
|
|
describeSkipped,
|
|
durationIssues,
|
|
LOUDNESS_TOLERANCE_LU,
|
|
MIN_VIDEO_KBPS,
|
|
needsLoudnessFix,
|
|
platformTarget,
|
|
sizeBudgetKbps,
|
|
sizeFixKbps,
|
|
TRUE_PEAK_TOLERANCE_DB,
|
|
} from "./platform-export.js";
|
|
|
|
const tiktok = MEDIA_PLATFORM_PROFILES.tiktok;
|
|
|
|
/** A clean 30 s TikTok render; tests override what they examine. */
|
|
function measured(overrides: Partial<ExportMeasurement> = {}): ExportMeasurement {
|
|
return {
|
|
width: 1080,
|
|
height: 1920,
|
|
fps: 30,
|
|
codec: "h264",
|
|
durationMs: 30_000,
|
|
sizeBytes: 20_000_000,
|
|
videoKbps: 5000,
|
|
loudness: {
|
|
integratedLufs: -14.2,
|
|
truePeakDb: -1.3,
|
|
rangeLu: 4,
|
|
thresholdLufs: -24.5,
|
|
offsetLu: 0.1,
|
|
},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function loudness(integratedLufs: number, truePeakDb = -2) {
|
|
return { integratedLufs, truePeakDb, rangeLu: 4, thresholdLufs: -30, offsetLu: 0 };
|
|
}
|
|
|
|
describe("platform targets", () => {
|
|
it("renders to the profile and caps the bitrate below its size limit for long videos", () => {
|
|
expect(platformTarget(tiktok, "blur", 30_000)).toEqual({
|
|
width: 1080,
|
|
height: 1920,
|
|
fit: "blur",
|
|
maxFps: 30,
|
|
maxBitrateKbps: 8000,
|
|
integratedLufs: -14,
|
|
truePeakDb: -1,
|
|
});
|
|
// 287 MB over 10 minutes leaves about 3.6 Mbps of video after headroom and audio.
|
|
const tenMinutes = platformTarget(tiktok, "crop", 600_000).maxBitrateKbps;
|
|
expect(tenMinutes).toBe(sizeBudgetKbps(tiktok, 600_000));
|
|
expect(tenMinutes).toBeGreaterThan(3000);
|
|
expect(tenMinutes).toBeLessThan(3700);
|
|
expect(tenMinutes * 600 + 160 * 600).toBeLessThan(287 * 8000);
|
|
expect(sizeBudgetKbps(tiktok, 1e9)).toBe(MIN_VIDEO_KBPS);
|
|
});
|
|
});
|
|
|
|
describe("duration limits", () => {
|
|
it("refuses videos past the limits, with a small tolerance for container rounding", () => {
|
|
expect(durationIssues(tiktok, 600_000 + DURATION_TOLERANCE_MS)).toEqual([]);
|
|
expect(durationIssues(tiktok, 720_000)).toEqual([
|
|
{ code: "too_long", maxMs: 600_000, actualMs: 720_000 },
|
|
]);
|
|
expect(durationIssues(tiktok, 3000 - DURATION_TOLERANCE_MS)).toEqual([]);
|
|
expect(durationIssues(tiktok, 2000)).toEqual([
|
|
{ code: "too_short", minMs: 3000, actualMs: 2000 },
|
|
]);
|
|
});
|
|
|
|
it("words a skipped platform for the bot with what to do about it", () => {
|
|
expect(describeSkipped(tiktok, durationIssues(tiktok, 720_000))).toBe(
|
|
"Not exported for TikTok: 720 s is longer than its 600 s limit; trim the video first.",
|
|
);
|
|
expect(describeSkipped(tiktok, durationIssues(tiktok, 1500))).toBe(
|
|
"Not exported for TikTok: 1.5 s is shorter than its 3 s minimum; use a longer video.",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("platform check", () => {
|
|
it("passes a render within every tolerance", () => {
|
|
expect(
|
|
checkExport(
|
|
tiktok,
|
|
measured({
|
|
videoKbps: 8000 * (1 + BITRATE_TOLERANCE),
|
|
fps: 30.2,
|
|
loudness: loudness(-14 - LOUDNESS_TOLERANCE_LU, -1 + TRUE_PEAK_TOLERANCE_DB),
|
|
}),
|
|
),
|
|
).toEqual({ status: "pass", issues: [] });
|
|
});
|
|
|
|
it("warns about what the platform would degrade", () => {
|
|
expect(
|
|
checkExport(
|
|
tiktok,
|
|
measured({
|
|
width: 720,
|
|
height: 1280,
|
|
fps: 60,
|
|
videoKbps: 9500,
|
|
loudness: loudness(-17.26, -0.2),
|
|
}),
|
|
),
|
|
).toEqual({
|
|
status: "warn",
|
|
issues: [
|
|
{
|
|
code: "resolution_mismatch",
|
|
width: 1080,
|
|
height: 1920,
|
|
actualWidth: 720,
|
|
actualHeight: 1280,
|
|
},
|
|
{ code: "fps_mismatch", maxFps: 30, actualFps: 60 },
|
|
{ code: "bitrate_high", maxKbps: 8000, actualKbps: 9500 },
|
|
{ code: "loudness_off", targetLufs: -14, actualLufs: -17.3 },
|
|
{ code: "true_peak_high", maxDb: -1, actualDb: -0.2 },
|
|
],
|
|
});
|
|
});
|
|
|
|
it("fails what the platform would reject, most severe first", () => {
|
|
const check = checkExport(
|
|
tiktok,
|
|
measured({ codec: "hevc", sizeBytes: 300_456_789, loudness: loudness(-20) }),
|
|
);
|
|
expect(check.status).toBe("fail");
|
|
expect(check.issues).toEqual([
|
|
{ code: "file_too_large", maxMb: 287, actualMb: 300.5 },
|
|
{ code: "codec_mismatch" },
|
|
{ code: "loudness_off", targetLufs: -14, actualLufs: -20 },
|
|
]);
|
|
expect(checkExport(tiktok, measured({ durationMs: 601_000 })).issues[0]).toEqual({
|
|
code: "too_long",
|
|
maxMs: 600_000,
|
|
actualMs: 601_000,
|
|
});
|
|
});
|
|
|
|
it("does not judge the loudness of silent or missing audio, or an unknown frame rate", () => {
|
|
expect(checkExport(tiktok, measured({ loudness: null, fps: 0 })).status).toBe("pass");
|
|
expect(
|
|
checkExport(tiktok, measured({ loudness: loudness(Number.NEGATIVE_INFINITY) })).status,
|
|
).toBe("pass");
|
|
expect(checkExport(tiktok, measured({ loudness: loudness(-65) })).status).toBe("pass");
|
|
});
|
|
});
|
|
|
|
describe("automatic fixes", () => {
|
|
it("re-encodes an oversized file once at a bitrate scaled from what it averaged", () => {
|
|
expect(sizeFixKbps(tiktok, measured(), 8000)).toBeNull();
|
|
// 330 MB at 4400 kbps average: scale by 287/330 and keep 10% headroom.
|
|
const lower = sizeFixKbps(tiktok, measured({ sizeBytes: 330_000_000, videoKbps: 4400 }), 8000);
|
|
expect(lower).toBe(Math.floor(4400 * (287 / 330) * 0.9));
|
|
// Without a stream bitrate, the file size and length give the average.
|
|
expect(
|
|
sizeFixKbps(
|
|
tiktok,
|
|
measured({ sizeBytes: 330_000_000, videoKbps: undefined, durationMs: 600_000 }),
|
|
8000,
|
|
),
|
|
).toBe(Math.floor((4400 - 160) * (287 / 330) * 0.9));
|
|
// Nothing lower is left to try.
|
|
expect(
|
|
sizeFixKbps(tiktok, measured({ sizeBytes: 330_000_000, videoKbps: 200 }), MIN_VIDEO_KBPS),
|
|
).toBeNull();
|
|
});
|
|
|
|
it("re-normalizes audible audio that missed the loudness target by more than the tolerance", () => {
|
|
expect(needsLoudnessFix(tiktok, measured())).toBe(false);
|
|
expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-16) }))).toBe(false);
|
|
expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-16.5) }))).toBe(true);
|
|
expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-10) }))).toBe(true);
|
|
expect(needsLoudnessFix(tiktok, measured({ loudness: null }))).toBe(false);
|
|
expect(needsLoudnessFix(tiktok, measured({ loudness: loudness(-70) }))).toBe(false);
|
|
});
|
|
});
|