diff --git a/lib/image.test.ts b/lib/image.test.ts index cb3c41bb..dc102be2 100644 --- a/lib/image.test.ts +++ b/lib/image.test.ts @@ -1,15 +1,11 @@ /* @vitest-environment node */ import sharp from "sharp"; import { describe, expect, it } from "vitest"; -import { - addWatermarkToScreenshot, - convertBufferToSquarePng, - optimizePngCover, -} from "./image"; +import { addWatermarkToScreenshot, optimizeImageCover } from "./image"; describe("image utilities", () => { describe("addWatermarkToScreenshot", () => { - it("adds watermark to screenshot and returns valid PNG buffer", async () => { + it("adds watermark to screenshot and returns valid WebP buffer", async () => { // Create a simple test PNG (100x100 red square) const testPng = await sharp({ create: { @@ -24,13 +20,13 @@ describe("image utilities", () => { const result = await addWatermarkToScreenshot(testPng, 100, 100); - // Verify result is a valid PNG buffer + // Verify result is a valid WebP buffer expect(Buffer.isBuffer(result)).toBe(true); expect(result.length).toBeGreaterThan(0); - // Verify it's still a valid PNG by processing with Sharp + // Verify it's still a valid image by processing with Sharp const metadata = await sharp(result).metadata(); - expect(metadata.format).toBe("png"); + expect(metadata.format).toBe("webp"); expect(metadata.width).toBe(100); expect(metadata.height).toBe(100); @@ -64,7 +60,7 @@ describe("image utilities", () => { const smallResult = await addWatermarkToScreenshot(smallPng, 200, 200); const largeResult = await addWatermarkToScreenshot(largePng, 1200, 630); - // Both should be valid PNGs + // Both should be valid WebPs expect(Buffer.isBuffer(smallResult)).toBe(true); expect(Buffer.isBuffer(largeResult)).toBe(true); @@ -90,12 +86,12 @@ describe("image utilities", () => { const metadata = await sharp(result).metadata(); expect(metadata.width).toBe(1200); expect(metadata.height).toBe(630); - expect(metadata.format).toBe("png"); + expect(metadata.format).toBe("webp"); }); }); - describe("optimizePngCover", () => { - it("optimizes PNG with cover fit", async () => { + describe("optimizeImageCover", () => { + it("optimizes image with cover fit into WebP", async () => { const testPng = await sharp({ create: { width: 100, @@ -107,43 +103,12 @@ describe("image utilities", () => { .png() .toBuffer(); - const result = await optimizePngCover(testPng, 50, 50); + const result = await optimizeImageCover(testPng, 50, 50); const metadata = await sharp(result).metadata(); - expect(metadata.format).toBe("png"); + expect(metadata.format).toBe("webp"); expect(metadata.width).toBe(50); expect(metadata.height).toBe(50); }); }); - - describe("convertBufferToSquarePng", () => { - it("converts buffer to square PNG", async () => { - const testPng = await sharp({ - create: { - width: 100, - height: 50, - channels: 4, - background: { r: 0, g: 255, b: 0, alpha: 1 }, - }, - }) - .png() - .toBuffer(); - - const result = await convertBufferToSquarePng(testPng, 64); - - expect(result).not.toBeNull(); - if (result) { - const metadata = await sharp(result).metadata(); - expect(metadata.format).toBe("png"); - expect(metadata.width).toBe(64); - expect(metadata.height).toBe(64); - } - }); - - it("returns null for invalid buffer", async () => { - const invalidBuffer = Buffer.from("not an image"); - const result = await convertBufferToSquarePng(invalidBuffer, 64); - expect(result).toBeNull(); - }); - }); }); diff --git a/lib/image.ts b/lib/image.ts index 3e94bc75..c18628db 100644 --- a/lib/image.ts +++ b/lib/image.ts @@ -12,18 +12,14 @@ function isIcoBuffer(buf: Buffer): boolean { ); } -export async function convertBufferToPngCover( +export async function convertBufferToImageCover( input: Buffer, width: number, height: number, contentTypeHint?: string | null, ): Promise { try { - const img = sharp(input, { failOn: "none" }); - const pipeline = img - .resize(width, height, { fit: "cover" }) - .png({ compressionLevel: 9 }); - return await pipeline.toBuffer(); + return await optimizeImageCover(input, width, height); } catch { // ignore and try ICO-specific decode if it looks like ICO } @@ -60,10 +56,7 @@ export async function convertBufferToPngCover( const arrBuf: ArrayBuffer | undefined = chosen.buffer ?? chosen.data; if (arrBuf) { const pngBuf = Buffer.from(arrBuf); - return await sharp(pngBuf) - .resize(width, height, { fit: "cover" }) - .png({ compressionLevel: 9 }) - .toBuffer(); + return await optimizeImageCover(pngBuf, width, height); } } } catch { @@ -74,27 +67,19 @@ export async function convertBufferToPngCover( return null; } -export async function convertBufferToSquarePng( - input: Buffer, - size: number, - contentTypeHint?: string | null, -): Promise { - return convertBufferToPngCover(input, size, size, contentTypeHint); -} - -export async function optimizePngCover( - png: Buffer, +export async function optimizeImageCover( + buffer: Buffer, width: number, height: number, ): Promise { - return await sharp(png) + return await sharp(buffer) .resize(width, height, { fit: "cover" }) - .png({ compressionLevel: 9 }) + .webp({}) .toBuffer(); } export async function addWatermarkToScreenshot( - png: Buffer, + buffer: Buffer, width: number, height: number, ): Promise { @@ -120,7 +105,7 @@ export async function addWatermarkToScreenshot( const watermarkBuffer = Buffer.from(watermarkSvg); - return await sharp(png) + return await sharp(buffer) .resize(width, height, { fit: "cover" }) .composite([ { @@ -128,6 +113,6 @@ export async function addWatermarkToScreenshot( blend: "over", }, ]) - .png({ compressionLevel: 9 }) + .webp({}) .toBuffer(); } diff --git a/lib/storage.test.ts b/lib/storage.test.ts index 92f70916..766114a0 100644 --- a/lib/storage.test.ts +++ b/lib/storage.test.ts @@ -33,7 +33,7 @@ describe("storage uploads", () => { domain: "example.com", width: 32, height: 32, - png: Buffer.from([1, 2, 3]), + buffer: Buffer.from([1, 2, 3]), }); expect(res.url).toBe("https://app.ufs.sh/f/mock-key"); // we return UploadThing file key for deletion @@ -50,7 +50,7 @@ describe("storage uploads", () => { domain: "example.com", width: 1200, height: 630, - png: Buffer.from([4, 5, 6]), + buffer: Buffer.from([4, 5, 6]), }); expect(res.url).toBe("https://app.ufs.sh/f/mock-key"); // we return UploadThing file key for deletion @@ -74,7 +74,7 @@ describe("storage uploads", () => { domain: "retry.com", width: 32, height: 32, - png: Buffer.from([1, 2, 3]), + buffer: Buffer.from([1, 2, 3]), }); expect(res.url).toBe("https://app.ufs.sh/f/retry-key"); @@ -98,7 +98,7 @@ describe("storage uploads", () => { domain: "retry.com", width: 32, height: 32, - png: Buffer.from([1, 2, 3]), + buffer: Buffer.from([1, 2, 3]), }); expect(res.url).toBe("https://app.ufs.sh/f/retry-key"); @@ -114,7 +114,7 @@ describe("storage uploads", () => { domain: "fail.com", width: 32, height: 32, - png: Buffer.from([1, 2, 3]), + buffer: Buffer.from([1, 2, 3]), }), ).rejects.toThrow(/Upload failed after 3 attempts/); @@ -137,7 +137,7 @@ describe("storage uploads", () => { domain: "error.com", width: 32, height: 32, - png: Buffer.from([1, 2, 3]), + buffer: Buffer.from([1, 2, 3]), }); expect(res.url).toBe("https://app.ufs.sh/f/ok"); @@ -158,7 +158,7 @@ describe("hashing helpers", () => { const f2 = makeImageFileName("social", "example.com", 1200, 630); const f3 = makeImageFileName("social", "example.com", 1200, 630, "v2"); expect(f1).toBe(f2); - expect(f1).toMatch(/^social_[a-f0-9]{32}\.png$/); + expect(f1).toMatch(/^social_[a-f0-9]{32}\.webp$/); expect(f3).not.toBe(f1); }); }); diff --git a/lib/storage.ts b/lib/storage.ts index f5f9c93c..b1e96d9b 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -52,7 +52,7 @@ export function makeImageFileName( ): string { const base = `${kind}:${domain}:${width}x${height}${extra ? `:${extra}` : ""}`; const digest = deterministicHash(base); - return `${kind}_${digest}.png`; + return `${kind}_${digest}.webp`; } const utapi = new UTApi(); @@ -171,13 +171,11 @@ export async function uploadImage(options: { domain: string; width: number; height: number; - png: Buffer; + buffer: Buffer; }): Promise<{ url: string; key: string }> { - const { kind, domain, width, height, png } = options; + const { kind, domain, width, height, buffer } = options; const fileName = makeImageFileName(kind, domain, width, height); - const file = new UTFile([new Uint8Array(png)], fileName, { - type: "image/png", - }); + const file = new UTFile([new Uint8Array(buffer)], fileName); return await uploadWithRetry(file); } diff --git a/server/services/favicon.test.ts b/server/services/favicon.test.ts index f9baeb65..70189897 100644 --- a/server/services/favicon.test.ts +++ b/server/services/favicon.test.ts @@ -11,11 +11,11 @@ const storageMock = vi.hoisted(() => ({ vi.mock("@/lib/storage", () => storageMock); -// Mock sharp to return a pipeline that resolves a buffer +// Mock sharp to return a pipeline that resolves a buffer (now using webp) vi.mock("sharp", () => ({ default: (_input: unknown, _opts?: unknown) => ({ resize: () => ({ - png: () => ({ + webp: () => ({ toBuffer: async () => Buffer.from([1, 2, 3]), }), }), diff --git a/server/services/favicon.ts b/server/services/favicon.ts index 3b53b7a9..a6e527af 100644 --- a/server/services/favicon.ts +++ b/server/services/favicon.ts @@ -1,6 +1,6 @@ import { captureServer } from "@/lib/analytics/server"; import { USER_AGENT } from "@/lib/constants"; -import { convertBufferToSquarePng } from "@/lib/image"; +import { convertBufferToImageCover } from "@/lib/image"; import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis"; import { getFaviconTtlSeconds, uploadImage } from "@/lib/storage"; @@ -40,8 +40,6 @@ function buildSources(domain: string): string[] { ]; } -// Legacy getFaviconPngForDomain removed - export async function getOrCreateFaviconBlobUrl( domain: string, ): Promise<{ url: string | null }> { @@ -160,15 +158,16 @@ export async function getOrCreateFaviconBlobUrl( bytes: buf.length, }); - const png = await convertBufferToSquarePng( + const webp = await convertBufferToImageCover( buf, DEFAULT_SIZE, + DEFAULT_SIZE, contentType, ); - if (!png) continue; - console.debug("[favicon] converted to png", { + if (!webp) continue; + console.debug("[favicon] converted to webp", { size: DEFAULT_SIZE, - bytes: png.length, + bytes: webp.length, }); const source = (() => { @@ -185,7 +184,7 @@ export async function getOrCreateFaviconBlobUrl( domain, width: DEFAULT_SIZE, height: DEFAULT_SIZE, - png, + buffer: webp, }); console.info("[favicon] uploaded", { url, key }); diff --git a/server/services/screenshot.test.ts b/server/services/screenshot.test.ts index 84ca32ca..7c016907 100644 --- a/server/services/screenshot.test.ts +++ b/server/services/screenshot.test.ts @@ -35,7 +35,7 @@ vi.mock("puppeteer-core", () => ({ // Watermark function does a simple pass-through for test speed vi.mock("@/lib/image", () => ({ - optimizePngCover: vi.fn(async (b: Buffer) => b), + optimizeImageCover: vi.fn(async (b: Buffer) => b), addWatermarkToScreenshot: vi.fn(async (b: Buffer) => b), })); diff --git a/server/services/screenshot.ts b/server/services/screenshot.ts index 3149cc8a..fd2d245d 100644 --- a/server/services/screenshot.ts +++ b/server/services/screenshot.ts @@ -2,7 +2,7 @@ import { waitUntil } from "@vercel/functions"; import type { Browser } from "puppeteer-core"; import { captureServer } from "@/lib/analytics/server"; import { USER_AGENT } from "@/lib/constants"; -import { addWatermarkToScreenshot, optimizePngCover } from "@/lib/image"; +import { addWatermarkToScreenshot, optimizeImageCover } from "@/lib/image"; import { launchChromium } from "@/lib/puppeteer"; import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis"; import { getScreenshotTtlSeconds, uploadImage } from "@/lib/storage"; @@ -190,7 +190,7 @@ export async function getOrCreateScreenshotBlobUrl( bytes: rawPng.length, }); - const png = await optimizePngCover( + const png = await optimizeImageCover( rawPng, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, @@ -199,13 +199,13 @@ export async function getOrCreateScreenshotBlobUrl( console.debug("[screenshot] optimized png bytes", { bytes: png.length, }); - const pngWithWatermark = await addWatermarkToScreenshot( + const withWatermark = await addWatermarkToScreenshot( png, VIEWPORT_WIDTH, VIEWPORT_HEIGHT, ); - console.debug("[screenshot] watermarked png bytes", { - bytes: pngWithWatermark.length, + console.debug("[screenshot] watermarked bytes", { + bytes: withWatermark.length, }); console.info("[screenshot] uploading via uploadthing"); const { url: storedUrl, key: fileKey } = await uploadImage({ @@ -213,7 +213,7 @@ export async function getOrCreateScreenshotBlobUrl( domain, width: VIEWPORT_WIDTH, height: VIEWPORT_HEIGHT, - png: pngWithWatermark, + buffer: withWatermark, }); console.info("[screenshot] uploaded", { url: storedUrl, diff --git a/server/services/seo.ts b/server/services/seo.ts index fb65a41a..7d366a3f 100644 --- a/server/services/seo.ts +++ b/server/services/seo.ts @@ -1,6 +1,6 @@ import { captureServer } from "@/lib/analytics/server"; import { USER_AGENT } from "@/lib/constants"; -import { optimizePngCover } from "@/lib/image"; +import { optimizeImageCover } from "@/lib/image"; import { acquireLockOrWaitForResult, ns, redis } from "@/lib/redis"; import type { SeoResponse } from "@/lib/schemas"; import { parseHtmlMeta, parseRobotsTxt, selectPreview } from "@/lib/seo"; @@ -252,15 +252,15 @@ async function getOrCreateSocialPreviewImageUrl( const ab = await res.arrayBuffer(); const raw = Buffer.from(ab); - const png = await optimizePngCover(raw, SOCIAL_WIDTH, SOCIAL_HEIGHT); - if (!png || png.length === 0) return { url: null }; + const image = await optimizeImageCover(raw, SOCIAL_WIDTH, SOCIAL_HEIGHT); + if (!image || image.length === 0) return { url: null }; const { url, key } = await uploadImage({ kind: "social", domain: lower, width: SOCIAL_WIDTH, height: SOCIAL_HEIGHT, - png, + buffer: image, }); try {