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:
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
## 2.0.0-beta5 — Unreleased
|
## 2.0.0-beta5 — Unreleased
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Media: preserve GIF animation when uploading to Discord/other providers (skip JPEG optimization for image/gif).
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- Skills config schema moved under `skills.*`:
|
- Skills config schema moved under `skills.*`:
|
||||||
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
- `skillsLoad.extraDirs` → `skills.load.extraDirs`
|
||||||
|
|||||||
@@ -75,4 +75,58 @@ describe("web media loading", () => {
|
|||||||
|
|
||||||
fetchMock.mockRestore();
|
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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,6 +74,17 @@ export async function loadWebMedia(
|
|||||||
maxBytesForKind(kind),
|
maxBytesForKind(kind),
|
||||||
);
|
);
|
||||||
if (kind === "image") {
|
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 };
|
return { ...(await optimizeAndClampImage(array, cap)), fileName };
|
||||||
}
|
}
|
||||||
if (array.length > cap) {
|
if (array.length > cap) {
|
||||||
@@ -105,6 +116,17 @@ export async function loadWebMedia(
|
|||||||
maxBytesForKind(kind),
|
maxBytesForKind(kind),
|
||||||
);
|
);
|
||||||
if (kind === "image") {
|
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 };
|
return { ...(await optimizeAndClampImage(data, cap)), fileName };
|
||||||
}
|
}
|
||||||
if (data.length > cap) {
|
if (data.length > cap) {
|
||||||
|
|||||||
Reference in New Issue
Block a user