From d8c1fdf5c29fc80585cdd6a23156154a9d19a0f6 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 16:09:31 +0800 Subject: [PATCH] feat(beautify): add SVG frame generators (macOS, Windows, browser) --- apps/api/src/lib/beautify/frames.ts | 196 ++++++++++++++++++++++++++++ tests/unit/beautify-frames.test.ts | 97 ++++++++++++++ 2 files changed, 293 insertions(+) create mode 100644 apps/api/src/lib/beautify/frames.ts create mode 100644 tests/unit/beautify-frames.test.ts diff --git a/apps/api/src/lib/beautify/frames.ts b/apps/api/src/lib/beautify/frames.ts new file mode 100644 index 00000000..e4900f2c --- /dev/null +++ b/apps/api/src/lib/beautify/frames.ts @@ -0,0 +1,196 @@ +import sharp from "sharp"; +import { DEVICE_FRAMES, SVG_FRAMES } from "./constants.js"; + +const WINDOW_TITLE_BAR_HEIGHT = 36; +const BROWSER_TITLE_BAR_HEIGHT = 72; + +function escapeXml(text: string): string { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +function macosFrame(width: number, variant: "light" | "dark", title?: string): string { + const bg = variant === "light" ? "#e8e8e8" : "#3a3a3c"; + const h = WINDOW_TITLE_BAR_HEIGHT; + const dotY = h / 2; + const dotR = 6; + const dotStart = 20; + const dotGap = 20; + + const titleText = title + ? `${escapeXml(title)}` + : ""; + + return ` + + + + + ${titleText} +`; +} + +function windowsFrame(width: number, variant: "light" | "dark", title?: string): string { + const bg = variant === "light" ? "#f0f0f0" : "#2b2b2b"; + const h = WINDOW_TITLE_BAR_HEIGHT; + const iconColor = variant === "light" ? "#616161" : "#a0a0a0"; + const closeHoverBg = variant === "light" ? "#e81123" : "#e81123"; + const midY = h / 2; + const btnW = 46; + const btnH = h; + + const closeX = width - btnW; + const maxX = closeX - btnW; + const minX = maxX - btnW; + + const titleText = title + ? `${escapeXml(title)}` + : ""; + + return ` + + ${titleText} + + + + + + + + + + +`; +} + +function browserFrame(width: number, variant: "light" | "dark", title?: string): string { + const bg = variant === "light" ? "#f1f5f9" : "#1e293b"; + const h = BROWSER_TITLE_BAR_HEIGHT; + const topH = 36; + const bottomH = h - topH; + + const dotY = topH / 2; + const dotR = 6; + const dotStart = 20; + const dotGap = 20; + + const tabBg = variant === "light" ? "#ffffff" : "#334155"; + const tabTextColor = variant === "light" ? "#334155" : "#e2e8f0"; + const tabText = title ? escapeXml(title) : "New Tab"; + const tabWidth = Math.min(200, width - 120); + const tabX = dotStart + dotGap * 3 + 16; + + const barBg = variant === "light" ? "#ffffff" : "#0f172a"; + const barTextColor = variant === "light" ? "#64748b" : "#94a3b8"; + const barPadX = 12; + const barY = topH + 6; + const barH = bottomH - 12; + const barRx = Math.min(barH / 2, 10); + const urlText = title ? escapeXml(title) : ""; + + // Lock icon path (small padlock in the URL bar) + const lockX = barPadX + 10; + const lockY = barY + barH / 2; + const lockColor = variant === "light" ? "#16a34a" : "#4ade80"; + + return ` + + + + + + + + + + + + + ${tabText} + + + + + + + + + + + + ${urlText} + + + +`; +} + +function generateSvgFrame( + width: number, + frame: string, + title?: string, +): { svg: string; height: number } { + switch (frame) { + case "macos-light": + return { svg: macosFrame(width, "light", title), height: WINDOW_TITLE_BAR_HEIGHT }; + case "macos-dark": + return { svg: macosFrame(width, "dark", title), height: WINDOW_TITLE_BAR_HEIGHT }; + case "windows-light": + return { svg: windowsFrame(width, "light", title), height: WINDOW_TITLE_BAR_HEIGHT }; + case "windows-dark": + return { svg: windowsFrame(width, "dark", title), height: WINDOW_TITLE_BAR_HEIGHT }; + case "browser-light": + return { svg: browserFrame(width, "light", title), height: BROWSER_TITLE_BAR_HEIGHT }; + case "browser-dark": + return { svg: browserFrame(width, "dark", title), height: BROWSER_TITLE_BAR_HEIGHT }; + default: + throw new Error(`Unknown SVG frame type: ${frame}`); + } +} + +export async function renderFrame( + imageBuffer: Buffer, + frame: string, + title?: string, +): Promise { + if (frame === "none") { + return imageBuffer; + } + + if (DEVICE_FRAMES.has(frame)) { + return imageBuffer; + } + + if (!SVG_FRAMES.has(frame)) { + return imageBuffer; + } + + const meta = await sharp(imageBuffer).metadata(); + const imgW = meta.width ?? 100; + const imgH = meta.height ?? 100; + + const { svg, height: titleBarH } = generateSvgFrame(imgW, frame, title); + + const titleBarBuf = await sharp(Buffer.from(svg)).resize(imgW, titleBarH).png().toBuffer(); + + const result = await sharp({ + create: { + width: imgW, + height: titleBarH + imgH, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([ + { input: titleBarBuf, left: 0, top: 0 }, + { input: imageBuffer, left: 0, top: titleBarH }, + ]) + .png() + .toBuffer(); + + return result; +} diff --git a/tests/unit/beautify-frames.test.ts b/tests/unit/beautify-frames.test.ts new file mode 100644 index 00000000..5e6307d6 --- /dev/null +++ b/tests/unit/beautify-frames.test.ts @@ -0,0 +1,97 @@ +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; +import { renderFrame } from "../../apps/api/src/lib/beautify/frames.js"; + +describe("renderFrame", () => { + const makeImage = (w: number, h: number) => + sharp({ + create: { + width: w, + height: h, + channels: 4, + background: { r: 200, g: 200, b: 200, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + + it("returns original image for frame 'none'", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "none"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height).toBe(300); + }); + + it("renders macOS light frame with title bar above image", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "macos-light", "My App"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height!).toBeGreaterThan(300); + }); + + it("renders macOS dark frame", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "macos-dark"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height!).toBeGreaterThan(300); + }); + + it("renders Windows light frame", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "windows-light"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height!).toBeGreaterThan(300); + }); + + it("renders Windows dark frame", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "windows-dark"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height!).toBeGreaterThan(300); + }); + + it("renders browser light frame with URL bar", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "browser-light", "example.com"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height!).toBeGreaterThan(300); + }); + + it("renders browser dark frame", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "browser-dark", "app.test.com"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height!).toBeGreaterThan(300); + }); + + it("passes through device frames unchanged (handled later)", async () => { + const img = await makeImage(400, 300); + const result = await renderFrame(img, "iphone"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(400); + expect(meta.height).toBe(300); + }); + + it("handles wide images", async () => { + const img = await makeImage(1920, 1080); + const result = await renderFrame(img, "macos-light", "Wide App"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(1920); + expect(meta.height!).toBeGreaterThan(1080); + }); + + it("handles narrow images", async () => { + const img = await makeImage(100, 200); + const result = await renderFrame(img, "browser-light", "narrow.app"); + const meta = await sharp(result).metadata(); + expect(meta.width).toBe(100); + expect(meta.height!).toBeGreaterThan(200); + }); +});