feat(downloads): a clip or an edit can be a GIF, shared as a picture

A clip and an editor range can come out as a silent GIF (long side
320/480/640, 12 fps, at most 30 s, one palette per GIF); one over 30 MB
is not kept. A shared picture is served inline under the preview's
sandbox, and a GIF's link card is the GIF itself up to 15 MB, the still
for Facebook's crawler.
This commit is contained in:
2026-11-11 01:50:55 +01:00
parent fe38c07e7e
commit c221452a00
24 changed files with 885 additions and 58 deletions
@@ -55,6 +55,15 @@ describe.each(["en", "hu"])("localizeDetail (%s)", (lng) => {
expect(upload).not.toBe(i18n.t("downloads.errors.upload.tooLarge", { size: "" }));
});
it("says a clip's cap in minutes and a GIF's in seconds, from a clip or an edit", async () => {
expect(await say({ code: "clip", reason: "too_long", limit_s: 600 })).toContain("10");
for (const code of ["clip", "edit"]) {
const said = await say({ code, reason: "gif_too_long", limit_s: 30 });
expect(said).toContain("30");
expect(said).not.toContain("{{");
}
});
it("says a shared-sentence code's own fallback for a reason it does not know", async () => {
expect(await say({ code: "quota", reason: "nope" })).toBe(
i18n.t("downloads.errors.quota.generic"),
+9 -4
View File
@@ -30,13 +30,18 @@ const byReason = new Map(
const fixed = new Map<string, string>(Object.entries(REFUSAL_FIXED));
// The numbers a refusal carries, in the reader's number format: a download cap in GB, a clip cap in
// minutes, an upload cap in bytes, a password's minimum length; a generic `refused` interpolates
// whatever values its sentence names (`{{min}}`, `{{name}}`, …).
// minutes (a GIF's in seconds), an upload cap in bytes, a password's minimum length; a generic
// `refused` interpolates whatever values its sentence names (`{{min}}`, `{{name}}`, …).
function valuesOf(o: NonNullable<Detail>): Record<string, unknown> {
const lang = i18n.language;
if (o.code === "refused") return { ...o };
if (o.code === "clip")
return { minutes: o.limit_s == null ? undefined : formatDecimal(o.limit_s / 60, lang, 1, 0) };
if (o.code === "clip" || o.code === "edit")
return o.limit_s == null
? {}
: {
minutes: formatDecimal(o.limit_s / 60, lang, 1, 0),
seconds: formatDecimal(o.limit_s, lang, 0, 0),
};
if (o.code === "quota")
return o.reason === "max_bytes"
? { size: formatDecimal((o.limit || 0) / 1_073_741_824, lang, 1, 0) }