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; 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; /** * 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; /** `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; /** 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; 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> = { 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, }, };