import * as z from "zod"; export const VIDEO_PREFIX = "/videos/"; export const VIDEO_R2_PREFIX = "videos/"; export const SCRUB_MANIFEST = "scrub/manifest.json"; const idPattern = /^[a-z0-9][a-z0-9-]{0,63}$/; const assetPattern = /^(?:master\.m3u8|poster\.jpg|captions\/[a-z]{2}\.vtt|scrub\/(?:manifest\.json|sheet-[0-9]{3}\.webp)|[1-9][0-9]{0,3}\/(?:index\.m3u8|init\.mp4|[0-9]{5}\.m4s))$/; export interface VideoPath { id: string; version: string; asset: string; } /** `/videos///`; anything outside the prepared layout is refused before touching R2. */ export function parseVideoPath(pathname: string): VideoPath | null { if (!pathname.startsWith(VIDEO_PREFIX)) return null; const [id, version, ...rest] = pathname.slice(VIDEO_PREFIX.length).split("/"); const asset = rest.join("/"); if (!id || !version || !idPattern.test(id) || !idPattern.test(version) || !assetPattern.test(asset)) return null; return { id, version, asset }; } export function contentTypeForAsset(asset: string): string { if (asset.endsWith(".m3u8")) return "application/vnd.apple.mpegurl"; if (asset.endsWith(".jpg")) return "image/jpeg"; if (asset.endsWith(".webp")) return "image/webp"; if (asset.endsWith(".vtt")) return "text/vtt; charset=utf-8"; if (asset.endsWith(".json")) return "application/json"; return "video/mp4"; } export function videoR2Key({ id, version, asset }: VideoPath): string { return `${VIDEO_R2_PREFIX}${id}/${version}/${asset}`; } export function videoUrl(id: string, version: string, asset: string): string { return `${VIDEO_PREFIX}${id}/${version}/${asset}`; } export const scrubManifestSchema = z.object({ interval: z.number().positive(), count: z.number().int().positive(), frameWidth: z.number().int().positive(), frameHeight: z.number().int().positive(), columns: z.number().int().positive(), rows: z.number().int().positive(), sheets: z.array(z.string().regex(/^scrub\/sheet-[0-9]{3}\.webp$/)).min(1), }); export type ScrubManifest = z.infer; export interface ScrubFrame { sheet: string; x: number; y: number; } /** The sprite tile nearest `time`; tiles are taken every `interval` seconds starting at 0. */ export function scrubFrameAt(manifest: ScrubManifest, time: number): ScrubFrame { const index = Math.min(manifest.count - 1, Math.max(0, Math.round(time / manifest.interval))); const perSheet = manifest.columns * manifest.rows; const within = index % perSheet; return { sheet: manifest.sheets[Math.min(manifest.sheets.length - 1, Math.floor(index / perSheet))], x: (within % manifest.columns) * manifest.frameWidth, y: Math.floor(within / manifest.columns) * manifest.frameHeight, }; }