v4: a still is scaled from its first frame alone, a second image in its file made the copy flash — an avatar came out as a WebP of two frames looping at 40 ms each, the picture then a blank. A still's file may hold more than one image, a phone's JPEG carrying a gain map or a depth map after the photo, and ffmpeg reads each as a frame: only the JPEG and PNG copies were held to one, the WebP keeping them all as it does a GIF's. scalableImage (lib/upload.ts) now says whether the image is an animation, which a GIF alone is, and scaleImage (lib/ffmpeg.ts) writes one frame of anything else, in every format; the image proxy scales through the same call. The copies already made stay, a copy being current while it has its upload's date: uploads/scaled is to be deleted once on an instance that ran the previous version, everything in it being made again on first request.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -94,17 +94,27 @@ Deno.test("scalableImage: an animated WebP and what isn't an image are served as
|
||||
assertEquals(scalableImage(webp(0x02)), null);
|
||||
// A reader without WebP gets a PNG of what may be transparent, a JPEG of
|
||||
// the rest.
|
||||
assertEquals(scalableImage(webp(0x10)), { orientation: 1, compat: "png" });
|
||||
const still = { orientation: 1, animated: false };
|
||||
assertEquals(scalableImage(webp(0x10)), { ...still, compat: "png" });
|
||||
assertEquals(
|
||||
scalableImage(new TextEncoder().encode("RIFF\0\0\0\0WEBPVP8 ")),
|
||||
{ orientation: 1, compat: "png" },
|
||||
{ ...still, compat: "png" },
|
||||
);
|
||||
assertEquals(scalableImage(PNG), { orientation: 1, compat: "png" });
|
||||
assertEquals(scalableImage(jpegWithOrientation(1, true))?.compat, "jpg");
|
||||
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
|
||||
orientation: 1,
|
||||
assertEquals(scalableImage(PNG), { ...still, compat: "png" });
|
||||
assertEquals(scalableImage(jpegWithOrientation(1, true)), {
|
||||
...still,
|
||||
compat: "jpg",
|
||||
});
|
||||
assertEquals(scalableImage(new TextEncoder().encode("BM-a-bitmap")), {
|
||||
...still,
|
||||
compat: "jpg",
|
||||
});
|
||||
// Only a GIF's frames are an animation to keep.
|
||||
assertEquals(scalableImage(new TextEncoder().encode("GIF89a")), {
|
||||
orientation: 1,
|
||||
animated: true,
|
||||
compat: "png",
|
||||
});
|
||||
assertEquals(scalableImage(new TextEncoder().encode("<svg/>")), null);
|
||||
assertEquals(scalableImage(new Uint8Array()), null);
|
||||
});
|
||||
|
||||
@@ -38,6 +38,24 @@ test("avatars: upload, serve, and 404 when there is none", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("avatars: a still holding a second image is scaled to its first alone", async () => {
|
||||
if (!await hasFfmpeg()) return;
|
||||
const user = await createUser();
|
||||
// As a phone's photo carries a gain map after it, which ffmpeg reads as a
|
||||
// second frame.
|
||||
const photo = await makeImage(600, 400, "red");
|
||||
const extra = await makeImage(150, 100, "white");
|
||||
const both = new Uint8Array(photo.length + extra.length);
|
||||
both.set(photo);
|
||||
both.set(extra, photo.length);
|
||||
await put("/api/me/avatar", { auth: user.auth, form: formWithFile(both) });
|
||||
|
||||
const small = await get(`/api/users/${user.id}/avatar?w=128`);
|
||||
assertEquals(small.headers.get("content-type"), "image/webp");
|
||||
assertEquals(webpWidth(small.bytes), 128);
|
||||
assertFalse(new TextDecoder().decode(small.bytes).includes("ANMF"));
|
||||
});
|
||||
|
||||
test("avatars: `?w=` answers a scaled copy, made again from a new avatar", async () => {
|
||||
if (!await hasFfmpeg()) return;
|
||||
const user = await createUser();
|
||||
|
||||
Reference in New Issue
Block a user