// A GIF cut from a clip or an edit: the sizes it may be made at and the limits it is held to, the // server's (backend downloads/gif.py; `gif.contract.test.ts` holds the two copies together), and the // size it is expected to come out at, which the panels show before anything is made. import { formatNumber } from "./format"; /** The longest side of the GIF, in pixels: the choices the server takes. */ export const GIF_SIDES = [320, 480, 640] as const; export type GifSide = (typeof GIF_SIDES)[number]; export const GIF_DEFAULT_SIDE: GifSide = 480; /** The longest GIF the server makes, in seconds. */ export const GIF_MAX_S = 30; /** Over this a chat's link preview (Teams) shows no moving picture: the share page sends a still. */ export const GIF_PREVIEW_MAX_BYTES = 15 * 1024 * 1024; /** Over this the server refuses the finished GIF. */ export const GIF_MAX_BYTES = 30 * 1024 * 1024; // Bytes a second, measured on real films at 12 frames a second, at each size of a 16:9 picture: the // low and the high end of what four sources gave. The same settings spread 2.5× by what is in the // picture, so only a range can be said before the GIF is made. const MB = 1024 * 1024; const RATE: Record = { 320: [0.23 * MB, 0.47 * MB], 480: [0.4 * MB, 1.0 * MB], 640: [0.7 * MB, 1.6 * MB], }; /** The size a GIF is expected to come out at, low and high, in bytes. `aspect` is the picture's * width / height after any crop: the long side is capped, so a square or tall picture holds more * or fewer pixels than the 16:9 one the rates were measured on. */ export function estimateGif( seconds: number, side: GifSide, aspect: number, ): { lo: number; hi: number } { const shape = aspect > 0 && Number.isFinite(aspect) ? aspect : 16 / 9; const area = shape >= 1 ? 1 / shape : shape; const scale = area / (9 / 16); const [lo, hi] = RATE[side]; const s = Math.max(0, seconds); return { lo: lo * s * scale, hi: hi * s * scale }; } /** An estimate's ends as whole megabytes in the reader's number format (at least 1, as a GIF of a * second or two still takes one in the reader's eyes). `single` when both ends read the same: the * panels then say one number ("about 1 MB"), not a range of one ("about 1–1 MB"). */ export function gifEstimateText(est: { lo: number; hi: number }, lang: string) { const lo = Math.max(1, Math.round(est.lo / MB)); const hi = Math.max(lo, Math.round(est.hi / MB)); return { lo: formatNumber(lo, lang), hi: formatNumber(hi, lang), single: lo === hi }; } /** What the panels say about a GIF before it is made: too long (refused, it does not start), it may * pass the cap (refused when it does), it may be too big to move in a link preview, or fine. */ export type GifVerdict = "too_long" | "may_fail" | "no_preview" | "ok"; /** A time as the request sends it: to the millisecond. */ export const atMs = (s: number) => Math.round(s * 1000) / 1000; /** A range's length in whole milliseconds, between its ends as the request sends them: the length * the server judges (gif.too_long), so a range the panel allows is never refused for its length, * and none it refuses would have been made. */ const spanMs = (start: number, end: number) => Math.round(end * 1000) - Math.round(start * 1000); /** A range's length to show beside a verdict: over the cap it is rounded up to the tenth, so a * refused range never reads as one the cap allows ("0:30.0" for 30.03 s). */ export function gifShownLength(start: number, end: number): number { const ms = spanMs(start, end); return ms > GIF_MAX_S * 1000 ? Math.ceil(ms / 100) / 10 : ms / 1000; } export function gifVerdict(start: number, end: number, est: { hi: number }): GifVerdict { if (spanMs(start, end) > GIF_MAX_S * 1000) return "too_long"; // Judged on the high end as shown (whole megabytes): "about 12–30 MB" beside "it may pass 30 MB" // would contradict itself, and the estimate is no finer than that anyway. const hi = Math.round(est.hi / MB) * MB; if (hi > GIF_MAX_BYTES) return "may_fail"; if (hi > GIF_PREVIEW_MAX_BYTES) return "no_preview"; return "ok"; }