diff --git a/apps/api/scripts/generate-frames.ts b/apps/api/scripts/generate-frames.ts new file mode 100644 index 00000000..61c9954b --- /dev/null +++ b/apps/api/scripts/generate-frames.ts @@ -0,0 +1,115 @@ +/** + * Generates device frame PNG assets + meta.json files from SVG templates. + * + * Run: cd apps/api && npx tsx scripts/generate-frames.ts + */ +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FRAMES_DIR = join(__dirname, "../src/assets/frames"); + +interface FrameDef { + id: string; + svg: string; + meta: { screenX: number; screenY: number; screenW: number; screenH: number }; +} + +// --------------------------------------------------------------------------- +// iPhone frames +// --------------------------------------------------------------------------- +function iphoneSvg(variant: "light" | "dark"): string { + const body = variant === "light" ? "#c0c0c0" : "#2a2a2a"; + const border = variant === "light" ? "#333333" : "#111111"; + return ` + + + + + + + + +`; +} + +const IPHONE_META = { screenX: 20, screenY: 80, screenW: 390, screenH: 720 }; + +// --------------------------------------------------------------------------- +// MacBook frames +// --------------------------------------------------------------------------- +function macbookSvg(variant: "light" | "dark"): string { + const bezel = variant === "light" ? "#d4d4d8" : "#3f3f46"; + const base = variant === "light" ? "#e4e4e7" : "#27272a"; + const cameraDot = variant === "light" ? "#a1a1aa" : "#52525b"; + return ` + + + + + + + + + + +`; +} + +const MACBOOK_META = { screenX: 100, screenY: 30, screenW: 1000, screenH: 625 }; + +// --------------------------------------------------------------------------- +// iPad frames +// --------------------------------------------------------------------------- +function ipadSvg(variant: "light" | "dark"): string { + const body = variant === "light" ? "#d4d4d8" : "#3f3f46"; + const cameraDot = variant === "light" ? "#a1a1aa" : "#52525b"; + return ` + + + + + + +`; +} + +const IPAD_META = { screenX: 25, screenY: 25, screenW: 490, screenH: 670 }; + +// --------------------------------------------------------------------------- +// Generate all frames +// --------------------------------------------------------------------------- +const frames: FrameDef[] = [ + { id: "iphone", svg: iphoneSvg("light"), meta: IPHONE_META }, + { id: "iphone-dark", svg: iphoneSvg("dark"), meta: IPHONE_META }, + { id: "macbook", svg: macbookSvg("light"), meta: MACBOOK_META }, + { id: "macbook-dark", svg: macbookSvg("dark"), meta: MACBOOK_META }, + { id: "ipad", svg: ipadSvg("light"), meta: IPAD_META }, + { id: "ipad-dark", svg: ipadSvg("dark"), meta: IPAD_META }, +]; + +async function main() { + if (!existsSync(FRAMES_DIR)) { + mkdirSync(FRAMES_DIR, { recursive: true }); + } + + for (const frame of frames) { + const pngPath = join(FRAMES_DIR, `${frame.id}.png`); + const metaPath = join(FRAMES_DIR, `${frame.id}.meta.json`); + + const pngBuf = await sharp(Buffer.from(frame.svg)).png().toBuffer(); + writeFileSync(pngPath, pngBuf); + writeFileSync(metaPath, JSON.stringify(frame.meta, null, 2) + "\n"); + + console.log(`Generated ${frame.id}.png + ${frame.id}.meta.json`); + } + + console.log(`\nAll frames written to ${FRAMES_DIR}`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/apps/api/src/assets/frames/ipad-dark.meta.json b/apps/api/src/assets/frames/ipad-dark.meta.json new file mode 100644 index 00000000..3b147f1b --- /dev/null +++ b/apps/api/src/assets/frames/ipad-dark.meta.json @@ -0,0 +1,6 @@ +{ + "screenX": 25, + "screenY": 25, + "screenW": 490, + "screenH": 670 +} diff --git a/apps/api/src/assets/frames/ipad-dark.png b/apps/api/src/assets/frames/ipad-dark.png new file mode 100644 index 00000000..1f2ec888 Binary files /dev/null and b/apps/api/src/assets/frames/ipad-dark.png differ diff --git a/apps/api/src/assets/frames/ipad.meta.json b/apps/api/src/assets/frames/ipad.meta.json new file mode 100644 index 00000000..3b147f1b --- /dev/null +++ b/apps/api/src/assets/frames/ipad.meta.json @@ -0,0 +1,6 @@ +{ + "screenX": 25, + "screenY": 25, + "screenW": 490, + "screenH": 670 +} diff --git a/apps/api/src/assets/frames/ipad.png b/apps/api/src/assets/frames/ipad.png new file mode 100644 index 00000000..68fad46b Binary files /dev/null and b/apps/api/src/assets/frames/ipad.png differ diff --git a/apps/api/src/assets/frames/iphone-dark.meta.json b/apps/api/src/assets/frames/iphone-dark.meta.json new file mode 100644 index 00000000..7bf4070d --- /dev/null +++ b/apps/api/src/assets/frames/iphone-dark.meta.json @@ -0,0 +1,6 @@ +{ + "screenX": 20, + "screenY": 80, + "screenW": 390, + "screenH": 720 +} diff --git a/apps/api/src/assets/frames/iphone-dark.png b/apps/api/src/assets/frames/iphone-dark.png new file mode 100644 index 00000000..902fc65f Binary files /dev/null and b/apps/api/src/assets/frames/iphone-dark.png differ diff --git a/apps/api/src/assets/frames/iphone.meta.json b/apps/api/src/assets/frames/iphone.meta.json new file mode 100644 index 00000000..7bf4070d --- /dev/null +++ b/apps/api/src/assets/frames/iphone.meta.json @@ -0,0 +1,6 @@ +{ + "screenX": 20, + "screenY": 80, + "screenW": 390, + "screenH": 720 +} diff --git a/apps/api/src/assets/frames/iphone.png b/apps/api/src/assets/frames/iphone.png new file mode 100644 index 00000000..49f79124 Binary files /dev/null and b/apps/api/src/assets/frames/iphone.png differ diff --git a/apps/api/src/assets/frames/macbook-dark.meta.json b/apps/api/src/assets/frames/macbook-dark.meta.json new file mode 100644 index 00000000..caf689ed --- /dev/null +++ b/apps/api/src/assets/frames/macbook-dark.meta.json @@ -0,0 +1,6 @@ +{ + "screenX": 100, + "screenY": 30, + "screenW": 1000, + "screenH": 625 +} diff --git a/apps/api/src/assets/frames/macbook-dark.png b/apps/api/src/assets/frames/macbook-dark.png new file mode 100644 index 00000000..739e3eaa Binary files /dev/null and b/apps/api/src/assets/frames/macbook-dark.png differ diff --git a/apps/api/src/assets/frames/macbook.meta.json b/apps/api/src/assets/frames/macbook.meta.json new file mode 100644 index 00000000..caf689ed --- /dev/null +++ b/apps/api/src/assets/frames/macbook.meta.json @@ -0,0 +1,6 @@ +{ + "screenX": 100, + "screenY": 30, + "screenW": 1000, + "screenH": 625 +} diff --git a/apps/api/src/assets/frames/macbook.png b/apps/api/src/assets/frames/macbook.png new file mode 100644 index 00000000..22e61211 Binary files /dev/null and b/apps/api/src/assets/frames/macbook.png differ diff --git a/apps/api/src/lib/beautify/frames.ts b/apps/api/src/lib/beautify/frames.ts index e4900f2c..db915060 100644 --- a/apps/api/src/lib/beautify/frames.ts +++ b/apps/api/src/lib/beautify/frames.ts @@ -1,6 +1,62 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; import sharp from "sharp"; import { DEVICE_FRAMES, SVG_FRAMES } from "./constants.js"; +const __dirname = dirname(fileURLToPath(import.meta.url)); +const FRAMES_DIR = join(__dirname, "../../assets/frames"); + +interface DeviceFrameData { + png: Buffer; + meta: { screenX: number; screenY: number; screenW: number; screenH: number }; +} + +const frameCache = new Map(); + +function loadDeviceFrame(frameId: string): DeviceFrameData { + const cached = frameCache.get(frameId); + if (cached) return cached; + + const pngBuf = readFileSync(join(FRAMES_DIR, `${frameId}.png`)); + const meta = JSON.parse(readFileSync(join(FRAMES_DIR, `${frameId}.meta.json`), "utf-8")); + + const data: DeviceFrameData = { png: pngBuf, meta }; + frameCache.set(frameId, data); + return data; +} + +async function renderDeviceFrame(imageBuffer: Buffer, frameId: string): Promise { + const { png: framePng, meta } = loadDeviceFrame(frameId); + const frameMeta = await sharp(framePng).metadata(); + const frameW = frameMeta.width ?? 0; + const frameH = frameMeta.height ?? 0; + + // Resize the screenshot to fit the screen rect + const resized = await sharp(imageBuffer) + .resize(meta.screenW, meta.screenH, { fit: "fill" }) + .png() + .toBuffer(); + + // Composite: screenshot behind frame so the frame bezel overlays it + const result = await sharp({ + create: { + width: frameW, + height: frameH, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([ + { input: resized, left: meta.screenX, top: meta.screenY }, + { input: framePng, left: 0, top: 0 }, + ]) + .png() + .toBuffer(); + + return result; +} + const WINDOW_TITLE_BAR_HEIGHT = 36; const BROWSER_TITLE_BAR_HEIGHT = 72; @@ -162,7 +218,7 @@ export async function renderFrame( } if (DEVICE_FRAMES.has(frame)) { - return imageBuffer; + return renderDeviceFrame(imageBuffer, frame); } if (!SVG_FRAMES.has(frame)) { diff --git a/tests/unit/beautify-frames.test.ts b/tests/unit/beautify-frames.test.ts index 5e6307d6..0a37caba 100644 --- a/tests/unit/beautify-frames.test.ts +++ b/tests/unit/beautify-frames.test.ts @@ -71,12 +71,33 @@ describe("renderFrame", () => { expect(meta.height!).toBeGreaterThan(300); }); - it("passes through device frames unchanged (handled later)", async () => { - const img = await makeImage(400, 300); + it("renders iPhone frame with image composited into screen area", async () => { + const img = await makeImage(400, 800); const result = await renderFrame(img, "iphone"); const meta = await sharp(result).metadata(); - expect(meta.width).toBe(400); - expect(meta.height).toBe(300); + expect(meta.width!).toBeGreaterThan(400); + expect(meta.height!).toBeGreaterThan(800); + }); + + it("renders MacBook frame", async () => { + const img = await makeImage(800, 500); + const result = await renderFrame(img, "macbook"); + const meta = await sharp(result).metadata(); + expect(meta.width!).toBeGreaterThan(800); + }); + + it("renders iPad frame", async () => { + const img = await makeImage(400, 600); + const result = await renderFrame(img, "ipad"); + const meta = await sharp(result).metadata(); + expect(meta.width!).toBeGreaterThan(400); + }); + + it("renders iPhone dark frame", async () => { + const img = await makeImage(400, 800); + const result = await renderFrame(img, "iphone-dark"); + const meta = await sharp(result).metadata(); + expect(meta.width!).toBeGreaterThan(400); }); it("handles wide images", async () => {