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:
2026-11-11 02:10:07 +01:00
parent c221452a00
commit 27ccf253f5
15 changed files with 775 additions and 37 deletions
+5
View File
@@ -62,6 +62,8 @@ export interface ClipRequest {
audio_track?: number;
/** A crop frame: fractions of the box it was drawn in, and that box's shape (downloads/clip). */
crop?: { x: number; y: number; w: number; h: number; aspect: number };
/** Make it a GIF, this long side in pixels (`lib/gif` GIF_SIDES); left out, an mp4. */
gif_side?: number;
display_name?: string;
}
@@ -74,6 +76,9 @@ export interface EditSpec {
// scales them to the file's own pixels (a video's stored and shown sizes can differ).
crop?: { x: number; y: number; w: number; h: number; frame_w?: number; frame_h?: number };
accurate?: boolean;
/** Make it a GIF (a single trim with an end, `lib/gif` GIF_MAX_S long at most): a GIF is always
* re-encoded, so `accurate` does not apply. */
gif?: { side: number };
}
export interface StoryboardMeta {
+52
View File
@@ -0,0 +1,52 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
// The GIF's sizes and limits, held in two languages: the panels offer the sizes the server takes and
// stop a range it would refuse. Neither side can see the other's copy, so the gate reads both (as
// `clipLimits.contract.test.ts` does for a clip's). Read as TEXT, so a renamed constant fails here
// instead of comparing two undefineds.
const src = (u: string) => readFileSync(new URL(u, import.meta.url), "utf-8");
const backend = src("../../../backend/app/downloads/gif.py");
const frontend = src("./gif.ts");
// A product of integers ("15 * 1024 * 1024"), as both files write a byte cap.
const product = (expr: string) =>
expr
.split("*")
.map((n) => Number(n.trim()))
.reduce((a, b) => a * b, 1);
function pyConst(name: string): string {
const m = backend.match(new RegExp(`^${name}\\s*=\\s*(.+)$`, "m"));
expect(m, `backend/app/downloads/gif.py no longer defines ${name}`).toBeTruthy();
return m![1]!.trim();
}
function tsConst(name: string): string {
const m = frontend.match(
new RegExp(`^export const ${name}(?:: \\w+)? = (.+?)(?: as const)?;$`, "m"),
);
expect(m, `lib/gif.ts no longer defines ${name}`).toBeTruthy();
return m![1]!.trim();
}
const list = (s: string) =>
s
.replace(/[[\]()]/g, "")
.split(",")
.map((n) => Number(n.trim()));
describe("GIF limits", () => {
it("offers the sizes the server takes", () => {
expect(list(tsConst("GIF_SIDES"))).toEqual(list(pyConst("SIDES")));
expect(Number(tsConst("GIF_DEFAULT_SIDE"))).toBe(Number(pyConst("DEFAULT_SIDE")));
});
it.each([
["GIF_MAX_S", "MAX_S"],
["GIF_PREVIEW_MAX_BYTES", "PREVIEW_MAX_BYTES"],
["GIF_MAX_BYTES", "MAX_BYTES"],
])("%s is the server's %s", (ts, py) => {
expect(product(tsConst(ts))).toBe(product(pyConst(py)));
});
});
+42
View File
@@ -0,0 +1,42 @@
import { describe, expect, it } from "vitest";
import { estimateGif, GIF_MAX_BYTES, GIF_PREVIEW_MAX_BYTES, gifVerdict } from "./gif";
const MB = 1024 * 1024;
describe("estimateGif", () => {
it("gives the measured range for a 16:9 picture", () => {
const e = estimateGif(30, 480, 16 / 9);
expect(e.lo / MB).toBeCloseTo(12, 5);
expect(e.hi / MB).toBeCloseTo(30, 5);
});
it("grows with the length and the size", () => {
expect(estimateGif(20, 480, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi);
expect(estimateGif(10, 640, 16 / 9).hi).toBeGreaterThan(estimateGif(10, 480, 16 / 9).hi);
});
it("counts the pixels of the shape: a square holds more than 16:9, a tall one as many", () => {
const wide = estimateGif(10, 480, 16 / 9).hi;
expect(estimateGif(10, 480, 1).hi).toBeCloseTo(wide * (16 / 9), 3);
expect(estimateGif(10, 480, 9 / 16).hi).toBeCloseTo(wide, 3);
});
it("falls back to 16:9 for a shape it cannot read", () => {
expect(estimateGif(10, 480, 0).hi).toBe(estimateGif(10, 480, 16 / 9).hi);
expect(estimateGif(10, 480, Number.NaN).hi).toBe(estimateGif(10, 480, 16 / 9).hi);
});
});
describe("gifVerdict", () => {
it("refuses past the length cap, whatever the size", () => {
expect(gifVerdict(30.2, { hi: 1 })).toBe("too_long");
expect(gifVerdict(30, { hi: 1 })).toBe("ok");
});
it("warns past the server's cap, then past the link preview's", () => {
expect(gifVerdict(10, { hi: GIF_MAX_BYTES + 1 })).toBe("may_fail");
expect(gifVerdict(10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES + 1 })).toBe("no_preview");
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok");
});
});
+60
View File
@@ -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";
}