feat(beautify): add background generation (solid, gradient, image, transparent)

This commit is contained in:
SnapOtter
2026-05-08 16:04:34 +08:00
parent 2eef1f45f9
commit b13df56273
2 changed files with 247 additions and 0 deletions
+118
View File
@@ -0,0 +1,118 @@
import sharp from "sharp";
import { parseHex } from "./constants.js";
interface SolidOpts {
type: "solid";
color: string;
width: number;
height: number;
}
interface GradientOpts {
type: "linear-gradient" | "radial-gradient";
stops: Array<{ color: string; position: number }>;
angle?: number;
width: number;
height: number;
}
interface TransparentOpts {
type: "transparent";
width: number;
height: number;
}
interface ImageOpts {
type: "image";
imageBuffer: Buffer;
width: number;
height: number;
}
export type BackgroundOpts = SolidOpts | GradientOpts | TransparentOpts | ImageOpts;
export async function generateBackground(opts: BackgroundOpts): Promise<Buffer> {
const { width, height } = opts;
if (opts.type === "solid") {
const c = parseHex(opts.color);
return sharp({
create: {
width,
height,
channels: 4,
background: { r: c.r, g: c.g, b: c.b, alpha: 1 },
},
})
.png()
.toBuffer();
}
if (opts.type === "transparent") {
return sharp({
create: {
width,
height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.png()
.toBuffer();
}
if (opts.type === "image") {
return sharp(opts.imageBuffer)
.resize(width, height, { fit: "cover" })
.ensureAlpha()
.png()
.toBuffer();
}
// Gradient (linear or radial)
const stops = opts.stops
.map((s) => `<stop offset="${s.position}%" stop-color="${s.color}"/>`)
.join("\n ");
let gradientDef: string;
if (opts.type === "linear-gradient") {
const angle = opts.angle ?? 135;
const rad = ((angle - 90) * Math.PI) / 180;
const x1 = Math.round(50 - Math.cos(rad) * 50);
const y1 = Math.round(50 - Math.sin(rad) * 50);
const x2 = Math.round(50 + Math.cos(rad) * 50);
const y2 = Math.round(50 + Math.sin(rad) * 50);
gradientDef = `<linearGradient id="g" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
${stops}
</linearGradient>`;
} else {
gradientDef = `<radialGradient id="g" cx="50%" cy="50%" r="70%">
${stops}
</radialGradient>`;
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}">
<defs>${gradientDef}</defs>
<rect width="${width}" height="${height}" fill="url(#g)"/>
</svg>`;
return sharp(Buffer.from(svg)).png().toBuffer();
}
export function getDominantBackground(
opts: Pick<BackgroundOpts, "type"> & {
color?: string;
stops?: Array<{ color: string; position: number }>;
},
): { r: number; g: number; b: number; alpha: number } {
if (opts.type === "solid" && opts.color) {
const c = parseHex(opts.color);
return { ...c, alpha: 1 };
}
if ((opts.type === "linear-gradient" || opts.type === "radial-gradient") && opts.stops?.length) {
const last = opts.stops[opts.stops.length - 1];
const c = parseHex(last.color);
return { ...c, alpha: 1 };
}
return { r: 0, g: 0, b: 0, alpha: 0 };
}
+129
View File
@@ -0,0 +1,129 @@
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import {
generateBackground,
getDominantBackground,
} from "../../apps/api/src/lib/beautify/backgrounds.js";
describe("generateBackground", () => {
it("generates a solid color background", async () => {
const buf = await generateBackground({
type: "solid",
color: "#ff0000",
width: 200,
height: 100,
});
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(100);
expect(meta.channels).toBe(4);
});
it("generates a linear gradient background", async () => {
const buf = await generateBackground({
type: "linear-gradient",
stops: [
{ color: "#667eea", position: 0 },
{ color: "#764ba2", position: 100 },
],
angle: 135,
width: 400,
height: 300,
});
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
});
it("generates a radial gradient background", async () => {
const buf = await generateBackground({
type: "radial-gradient",
stops: [
{ color: "#667eea", position: 0 },
{ color: "#764ba2", position: 100 },
],
width: 400,
height: 300,
});
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
});
it("generates a transparent background", async () => {
const buf = await generateBackground({
type: "transparent",
width: 200,
height: 100,
});
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(100);
expect(meta.hasAlpha).toBe(true);
});
it("generates an image background", async () => {
const red = await sharp({
create: {
width: 800,
height: 600,
channels: 4,
background: { r: 255, g: 0, b: 0, alpha: 1 },
},
})
.png()
.toBuffer();
const buf = await generateBackground({
type: "image",
imageBuffer: red,
width: 400,
height: 300,
});
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
});
it("generates multi-stop linear gradient", async () => {
const buf = await generateBackground({
type: "linear-gradient",
stops: [
{ color: "#0f0c29", position: 0 },
{ color: "#302b63", position: 50 },
{ color: "#24243e", position: 100 },
],
angle: 135,
width: 400,
height: 300,
});
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
});
});
describe("getDominantBackground", () => {
it("returns solid color for solid type", () => {
const result = getDominantBackground({ type: "solid", color: "#ff0000" });
expect(result).toEqual({ r: 255, g: 0, b: 0, alpha: 1 });
});
it("returns last gradient stop for gradient type", () => {
const result = getDominantBackground({
type: "linear-gradient",
stops: [
{ color: "#667eea", position: 0 },
{ color: "#764ba2", position: 100 },
],
});
expect(result.r).toBe(118);
expect(result.g).toBe(75);
expect(result.b).toBe(162);
});
it("returns transparent for transparent type", () => {
const result = getDominantBackground({ type: "transparent" });
expect(result.alpha).toBe(0);
});
});