import sharp from "sharp"; export type BgOutputFormat = "png" | "webp" | "avif"; export const BG_OUTPUT_FORMATS: BgOutputFormat[] = ["png", "webp", "avif"]; export const BG_FORMAT_CONTENT_TYPES: Record = { png: "image/png", webp: "image/webp", avif: "image/avif", }; function toOutputFormat(pipeline: sharp.Sharp, format: BgOutputFormat): Promise { switch (format) { case "webp": return pipeline.webp({ lossless: true }).toBuffer(); case "avif": return pipeline.avif({ lossless: true }).toBuffer(); default: return pipeline.png().toBuffer(); } } /** * Background removal post-processing effects. * All effects use Sharp (libvips) for fast server-side image manipulation. */ /** * Blur the original background and composite the sharp subject on top. * Produces a "portrait mode" / bokeh effect. * * @param originalBuffer - The original image before bg removal * @param subjectBuffer - The bg-removed PNG with alpha channel * @param intensity - 0-100 slider value, mapped to sigma 1-50 */ export async function blurBackground( originalBuffer: Buffer, subjectBuffer: Buffer, intensity: number, ): Promise { const sigma = 1 + (Math.max(0, Math.min(100, intensity)) / 100) * 49; // Ensure both images are the same dimensions const subjectMeta = await sharp(subjectBuffer).metadata(); const { width, height } = subjectMeta; const blurredBg = await sharp(originalBuffer) .resize(width, height, { fit: "fill" }) .blur(sigma) .toBuffer(); return sharp(blurredBg) .composite([{ input: subjectBuffer, blend: "over" }]) .png() .toBuffer(); } /** * Add a drop shadow generated from the subject's alpha mask. * Shadow is offset downward and blurred for a natural look. * * @param subjectBuffer - PNG with alpha channel * @param opacity - 0-100 slider value */ export async function addDropShadow(subjectBuffer: Buffer, opacity: number): Promise { const meta = await sharp(subjectBuffer).metadata(); if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions"); const { width, height } = meta; const normalizedOpacity = Math.max(0, Math.min(100, opacity)) / 100; // Shadow parameters const offsetY = Math.max(4, Math.round(height * 0.015)); const blurSigma = Math.max(5, Math.round(height * 0.02)); // Extract alpha channel const alphaRaw = await sharp(subjectBuffer).extractChannel(3).raw().toBuffer(); // Build shadow RGBA: black pixels with scaled alpha const shadowPixels = Buffer.alloc(width * height * 4); for (let i = 0; i < width * height; i++) { shadowPixels[i * 4] = 0; shadowPixels[i * 4 + 1] = 0; shadowPixels[i * 4 + 2] = 0; shadowPixels[i * 4 + 3] = Math.round(alphaRaw[i] * normalizedOpacity); } // Blur the shadow const shadowBlurred = await sharp(shadowPixels, { raw: { width, height, channels: 4 }, }) .blur(blurSigma) .png() .toBuffer(); // Composite: transparent canvas -> shadow (offset) -> subject (centered) // Keep same canvas size, shadow clips at edges return sharp({ create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } }, }) .composite([ { input: shadowBlurred, left: 0, top: offsetY, blend: "over" }, { input: subjectBuffer, left: 0, top: 0, blend: "over" }, ]) .png() .toBuffer(); } /** * Create a linear gradient background image as SVG, rendered via Sharp. */ export async function createGradientBackground( width: number, height: number, color1: string, color2: string, angle = 180, ): Promise { const rad = (angle * Math.PI) / 180; const x1 = 50 - Math.sin(rad) * 50; const y1 = 50 - Math.cos(rad) * 50; const x2 = 50 + Math.sin(rad) * 50; const y2 = 50 + Math.cos(rad) * 50; const svg = Buffer.from( ` `, ); return sharp(svg).resize(width, height).png().toBuffer(); } /** * Composite a subject (PNG with alpha) onto a solid color background. */ export async function compositeOnColor(subjectBuffer: Buffer, hexColor: string): Promise { const meta = await sharp(subjectBuffer).metadata(); if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions"); const hex = hexColor.replace("#", ""); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); return sharp({ create: { width: meta.width, height: meta.height, channels: 4, background: { r, g, b, alpha: 1 }, }, }) .composite([{ input: subjectBuffer, blend: "over" }]) .png() .toBuffer(); } /** * Composite a subject onto a background image. * The background image is resized to cover the subject dimensions. */ export async function compositeOnImage( subjectBuffer: Buffer, backgroundBuffer: Buffer, ): Promise { const meta = await sharp(subjectBuffer).metadata(); if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions"); const { width, height } = meta; const resizedBg = await sharp(backgroundBuffer) .resize(width, height, { fit: "cover" }) .toBuffer(); return sharp(resizedBg) .composite([{ input: subjectBuffer, blend: "over" }]) .png() .toBuffer(); } /** * Apply the full effects pipeline to a bg-removed subject. * * Order: shadow -> blur/background compositing * Shadow is applied to the transparent subject first, then composited onto background. */ export async function applyEffects( subjectBuffer: Buffer, originalBuffer: Buffer, settings: { backgroundColor?: string; backgroundType?: string; gradientColor1?: string; gradientColor2?: string; gradientAngle?: number; backgroundImageBuffer?: Buffer; blurEnabled?: boolean; blurIntensity?: number; shadowEnabled?: boolean; shadowOpacity?: number; outputFormat?: BgOutputFormat; }, ): Promise { const meta = await sharp(subjectBuffer).metadata(); if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions"); const { width, height } = meta; const bgType = settings.backgroundType || "transparent"; // Step 1: Add shadow to the subject (before background compositing) let subject = subjectBuffer; if (settings.shadowEnabled && settings.shadowOpacity && settings.shadowOpacity > 0) { subject = await addDropShadow(subject, settings.shadowOpacity); } // Step 2: Build the background layer let background: Buffer | null = null; if (bgType === "image" && settings.backgroundImageBuffer) { // Custom uploaded background image background = await sharp(settings.backgroundImageBuffer) .resize(width, height, { fit: "cover" }) .toBuffer(); // Apply blur to the uploaded bg image if enabled if (settings.blurEnabled) { const intensity = settings.blurIntensity ?? 50; const sigma = 1 + (Math.max(0, Math.min(100, intensity)) / 100) * 49; background = await sharp(background).blur(sigma).toBuffer(); } } else if (settings.blurEnabled && (bgType === "transparent" || bgType === "blur")) { // Blur the original background (portrait mode) const intensity = settings.blurIntensity ?? 50; const sigma = 1 + (Math.max(0, Math.min(100, intensity)) / 100) * 49; background = await sharp(originalBuffer) .resize(width, height, { fit: "fill" }) .blur(sigma) .toBuffer(); } else if (bgType === "color" && settings.backgroundColor) { const hex = settings.backgroundColor.replace("#", ""); const r = parseInt(hex.substring(0, 2), 16); const g = parseInt(hex.substring(2, 4), 16); const b = parseInt(hex.substring(4, 6), 16); background = await sharp({ create: { width, height, channels: 4, background: { r, g, b, alpha: 1 } }, }) .png() .toBuffer(); } else if (bgType === "gradient" && settings.gradientColor1 && settings.gradientColor2) { background = await createGradientBackground( width, height, settings.gradientColor1, settings.gradientColor2, settings.gradientAngle ?? 180, ); } // else: transparent - no background layer const fmt = settings.outputFormat ?? "png"; // Step 3: Composite subject onto background if (background) { return toOutputFormat(sharp(background).composite([{ input: subject, blend: "over" }]), fmt); } return toOutputFormat(sharp(subject), fmt); }