feat(downloads): make a GIF in the clip panel and the editor, and show a shared one as itself
The clip panel and the editor get a Video / GIF choice with the GIF's size, the size it is expected to come out at, and what that size means (no moving link preview over 15 MB, refused over 30 MB). A range longer than a GIF may be leaves Make unavailable with its reason. The editor makes one GIF per kept segment; Join says why it is unavailable instead of going dead. The share page shows a picture as itself with its Download, and a picture in a folder opens above the list.
This commit is contained in:
@@ -0,0 +1,60 @@
|
||||
// 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). */
|
||||
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) };
|
||||
}
|
||||
|
||||
/** 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";
|
||||
|
||||
export function gifVerdict(seconds: number, est: { hi: number }): GifVerdict {
|
||||
if (seconds > GIF_MAX_S + 0.0005) return "too_long";
|
||||
if (est.hi > GIF_MAX_BYTES) return "may_fail";
|
||||
if (est.hi > GIF_PREVIEW_MAX_BYTES) return "no_preview";
|
||||
return "ok";
|
||||
}
|
||||
Reference in New Issue
Block a user