fix(media): preserve GIF animation, skip JPEG optimization

- Skip JPEG optimization for image/gif content type (both local and URL)
- Preserves animation in uploaded GIFs to Discord/other providers
- Added tests for GIF preservation from local files and URLs
- Updated changelog
This commit is contained in:
Peter Steinberger
2026-01-02 00:55:52 +00:00
parent 4c2812b429
commit 76e24653e9
3 changed files with 79 additions and 0 deletions

View File

@@ -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`

View File

@@ -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();
});
});

View File

@@ -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) {