diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c8948a28..5385b7c70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## 2.0.0-beta5 — Unreleased +### Fixed +- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif). + ### Breaking - Skills config schema moved under `skills.*`: - `skillsLoad.extraDirs` → `skills.load.extraDirs` diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 45d200934..a1618d5b5 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -75,4 +75,58 @@ describe("web media loading", () => { fetchMock.mockRestore(); }); + + it("preserves GIF animation by skipping JPEG optimization", async () => { + // Create a minimal valid GIF (1x1 pixel) + // GIF89a header + minimal image data + const gifBuffer = Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, // GIF89a + 0x01, 0x00, 0x01, 0x00, // 1x1 dimensions + 0x00, 0x00, 0x00, // no global color table + 0x2c, 0x00, 0x00, 0x00, 0x00, // image descriptor + 0x01, 0x00, 0x01, 0x00, 0x00, // 1x1 image + 0x02, 0x01, 0x44, 0x00, 0x3b, // minimal LZW data + trailer + ]); + + const file = path.join(os.tmpdir(), `clawdis-media-${Date.now()}.gif`); + tmpFiles.push(file); + await fs.writeFile(file, gifBuffer); + + const result = await loadWebMedia(file, 1024 * 1024); + + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/gif"); + // GIF should NOT be converted to JPEG + expect(result.buffer.slice(0, 3).toString()).toBe("GIF"); + }); + + it("preserves GIF from URL without JPEG conversion", async () => { + const gifBytes = new Uint8Array([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x01, 0x00, 0x01, 0x00, + 0x00, 0x00, 0x00, + 0x2c, 0x00, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x01, 0x00, 0x00, + 0x02, 0x01, 0x44, 0x00, 0x3b, + ]); + + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + body: true, + arrayBuffer: async () => gifBytes.buffer.slice(gifBytes.byteOffset, gifBytes.byteOffset + gifBytes.byteLength), + headers: { get: () => "image/gif" }, + status: 200, + } as Response); + + const result = await loadWebMedia( + "https://example.com/animation.gif", + 1024 * 1024, + ); + + expect(result.kind).toBe("image"); + expect(result.contentType).toBe("image/gif"); + expect(result.buffer.slice(0, 3).toString()).toBe("GIF"); + + fetchMock.mockRestore(); + }); }); diff --git a/src/web/media.ts b/src/web/media.ts index 629908879..091406f9b 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -74,6 +74,17 @@ export async function loadWebMedia( maxBytesForKind(kind), ); if (kind === "image") { + // Skip optimization for GIFs to preserve animation + if (contentType === "image/gif") { + if (array.length > cap) { + throw new Error( + `GIF exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( + array.length / (1024 * 1024) + ).toFixed(2)}MB)`, + ); + } + return { buffer: array, contentType, kind, fileName }; + } return { ...(await optimizeAndClampImage(array, cap)), fileName }; } if (array.length > cap) { @@ -105,6 +116,17 @@ export async function loadWebMedia( maxBytesForKind(kind), ); if (kind === "image") { + // Skip optimization for GIFs to preserve animation + if (mime === "image/gif") { + if (data.length > cap) { + throw new Error( + `GIF exceeds ${(cap / (1024 * 1024)).toFixed(0)}MB limit (got ${( + data.length / (1024 * 1024) + ).toFixed(2)}MB)`, + ); + } + return { buffer: data, contentType: mime, kind, fileName }; + } return { ...(await optimizeAndClampImage(data, cap)), fileName }; } if (data.length > cap) {