Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file not shown.
Binary file not shown.
28 changes: 25 additions & 3 deletions packages/vinext/src/server/image-animation.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
/**
* Animated image detection for the image optimization endpoint.
*
* Next.js serves an animated GIF, PNG (APNG) or WebP unoptimized rather than
* flattening it to its first frame. This is a port of the `is-animated`
* Animated images are served as-is to preserve all their frames.
* The GIF, PNG and WebP checks are a port of the `is-animated`
* package Next.js compiles in (next/dist/compiled/is-animated, MIT, Józef
* Sokołowski), which `imageOptimizer` in
* packages/next/src/server/image-optimizer.ts calls on the upstream bytes.
Expand Down Expand Up @@ -114,8 +114,27 @@ async function isAnimatedWebp(source: ImageSourceReader): Promise<boolean> {
}
}

/** An AVIF sequence advertises `avis` as a major or compatible brand. */
async function isAnimatedAvif(source: ImageSourceReader, head: Uint8Array): Promise<boolean> {
const boxSize = new DataView(head.buffer).getUint32(0);
if (boxSize < 16) return false;
if (ascii(head, 8, 12) === "avis") return (await source.bytes(12, 4)).length === 4;

// Skip the minor version at byte 12 and stop at the end of the ftyp box.
const sequenceBrand = [0x61, 0x76, 0x69, 0x73]; // "avis"
for (let offset = 16; offset + 4 <= boxSize; offset += 4096) {
const length = Math.min(4096, boxSize - offset);
const brands = await source.bytes(offset, length);
for (let index = 0; index + 4 <= brands.length; index += 4) {
if (bytesAt(brands, index, sequenceBrand)) return true;
}
if (brands.length < length) return false;
}
return false;
}

/**
* Whether a source is an animated GIF, PNG or WebP, judged by its signature.
* Whether a source is an animated GIF, PNG, WebP or AVIF.
* The source is parsed as it streams in, so only the bytes a format's check
* needs are read, and none are kept beyond what the reader holds to replay.
*/
Expand All @@ -124,5 +143,8 @@ export async function isAnimatedImage(source: ImageSourceReader): Promise<boolea
if (ascii(head, 0, 3) === "GIF") return isAnimatedGif(source, head);
if (bytesAt(head, 0, PNG_SIGNATURE)) return isAnimatedPng(source);
if (bytesAt(head, 8, WEBP_FOURCC)) return isAnimatedWebp(source);
if (head.length === 12 && ascii(head, 4, 8) === "ftyp") {
return isAnimatedAvif(source, head);
}
return false;
}
9 changes: 7 additions & 2 deletions packages/vinext/src/server/image-optimization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,8 +452,13 @@ const BYPASS_IMAGE_CONTENT_TYPES = new Set([
"image/heic",
]);

/** Formats Next.js serves as-is when animated (`ANIMATABLE_TYPES`). */
const ANIMATABLE_IMAGE_CONTENT_TYPES = new Set(["image/webp", "image/png", "image/gif"]);
/** Formats served as-is when animated. */
const ANIMATABLE_IMAGE_CONTENT_TYPES = new Set([
"image/webp",
"image/png",
"image/gif",
"image/avif",
]);

/** Next.js's 400 body for a source whose type it does not serve, such as SVG. */
export const IMAGE_TYPE_NOT_ALLOWED_MESSAGE =
Expand Down
2 changes: 1 addition & 1 deletion packages/vinext/src/server/image-source-reader.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Streamed reads of an image source for the image optimization endpoint.
*
* Animation detection parses a GIF, PNG or WebP source as it streams in rather
* Animation detection parses an image source as it streams in rather
* than buffering it, so a large source cannot exhaust a Worker isolate's
* memory. Only the source's first bytes are kept, to replay without a refetch.
*/
Expand Down
4 changes: 3 additions & 1 deletion tests/e2e/cloudflare-workers/image-optimizer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { expect, test, type APIRequestContext } from "@playwright/test";
// with each kind of source, against the Cloudflare Images binding. Each
// expectation was observed with `next start` on next@16.2.7 for the same file.
//
// The Worker detects animation as a GIF, PNG or WebP source streams in and
// The Worker detects animation as the source streams in and
// keeps only its first 1 MiB to replay. Some sources here are padded so that
// detection decides before the source ends, or reads past what it keeps and
// fetches the source again.
Expand Down Expand Up @@ -45,6 +45,7 @@ test.describe("Cloudflare Workers /_next/image optimizes still images", () => {
for (const [source, why] of [
["/image-still.jpg", "a format detection skips"],
["/image-still.webp", "a still WebP, read to its end"],
["/image-still.avif", "an AVIF without the sequence brand"],
] as const) {
test(`transforms ${source}: ${why}`, async ({ request }) => {
const sourceBytes = await original(request, source);
Expand Down Expand Up @@ -89,6 +90,7 @@ test.describe("Cloudflare Workers /_next/image serves animated images as-is", ()
for (const [source, contentType, cacheControl, why] of [
["/image-animated.webp", "image/webp", REVALIDATED, "decided at its ANIM chunk"],
["/image-animated.png", "image/png", REVALIDATED, "an APNG, read to its end"],
["/image-animated.avif", "image/avif", REVALIDATED, "an AVIF with the avis brand"],
// next.config.mjs gives it a longer max-age, which Next.js keeps over
// minimumCacheTTL.
[
Expand Down
39 changes: 35 additions & 4 deletions tests/image-bypass-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,20 @@ function riff(chunks: string): Uint8Array<ArrayBuffer> {
const STATIC_WEBP = riff("VP8 \0\0\0\0");
const ANIMATED_WEBP = riff("VP8X\0\0\0\0ANIM\0\0\0\0");

function avif(majorBrand: string, compatibleBrands: string): Uint8Array<ArrayBuffer> {
const size = 16 + compatibleBrands.length;
return new Uint8Array([
0,
0,
0,
size,
...new TextEncoder().encode(`ftyp${majorBrand}\0\0\0\0${compatibleBrands}mdat`),
]);
}

const STATIC_AVIF = avif("avif", "mif1");
const ANIMATED_AVIF = avif("avis", "avifmsf1");

/** A stream of `bytes` in chunks of `chunkSize`, counting the chunks pulled. */
function chunkedSource(bytes: Uint8Array, chunkSize: number) {
const source = { pulled: 0, body: null as unknown as ReadableStream<Uint8Array> };
Expand Down Expand Up @@ -133,6 +147,16 @@ describe("isAnimatedImage", () => {
it("ends the scan at a truncated PNG chunk", async () => {
expect(await isAnimated(ANIMATED_PNG.subarray(0, PNG_SIGNATURE.length + 30))).toBe(false);
});

it("recognizes the AVIF sequence brand in the file type box", async () => {
expect(await isAnimated(ANIMATED_AVIF)).toBe(true);
expect(await isAnimated(avif("avif", "mif1avis"))).toBe(true);
expect(await isAnimated(STATIC_AVIF)).toBe(false);
// The next box is outside ftyp, even if its bytes happen to say avis.
const outsideFileType = STATIC_AVIF.slice();
outsideFileType.set(new TextEncoder().encode("avis"), 20);
expect(await isAnimated(outsideFileType)).toBe(false);
});
});

describe("handleImageOptimization formats Next.js does not optimize", () => {
Expand Down Expand Up @@ -227,6 +251,7 @@ describe("handleImageOptimization formats Next.js does not optimize", () => {
["GIF", ANIMATED_GIF, "image/gif"],
["PNG", ANIMATED_PNG, "image/png"],
["WebP", ANIMATED_WEBP, "image/webp"],
["AVIF", ANIMATED_AVIF, "image/avif"],
] as const) {
it(`serves an animated ${name} as-is`, async () => {
const { handlers: imageHandlers, transformed } = handlers(bytes, contentType);
Expand All @@ -239,13 +264,19 @@ describe("handleImageOptimization formats Next.js does not optimize", () => {
});
}

it("transforms a still GIF from its replayed bytes", async () => {
const { handlers: imageHandlers, transformed } = handlers(STATIC_GIF, "image/gif");
const response = await handleImageOptimization(imageRequest("/still.gif"), imageHandlers);
it.each([
["gif", STATIC_GIF, "image/gif"],
["avif", STATIC_AVIF, "image/avif"],
] as const)("transforms a still %s from its replayed bytes", async (extension, bytes, type) => {
const { handlers: imageHandlers, transformed } = handlers(bytes, type);
const response = await handleImageOptimization(
imageRequest(`/still.${extension}`),
imageHandlers,
);
expect(response.status).toBe(200);
expect(await response.text()).toBe("transformed");
expect(response.headers.get("Content-Type")).toBe("image/avif");
expect(transformed).toEqual([new TextDecoder().decode(STATIC_GIF)]);
expect(transformed).toEqual([new TextDecoder().decode(bytes)]);
});

it("falls back to the replayed source when the transform fails", async () => {
Expand Down