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:
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
35
lib/image.ts
35
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<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();
|
||||
}
|
||||
|
@@ -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);
|
||||
});
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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]),
|
||||
}),
|
||||
}),
|
||||
|
@@ -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 });
|
||||
|
||||
|
@@ -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),
|
||||
}));
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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 {
|
||||
|
Reference in New Issue
Block a user