* 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>
283 lines
9.6 KiB
TypeScript
283 lines
9.6 KiB
TypeScript
import * as z from "zod";
|
|
import type { MediaPreset } from "./media-presets.js";
|
|
import { MEDIA_PRESETS } from "./media-presets.js";
|
|
|
|
/**
|
|
* Platforms a video can be exported for. An export renders each one to the platform's
|
|
* technical limits and checks the file against them, so an upload is not rejected or degraded.
|
|
*/
|
|
export const MediaPlatformIdSchema = z.enum([
|
|
"tiktok",
|
|
"instagram-reels",
|
|
"instagram-feed-4x5",
|
|
"youtube-shorts",
|
|
"youtube",
|
|
"facebook-reels",
|
|
"linkedin",
|
|
"x-twitter",
|
|
"pinterest-video-pin",
|
|
"snapchat-spotlight",
|
|
]);
|
|
export type MediaPlatformId = z.infer<typeof MediaPlatformIdSchema>;
|
|
|
|
const PRESET_ASPECTS = Object.values(MEDIA_PRESETS).map((preset) => preset.aspect) as [
|
|
MediaPreset["aspect"],
|
|
...MediaPreset["aspect"][],
|
|
];
|
|
|
|
const Percent = z.number().min(0).max(50);
|
|
|
|
/**
|
|
* The technical limits of a platform. Content, disclosure and caption rules need a review flow
|
|
* and are not part of a profile.
|
|
*/
|
|
export const PlatformProfileSchema = z
|
|
.object({
|
|
id: MediaPlatformIdSchema,
|
|
/** Brand name, shown untranslated. */
|
|
name: z.string().min(1).max(40),
|
|
/** Where the limits come from. */
|
|
sourceUrls: z.array(z.httpUrl()).min(1),
|
|
reviewedAt: z.iso.date(),
|
|
/** True until someone checked the limits against the platform's current documentation. */
|
|
unverified: z.boolean(),
|
|
video: z.object({
|
|
aspect: z.enum(PRESET_ASPECTS),
|
|
width: z.number().int().positive().multipleOf(2),
|
|
height: z.number().int().positive().multipleOf(2),
|
|
/** Highest frame rate; slower sources keep theirs. */
|
|
fps: z.number().positive().max(120),
|
|
codec: z.literal("h264"),
|
|
maxBitrateKbps: z.number().int().positive(),
|
|
maxFileSizeMb: z.number().positive(),
|
|
}),
|
|
duration: z
|
|
.object({
|
|
hardMinMs: z.number().int().nonnegative(),
|
|
hardMaxMs: z.number().int().positive(),
|
|
})
|
|
.refine((duration) => duration.hardMinMs < duration.hardMaxMs, "hardMinMs must be lower"),
|
|
/** Edges the platform covers with its own buttons and captions, in percent of the frame. */
|
|
safeZones: z.object({
|
|
topPct: Percent,
|
|
bottomPct: Percent,
|
|
leftPct: Percent,
|
|
rightPct: Percent,
|
|
}),
|
|
audio: z.object({
|
|
targetLufs: z.number().min(-40).max(0),
|
|
truePeakDb: z.number().min(-10).max(0),
|
|
}),
|
|
})
|
|
.refine((profile) => {
|
|
const [across, down] = profile.video.aspect.split(":").map(Number);
|
|
return Math.abs(profile.video.width / profile.video.height - across! / down!) < 0.01;
|
|
}, "width and height must match the aspect ratio");
|
|
export type PlatformProfile = z.infer<typeof PlatformProfileSchema>;
|
|
|
|
/**
|
|
* What a platform check found, with the numbers the UI words it with. Durations in ms, sizes in
|
|
* MB (10^6 bytes), bitrates in kbps, loudness in LUFS, peaks in dBTP.
|
|
*/
|
|
export const MediaCheckIssueSchema = z.discriminatedUnion("code", [
|
|
z.object({ code: z.literal("too_long"), maxMs: z.number(), actualMs: z.number() }),
|
|
z.object({ code: z.literal("too_short"), minMs: z.number(), actualMs: z.number() }),
|
|
z.object({ code: z.literal("file_too_large"), maxMb: z.number(), actualMb: z.number() }),
|
|
z.object({ code: z.literal("bitrate_high"), maxKbps: z.number(), actualKbps: z.number() }),
|
|
z.object({ code: z.literal("loudness_off"), targetLufs: z.number(), actualLufs: z.number() }),
|
|
z.object({ code: z.literal("true_peak_high"), maxDb: z.number(), actualDb: z.number() }),
|
|
z.object({
|
|
code: z.literal("resolution_mismatch"),
|
|
width: z.number(),
|
|
height: z.number(),
|
|
actualWidth: z.number(),
|
|
actualHeight: z.number(),
|
|
}),
|
|
z.object({ code: z.literal("fps_mismatch"), maxFps: z.number(), actualFps: z.number() }),
|
|
z.object({ code: z.literal("codec_mismatch") }),
|
|
]);
|
|
export type MediaCheckIssue = z.infer<typeof MediaCheckIssueSchema>;
|
|
|
|
/** `fail`: the platform would reject the file. `warn`: it would accept and degrade it. */
|
|
export const MediaCheckSchema = z.object({
|
|
status: z.enum(["pass", "warn", "fail"]),
|
|
/** Most severe first. */
|
|
issues: z.array(MediaCheckIssueSchema).max(20),
|
|
});
|
|
export type MediaCheck = z.infer<typeof MediaCheckSchema>;
|
|
|
|
/** A platform an export did not render, e.g. because the video is too long for it. */
|
|
export const MediaSkippedTargetSchema = z.object({
|
|
platform: MediaPlatformIdSchema,
|
|
issues: z.array(MediaCheckIssueSchema).min(1).max(20),
|
|
});
|
|
export type MediaSkippedTarget = z.infer<typeof MediaSkippedTargetSchema>;
|
|
|
|
const VERTICAL_SAFE_ZONES = { topPct: 10, bottomPct: 20, leftPct: 5, rightPct: 15 };
|
|
const FRAMED_SAFE_ZONES = { topPct: 5, bottomPct: 10, leftPct: 5, rightPct: 5 };
|
|
const STREAMING_LOUDNESS = { targetLufs: -14, truePeakDb: -1 };
|
|
const VERTICAL_1080 = { aspect: "9:16", width: 1080, height: 1920 } as const;
|
|
const REVIEWED_AT = "2026-10-03";
|
|
|
|
/**
|
|
* Researched limits, all unverified: each needs a check against the linked documentation before
|
|
* it is trusted. YouTube allows 60 fps per its encoding guide; the others are capped at 30.
|
|
*/
|
|
export const MEDIA_PLATFORM_PROFILES: Readonly<Record<MediaPlatformId, PlatformProfile>> = {
|
|
tiktok: {
|
|
id: "tiktok",
|
|
name: "TikTok",
|
|
sourceUrls: ["https://support.tiktok.com/en/using-tiktok/creating-videos/upload-videos"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 8000, maxFileSizeMb: 287 },
|
|
duration: { hardMinMs: 3000, hardMaxMs: 600_000 },
|
|
safeZones: VERTICAL_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"instagram-reels": {
|
|
id: "instagram-reels",
|
|
name: "Instagram Reels",
|
|
sourceUrls: ["https://help.instagram.com/", "https://www.facebook.com/business/ads-guide/"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 5000, maxFileSizeMb: 1024 },
|
|
duration: { hardMinMs: 3000, hardMaxMs: 90_000 },
|
|
safeZones: VERTICAL_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"instagram-feed-4x5": {
|
|
id: "instagram-feed-4x5",
|
|
name: "Instagram Feed",
|
|
sourceUrls: ["https://help.instagram.com/", "https://www.facebook.com/business/ads-guide/"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: {
|
|
aspect: "4:5",
|
|
width: 1080,
|
|
height: 1350,
|
|
fps: 30,
|
|
codec: "h264",
|
|
maxBitrateKbps: 5000,
|
|
maxFileSizeMb: 1024,
|
|
},
|
|
duration: { hardMinMs: 3000, hardMaxMs: 60_000 },
|
|
safeZones: FRAMED_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"youtube-shorts": {
|
|
id: "youtube-shorts",
|
|
name: "YouTube Shorts",
|
|
sourceUrls: [
|
|
"https://support.google.com/youtube/answer/15424877",
|
|
"https://support.google.com/youtube/answer/10059070",
|
|
"https://support.google.com/youtube/answer/1722171",
|
|
],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: {
|
|
...VERTICAL_1080,
|
|
fps: 60,
|
|
codec: "h264",
|
|
maxBitrateKbps: 12_000,
|
|
maxFileSizeMb: 2048,
|
|
},
|
|
duration: { hardMinMs: 1000, hardMaxMs: 180_000 },
|
|
safeZones: VERTICAL_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
youtube: {
|
|
id: "youtube",
|
|
name: "YouTube",
|
|
sourceUrls: [
|
|
"https://support.google.com/youtube/answer/1722171",
|
|
"https://support.google.com/youtube/answer/71673",
|
|
],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: {
|
|
aspect: "16:9",
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 60,
|
|
codec: "h264",
|
|
maxBitrateKbps: 8000,
|
|
maxFileSizeMb: 4096,
|
|
},
|
|
duration: { hardMinMs: 1000, hardMaxMs: 900_000 },
|
|
safeZones: FRAMED_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"facebook-reels": {
|
|
id: "facebook-reels",
|
|
name: "Facebook Reels",
|
|
sourceUrls: ["https://www.facebook.com/help/", "https://www.facebook.com/business/ads-guide/"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 5000, maxFileSizeMb: 1024 },
|
|
duration: { hardMinMs: 3000, hardMaxMs: 90_000 },
|
|
safeZones: VERTICAL_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
linkedin: {
|
|
id: "linkedin",
|
|
name: "LinkedIn",
|
|
sourceUrls: ["https://www.linkedin.com/help/linkedin/"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: {
|
|
aspect: "16:9",
|
|
width: 1920,
|
|
height: 1080,
|
|
fps: 30,
|
|
codec: "h264",
|
|
maxBitrateKbps: 8000,
|
|
maxFileSizeMb: 5000,
|
|
},
|
|
duration: { hardMinMs: 3000, hardMaxMs: 600_000 },
|
|
safeZones: FRAMED_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"x-twitter": {
|
|
id: "x-twitter",
|
|
name: "X",
|
|
sourceUrls: ["https://help.x.com/en/using-x/x-videos"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: {
|
|
aspect: "16:9",
|
|
width: 1280,
|
|
height: 720,
|
|
fps: 30,
|
|
codec: "h264",
|
|
maxBitrateKbps: 5000,
|
|
maxFileSizeMb: 512,
|
|
},
|
|
duration: { hardMinMs: 1000, hardMaxMs: 140_000 },
|
|
safeZones: FRAMED_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"pinterest-video-pin": {
|
|
id: "pinterest-video-pin",
|
|
name: "Pinterest",
|
|
sourceUrls: ["https://help.pinterest.com/en/business"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 8000, maxFileSizeMb: 2048 },
|
|
duration: { hardMinMs: 4000, hardMaxMs: 300_000 },
|
|
safeZones: VERTICAL_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
"snapchat-spotlight": {
|
|
id: "snapchat-spotlight",
|
|
name: "Snapchat Spotlight",
|
|
sourceUrls: ["https://help.snapchat.com/hc/en-us"],
|
|
reviewedAt: REVIEWED_AT,
|
|
unverified: true,
|
|
video: { ...VERTICAL_1080, fps: 30, codec: "h264", maxBitrateKbps: 5000, maxFileSizeMb: 250 },
|
|
duration: { hardMinMs: 5000, hardMaxMs: 60_000 },
|
|
safeZones: VERTICAL_SAFE_ZONES,
|
|
audio: STREAMING_LOUDNESS,
|
|
},
|
|
};
|