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 () => {