From 39358524cdddb0824b622c3e617369ea2161f69d Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Fri, 8 May 2026 16:06:37 +0800 Subject: [PATCH] feat(beautify): add shadow rendering (extracted from border.ts pattern) --- apps/api/src/lib/beautify/shadow.ts | 82 +++++++++++++++++++++++++++ tests/unit/beautify-shadow.test.ts | 87 +++++++++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 apps/api/src/lib/beautify/shadow.ts create mode 100644 tests/unit/beautify-shadow.test.ts diff --git a/apps/api/src/lib/beautify/shadow.ts b/apps/api/src/lib/beautify/shadow.ts new file mode 100644 index 00000000..0576cb07 --- /dev/null +++ b/apps/api/src/lib/beautify/shadow.ts @@ -0,0 +1,82 @@ +import sharp from "sharp"; +import { parseHex } from "./constants.js"; + +interface ShadowOpts { + blur: number; + offsetX: number; + offsetY: number; + color: string; + opacity: number; +} + +interface ShadowResult { + buffer: Buffer; + imgX: number; + imgY: number; + padLeft: number; + padTop: number; +} + +export async function applyShadow(imageBuffer: Buffer, opts: ShadowOpts): Promise { + const buf = await sharp(imageBuffer).ensureAlpha().png().toBuffer(); + const meta = await sharp(buf).metadata(); + const bW = meta.width ?? 100; + const bH = meta.height ?? 100; + + const sc = parseHex(opts.color); + const alpha = opts.opacity / 100; + const blur = opts.blur; + const spread = Math.ceil(blur * 2); + const ox = opts.offsetX; + const oy = opts.offsetY; + + const shadowSilhouette = await sharp({ + create: { + width: bW, + height: bH, + channels: 4, + background: { r: sc.r, g: sc.g, b: sc.b, alpha }, + }, + }) + .composite([{ input: buf, blend: "dest-in" }]) + .extend({ + top: spread, + bottom: spread, + left: spread, + right: spread, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }) + .blur(Math.max(blur, 0.3)) + .png() + .toBuffer(); + + const padL = Math.max(0, spread - ox); + const padR = Math.max(0, spread + ox); + const padT = Math.max(0, spread - oy); + const padB = Math.max(0, spread + oy); + + const canvasW = bW + padL + padR; + const canvasH = bH + padT + padB; + + const imgX = padL; + const imgY = padT; + const shadX = Math.max(0, imgX + ox - spread); + const shadY = Math.max(0, imgY + oy - spread); + + const result = await sharp({ + create: { + width: canvasW, + height: canvasH, + channels: 4, + background: { r: 0, g: 0, b: 0, alpha: 0 }, + }, + }) + .composite([ + { input: shadowSilhouette, left: shadX, top: shadY }, + { input: buf, left: imgX, top: imgY }, + ]) + .png() + .toBuffer(); + + return { buffer: result, imgX, imgY, padLeft: padL, padTop: padT }; +} diff --git a/tests/unit/beautify-shadow.test.ts b/tests/unit/beautify-shadow.test.ts new file mode 100644 index 00000000..68077096 --- /dev/null +++ b/tests/unit/beautify-shadow.test.ts @@ -0,0 +1,87 @@ +import sharp from "sharp"; +import { describe, expect, it } from "vitest"; +import { applyShadow } from "../../apps/api/src/lib/beautify/shadow.js"; + +describe("applyShadow", () => { + const makeImage = (w: number, h: number) => + sharp({ + create: { + width: w, + height: h, + channels: 4, + background: { r: 100, g: 100, b: 255, alpha: 1 }, + }, + }) + .png() + .toBuffer(); + + it("returns larger buffer with shadow applied", async () => { + const img = await makeImage(200, 150); + const result = await applyShadow(img, { + blur: 20, + offsetX: 0, + offsetY: 10, + color: "#000000", + opacity: 30, + }); + const meta = await sharp(result.buffer).metadata(); + expect(meta.width).toBeGreaterThan(200); + expect(meta.height).toBeGreaterThan(150); + expect(result.padLeft).toBeGreaterThanOrEqual(0); + expect(result.padTop).toBeGreaterThanOrEqual(0); + }); + + it("returns image position within shadow canvas", async () => { + const img = await makeImage(200, 150); + const result = await applyShadow(img, { + blur: 20, + offsetX: 5, + offsetY: 10, + color: "#000000", + opacity: 50, + }); + expect(result.imgX).toBeDefined(); + expect(result.imgY).toBeDefined(); + }); + + it("handles zero-blur shadow gracefully", async () => { + const img = await makeImage(100, 100); + const result = await applyShadow(img, { + blur: 0, + offsetX: 0, + offsetY: 0, + color: "#000000", + opacity: 0, + }); + const meta = await sharp(result.buffer).metadata(); + expect(meta.width).toBeGreaterThanOrEqual(100); + }); + + it("handles negative offsets", async () => { + const img = await makeImage(200, 150); + const result = await applyShadow(img, { + blur: 15, + offsetX: -10, + offsetY: -5, + color: "#ff0000", + opacity: 40, + }); + const meta = await sharp(result.buffer).metadata(); + expect(meta.width).toBeGreaterThan(200); + expect(meta.height).toBeGreaterThan(150); + }); + + it("produces RGBA output with alpha channel", async () => { + const img = await makeImage(100, 100); + const result = await applyShadow(img, { + blur: 10, + offsetX: 0, + offsetY: 5, + color: "#000000", + opacity: 30, + }); + const meta = await sharp(result.buffer).metadata(); + expect(meta.hasAlpha).toBe(true); + expect(meta.channels).toBe(4); + }); +});