fix(downloads): GIF review round 2 — cap as sent, shown length, retry, Join reason

The panels judge a GIF's length in whole milliseconds between the ends as sent, the server's own rule, and a refused length reads rounded up. A shared picture says it failed only once its retry failed too. Join's reason shows on a key's focus; the editor's failure toasts name GIFs. The edit route's cap is test-held, and DESIGN.md records the GIF UI.
This commit is contained in:
2026-11-13 00:19:53 +01:00
parent 8845d1aa49
commit fe6af1445f
12 changed files with 200 additions and 43 deletions
+30 -8
View File
@@ -4,8 +4,10 @@ import {
gifEstimateText,
GIF_MAX_BYTES,
GIF_PREVIEW_MAX_BYTES,
gifShownLength,
gifVerdict,
} from "./gif";
import { formatTimecode } from "./timecode";
const MB = 1024 * 1024;
@@ -50,22 +52,42 @@ describe("gifEstimateText", () => {
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");
expect(gifVerdict(0, 30.2, { hi: 1 })).toBe("too_long");
expect(gifVerdict(0, 30, { hi: 1 })).toBe("ok");
});
it("judges the length the server judges: whole milliseconds between the ends as sent", () => {
// A float difference a hair over 30 s is exactly 30 s once the ends are sent.
expect(60.529 - 30.529).toBeGreaterThan(30);
expect(gifVerdict(30.529, 60.529, { hi: 1 })).toBe("ok");
expect(gifVerdict(0, 30.001, { hi: 1 })).toBe("too_long");
// Each end is sent rounded on its own: 10.0004–40.0008 goes as 10–40.001, 30001 ms, refused…
expect(gifVerdict(10.0004, 40.0008, { hi: 1 })).toBe("too_long");
// …and 10.0006–40.0012 as 10.001–40.001, exactly 30 s, made.
expect(gifVerdict(10.0006, 40.0012, { hi: 1 })).toBe("ok");
});
it("warns past the server's cap, then past the link preview's", () => {
expect(gifVerdict(10, { hi: GIF_MAX_BYTES + 0.6 * MB })).toBe("may_fail");
expect(gifVerdict(10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES + 0.6 * MB })).toBe("no_preview");
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok");
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES + 0.6 * MB })).toBe("may_fail");
expect(gifVerdict(0, 10, { hi: GIF_MAX_BYTES })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.6 * MB })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES })).toBe("ok");
});
it("judges the size the reader is shown: a high end shown as 30 MB is not past 30 MB", () => {
// 30 s at 480 lands a hair over 30 MiB, and reads "about 12–30 MB".
const hi = GIF_MAX_BYTES + 0.4 * MB;
expect(gifEstimateText({ lo: 12 * MB, hi }, "en").hi).toBe("30");
expect(gifVerdict(30, { hi })).toBe("no_preview");
expect(gifVerdict(10, { hi: GIF_PREVIEW_MAX_BYTES + 0.4 * MB })).toBe("ok");
expect(gifVerdict(0, 30, { hi })).toBe("no_preview");
expect(gifVerdict(0, 10, { hi: GIF_PREVIEW_MAX_BYTES + 0.4 * MB })).toBe("ok");
});
});
describe("gifShownLength", () => {
it("never shows a refused range as one the cap allows", () => {
expect(formatTimecode(gifShownLength(0, 30.03))).toBe("0:30.1");
expect(formatTimecode(gifShownLength(0, 30.001))).toBe("0:30.1");
expect(gifShownLength(30.529, 60.529)).toBe(30);
expect(gifShownLength(0, 12.34)).toBe(12.34);
});
});
+18 -2
View File
@@ -53,8 +53,24 @@ export function gifEstimateText(est: { lo: number; hi: number }, lang: string) {
* 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";
/** 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. */
export 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;