80 lines
4.0 KiB
TypeScript
80 lines
4.0 KiB
TypeScript
// 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<GifSide, readonly [number, number]> = {
|
||
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";
|
||
}
|