Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 11 additions & 46 deletions lib/image.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand All @@ -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);

Expand Down Expand Up @@ -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);

Expand All @@ -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,
Expand All @@ -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 changes: 10 additions & 25 deletions lib/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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> {
Expand All @@ -120,14 +105,14 @@ export async function addWatermarkToScreenshot(

const watermarkBuffer = Buffer.from(watermarkSvg);

return await sharp(png)
return await sharp(buffer)
.resize(width, height, { fit: "cover" })
.composite([
{
input: watermarkBuffer,
blend: "over",
},
])
.png({ compressionLevel: 9 })
.webp({})
.toBuffer();
}
14 changes: 7 additions & 7 deletions lib/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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");
Expand All @@ -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");
Expand All @@ -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/);

Expand All @@ -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");
Expand All @@ -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);
});
});
10 changes: 4 additions & 6 deletions lib/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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);
}
4 changes: 2 additions & 2 deletions server/services/favicon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]),
}),
}),
Expand Down
15 changes: 7 additions & 8 deletions server/services/favicon.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -40,8 +40,6 @@ function buildSources(domain: string): string[] {
];
}

// Legacy getFaviconPngForDomain removed

export async function getOrCreateFaviconBlobUrl(
domain: string,
): Promise<{ url: string | null }> {
Expand Down Expand Up @@ -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 = (() => {
Expand All @@ -185,7 +184,7 @@ export async function getOrCreateFaviconBlobUrl(
domain,
width: DEFAULT_SIZE,
height: DEFAULT_SIZE,
png,
buffer: webp,
});
console.info("[favicon] uploaded", { url, key });

Expand Down
2 changes: 1 addition & 1 deletion server/services/screenshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}));

Expand Down
12 changes: 6 additions & 6 deletions server/services/screenshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -190,7 +190,7 @@ export async function getOrCreateScreenshotBlobUrl(
bytes: rawPng.length,
});

const png = await optimizePngCover(
const png = await optimizeImageCover(
rawPng,
VIEWPORT_WIDTH,
VIEWPORT_HEIGHT,
Expand All @@ -199,21 +199,21 @@ 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({
kind: "screenshot",
domain,
width: VIEWPORT_WIDTH,
height: VIEWPORT_HEIGHT,
png: pngWithWatermark,
buffer: withWatermark,
});
console.info("[screenshot] uploaded", {
url: storedUrl,
Expand Down
Loading