1
mirror of https://github.com/jakejarvis/hoot.git synced 2025-10-18 20:14:25 -04:00

Refactor image processing functions to use WebP format (#93)

This commit is contained in:
2025-10-14 12:54:02 -04:00
committed by GitHub
parent 19f7cdf2e6
commit 4b64ef1efe
9 changed files with 52 additions and 105 deletions

View File

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

View File

@@ -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<Buffer | null> {
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<Buffer | null> {
return convertBufferToPngCover(input, size, size, contentTypeHint);
}
export async function optimizePngCover(
png: Buffer,
export async function optimizeImageCover(
buffer: Buffer,
width: number,
height: number,
): Promise<Buffer> {
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<Buffer> {
@@ -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();
}

View File

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

View File

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

View File

@@ -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]),
}),
}),

View File

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

View File

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

View File

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

View File

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