diff --git a/examples/app-router-cloudflare/public/image-animated.avif b/examples/app-router-cloudflare/public/image-animated.avif new file mode 100644 index 0000000000..8775349620 Binary files /dev/null and b/examples/app-router-cloudflare/public/image-animated.avif differ diff --git a/examples/app-router-cloudflare/public/image-still.avif b/examples/app-router-cloudflare/public/image-still.avif new file mode 100644 index 0000000000..afa3726f71 Binary files /dev/null and b/examples/app-router-cloudflare/public/image-still.avif differ diff --git a/packages/vinext/src/server/image-animation.ts b/packages/vinext/src/server/image-animation.ts index 40cbd3828f..ac84d4d5b8 100644 --- a/packages/vinext/src/server/image-animation.ts +++ b/packages/vinext/src/server/image-animation.ts @@ -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. @@ -114,8 +114,27 @@ async function isAnimatedWebp(source: ImageSourceReader): Promise { } } +/** An AVIF sequence advertises `avis` as a major or compatible brand. */ +async function isAnimatedAvif(source: ImageSourceReader, head: Uint8Array): Promise { + 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. */ @@ -124,5 +143,8 @@ export async function isAnimatedImage(source: ImageSourceReader): Promise { 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); @@ -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. [ diff --git a/tests/image-bypass-types.test.ts b/tests/image-bypass-types.test.ts index 216fd4541d..14f1ab6a7e 100644 --- a/tests/image-bypass-types.test.ts +++ b/tests/image-bypass-types.test.ts @@ -68,6 +68,20 @@ function riff(chunks: string): Uint8Array { 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 { + 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 }; @@ -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", () => { @@ -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); @@ -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 () => {