From 18d659e1ffb4a8cbd4921c634f6dd94e86d21177 Mon Sep 17 00:00:00 2001 From: "Mohamed A. Ali" <133538288+Mohamedajab@users.noreply.github.com> Date: Mon, 9 Nov 2026 18:40:04 +0000 Subject: [PATCH] fix(image): preserve animated AVIFs --- .../public/image-animated.avif | Bin 0 -> 2371 bytes .../public/image-still.avif | Bin 0 -> 1039 bytes packages/vinext/src/server/image-animation.ts | 28 +++++++++++-- .../vinext/src/server/image-optimization.ts | 9 +++- .../vinext/src/server/image-source-reader.ts | 2 +- .../image-optimizer.spec.ts | 4 +- tests/image-bypass-types.test.ts | 39 ++++++++++++++++-- 7 files changed, 71 insertions(+), 11 deletions(-) create mode 100644 examples/app-router-cloudflare/public/image-animated.avif create mode 100644 examples/app-router-cloudflare/public/image-still.avif 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 0000000000000000000000000000000000000000..8775349620c19ef9d8511b40a760a247b2f6892c GIT binary patch literal 2371 zcmb7Edpwls7k_6+V^W4@XJ(5GjZ1EsaoHux?Sl;&tjnfi%naVlnAw>zp=@DkwY7^R zi=t(_nRIcBB*{It+S&|KE0>f@mPV-9=N;K!zkdDmJMZVb=kh(zdC&8l^SlrQ8Hb5t z`3x~Ega9EDa2Qt@MummEFS&3Sl?yY%JRPWx0G{QtMGPbk(8dTBN3f_u5FciWmUKkY zfgLSiyD_3z9JV}a6(1IH5BOSPkd~FdP+zpmJmlFxcq# z7DH@Cb>eY&@|?S2zJLz^cbL!Q0f~o&d^QpYDC9lk_;3u2I214#3=k>|G1VynQbAm+ znLLgliphzA(DDbkh%leKv_nvsA_|?P2tnv;TpmvhU=CLt!9te)hvZccBb6fC_?Q06 z0N#&m`+oyJsetXGT}1*$BtRoXkr6DgT;UUu@~rz87U_TRkG}cI8bZJ-gdvE+W{Hp? z&|q_fNHdYu$Y()U-dhl=l*@t{z#?;5AD@FgaN9$E4M6cupuwgKpBTtRvGla-6A_l@ZCR2Wl z&^}-QA_+zE<3)4?2Q4NeIv&(4zk5JmS_}3gWFjD-B_DDF5FNqd5a2xlKXPf%TY(L7 z$u9x|fmoz<%a~jiLxjZwkAmf!q0SaGEl~*%QOIVAiUxEX{gKCNRY%I^dt=M%NI9x! z^43(8|4m6ePr%+fTlBMWsbYXdw!4-6{Ts?BW=_eZ7DXraPCpwWDaHv~yYIjujMx6@ zJqC1Qd>k+zUx((W-gE!{M?d^wgI4R>W$c{XaAfQDFOCKEPnWuBtIu7p zS8U!E`g_K>V!fz7>pItNTY<{cudeh?elt$U@KZgd-BB$(7ORv{{{2nQq&-Q=I!Y+s zjVo!pa}{UCZR@FXI203( zDo%DeYhGnZd!m(Pw_^0Zq2UGPA5$96kA&Hsq-%f4mw3dQz}D%94SNT(TLWs6bFqTrfdc(^$H(-ig6$?-Sw1RaWrw&s%NnLMj}UG*Pv zj51A~Q|hks=&l>|Fi}(Wq%+3cCqs97ZqSXW?0u>wc*@1JmaQW=plju^k=-$s!fYCg zxkp;HkN-i`D(-XciY5>}k!4G}K@A;8M8NVaSfj#GUp~ug#bZFSuq? zs*`s9<(1*@GV!kcRhkzqS_*PA<6>gkQi3Sdix`yS{XjjxN2*zg6UONmb1uMN6O}Um z4%Se%CMXRK_vJk__cIXP)Xa$p_J*9-skl_GtRzoze!Ju`Q!+xl*{yefA@E(72{~7X zM!MbZ2W#<@1Y~A`~x*a%hyLC?woowv2LRg;+RJ1cJF#`iRt z;H^(SKHAhwpy2B_Vfw`QG53YpV~tgiZoupC`Ir*F5hpCY)qLdVO@V_m>l@ECiluC= z&RVaxHs+GvooZ$Q(Ql{FD>~J)!ViWzeRsFHy}FG!>wyarueB6D%)nwoZu=hGOv0wA z@7V2sxQeXQ6Sy*-Otvk+siqF#%C*`GLOY8iU*jq-?K^&)Z1g~)uRs_$()oRjH<|SL&1`cv-D%&l+TB`XXcbl77t+viY^_q= zErVgJplsKwgz&y-yis`R+7-D)Z~Z-A^+#rUKIZJ9T}_=|$3If^>S&ie%Z)p*|3tjGGU zrX*-CPaS)4I}V+?ktefF_sXpZc;(p==rZ%dm~dJlzhq>2wjup%jC8NwovpM{i{7NW tQX_1*X|!W+3`y$QZ8ih&(&NLky*H+ev`gj9}(M-@(*&~7y$qP literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..afa3726f7166042075da534883066298184caa5a GIT binary patch literal 1039 zcmZQzU{FXasVqn=%S>Yc0uY^>nP!-qnV9D5Xy^nK`k9+rk_eIm0{x7XoFWL5fuSHX zxdg@r(E&hyX;G?2VqQv4Dp(a?L1sxVSP3JLl*`P?PX_T^7#J9V5JWOCDl#xIy#ul} zGBfkQP5{eD0XeBmAjLp6iDd?c&iOg{U^BBa3yKPW;@+7B$@xH9B(u066{HXd5};V1 zAhRM9B*4tU!2zUr63Yyo8yR>&Lc+=UIYoKNITZ{{U`G zzXH9s)P?uGFMoQ>RL?KWd3(g2)j21`ddpb1zB%jYzV*p#)+L+V*B}2-=9%y_lVegN zqqlRm%cbb08~(;In>lINi!WTZH2+kpYVeKXV|rGN8e;2Z)eQ7fGw)q~D55U0{m}96 zwl}WvPgFls{9ZM_@22%M9?3l$?U}C3I{R|nufyBaUc8OmqbGMM{j#C_&q8kwS-!s( zQ~#a(9PsEdznF2gS}-{vXVDH9VR6UJJA}G-l?&RG-Bn*cwK+XaLKu^ zEoSPOK2yIuRmtN)u|P!V(q7F3Z6@hT*NO%EcAi=PVeOHiNr2Is_zzA z+Ql(D?P^11jkfxt{2%{*IjL*-5Df5hkzXpWJx&Ep{oBrPQ2@hYDp1C%wOO$*21?b0V)}bZ)x) z1UX6fu-04q?Dni|yj)*xHU0aS%`rw5#%HOS7rvg+;Z@FJ(VJd+l_WW3Gfy1@EcMfo#E-(`7Q zm6DYHeGf4Ezecn$A*W-liss)Xr+l-X&U}` { } } +/** 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 () => {