Files
siftlode/frontend/src/lib/gif.ts
T

80 lines
4.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
}