mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: overhaul remove-background with effects pipeline, consolidate color tools
Remove Background: - Two-phase flow: AI removes bg once, then effects adjust instantly - Blur background effect with real-time CSS preview (portrait mode) - Drop shadow effect with opacity control - Gradient backgrounds with presets, custom colors, and angle - Custom background image upload (including HEIC/HEIF) - Solid color backgrounds moved from Python to Node.js/Sharp - Effects-only API endpoint for instant re-renders without AI re-run - HEIC/HEIF input support (decoded before passing to Python/rembg) - Passport/ID photo checkbox defaults ON for People subject - Before/after slider preserved when no effects active - 15 comprehensive Playwright e2e tests Color Tools: - Consolidated 4 tools (brightness-contrast, saturation, color-channels, color-effects) into single "Adjust Colors" tool - Added exposure, temperature, tint, hue, sharpness controls - SVG filter-based live preview for all adjustments - Backward-compatible URL redirects from old tool paths Other fixes: - Favicon tool: download button instead of auto-download - Batch processing: HEIC filename extension fix - File store: processedFilename field for proper batch downloads
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* 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<Buffer> {
|
||||
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<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
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<Buffer> {
|
||||
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(
|
||||
`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
|
||||
<stop offset="0%" stop-color="${color1}"/>
|
||||
<stop offset="100%" stop-color="${color2}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#g)"/>
|
||||
</svg>`,
|
||||
);
|
||||
|
||||
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<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
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: 255 },
|
||||
},
|
||||
})
|
||||
.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<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
|
||||
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;
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
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: 255 } },
|
||||
})
|
||||
.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
|
||||
|
||||
// Step 3: Composite subject onto background
|
||||
if (background) {
|
||||
return sharp(background)
|
||||
.composite([{ input: subject, blend: "over" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
return subject;
|
||||
}
|
||||
@@ -146,15 +146,19 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
try {
|
||||
let processBuffer = file.buffer;
|
||||
let processFilename = file.filename;
|
||||
// Skip HEIC decode and auto-orient for edit-metadata (ExifTool handles all formats natively)
|
||||
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
|
||||
if (!skipPreprocess && validation.format === "heif") {
|
||||
processBuffer = await decodeHeic(processBuffer);
|
||||
// Update extension to match decoded format (HEIC/HEIF → PNG)
|
||||
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) processFilename = processFilename.slice(0, -ext.length) + ".png";
|
||||
}
|
||||
if (!skipPreprocess) {
|
||||
processBuffer = await autoOrient(processBuffer);
|
||||
}
|
||||
const result = await toolConfig.process(processBuffer, settings, file.filename);
|
||||
const result = await toolConfig.process(processBuffer, settings, processFilename);
|
||||
|
||||
results[index] = { buffer: result.buffer, filename: result.filename };
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
brightness as adjustBrightness,
|
||||
contrast as adjustContrast,
|
||||
saturation as adjustSaturation,
|
||||
sharpen as adjustSharpen,
|
||||
colorChannels,
|
||||
grayscale,
|
||||
invert,
|
||||
@@ -14,73 +15,123 @@ import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
// Light
|
||||
brightness: z.number().min(-100).max(100).default(0),
|
||||
contrast: z.number().min(-100).max(100).default(0),
|
||||
exposure: z.number().min(-100).max(100).default(0),
|
||||
// Color
|
||||
saturation: z.number().min(-100).max(100).default(0),
|
||||
temperature: z.number().min(-100).max(100).default(0),
|
||||
tint: z.number().min(-100).max(100).default(0),
|
||||
hue: z.number().min(-180).max(180).default(0),
|
||||
// Detail
|
||||
sharpness: z.number().min(0).max(100).default(0),
|
||||
// Channels
|
||||
red: z.number().min(0).max(200).default(100),
|
||||
green: z.number().min(0).max(200).default(100),
|
||||
blue: z.number().min(0).max(200).default(100),
|
||||
// Effects
|
||||
effect: z.enum(["none", "grayscale", "sepia", "invert"]).default("none"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined color adjustment route that handles brightness, contrast,
|
||||
* saturation, color channels, and color effects in a single request.
|
||||
*
|
||||
* Serves tool IDs: brightness-contrast, saturation, color-channels, color-effects
|
||||
* Build a 3x3 recomb matrix for color temperature + tint shift.
|
||||
* Temperature: cool (blue) ←→ warm (orange) on the blue-orange axis.
|
||||
* Tint: green ←→ magenta on the green-magenta axis.
|
||||
*/
|
||||
export function registerColorAdjustments(app: FastifyInstance) {
|
||||
const toolIds = ["brightness-contrast", "saturation", "color-channels", "color-effects"];
|
||||
function colorTempTintMatrix(
|
||||
temp: number,
|
||||
tintVal: number,
|
||||
): [[number, number, number], [number, number, number], [number, number, number]] {
|
||||
const t = temp / 100;
|
||||
const n = tintVal / 100;
|
||||
return [
|
||||
[1 + t * 0.15 + n * 0.1, 0, 0],
|
||||
[0, 1 + t * 0.05 - n * 0.15, 0],
|
||||
[0, 0, 1 - t * 0.15 + n * 0.1],
|
||||
];
|
||||
}
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
createToolRoute(app, {
|
||||
toolId,
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let image = sharp(inputBuffer);
|
||||
/**
|
||||
* Consolidated color adjustment route.
|
||||
*
|
||||
* Replaces the old brightness-contrast, saturation, color-channels,
|
||||
* and color-effects tools with a single "adjust-colors" endpoint.
|
||||
*/
|
||||
async function processColorAdjustments(
|
||||
inputBuffer: Buffer,
|
||||
settings: z.infer<typeof settingsSchema>,
|
||||
filename: string,
|
||||
) {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let image = sharp(inputBuffer);
|
||||
|
||||
if (settings.brightness !== 0) {
|
||||
image = await adjustBrightness(image, {
|
||||
value: settings.brightness,
|
||||
});
|
||||
}
|
||||
// Light
|
||||
if (settings.brightness !== 0) {
|
||||
image = await adjustBrightness(image, { value: settings.brightness });
|
||||
}
|
||||
if (settings.contrast !== 0) {
|
||||
image = await adjustContrast(image, { value: settings.contrast });
|
||||
}
|
||||
if (settings.exposure !== 0) {
|
||||
// Map -100..+100 to gamma 3.0..0.33 (lower gamma = brighter midtones)
|
||||
const gamma = 1 / (1 + settings.exposure / 100);
|
||||
image = image.gamma(gamma);
|
||||
}
|
||||
|
||||
if (settings.contrast !== 0) {
|
||||
image = await adjustContrast(image, { value: settings.contrast });
|
||||
}
|
||||
// Color
|
||||
if (settings.saturation !== 0 || settings.hue !== 0) {
|
||||
const modOpts: { saturation?: number; hue?: number } = {};
|
||||
if (settings.saturation !== 0) modOpts.saturation = 1 + settings.saturation / 100;
|
||||
if (settings.hue !== 0) modOpts.hue = settings.hue;
|
||||
image = image.modulate(modOpts);
|
||||
}
|
||||
if (settings.temperature !== 0 || settings.tint !== 0) {
|
||||
image = image.recomb(colorTempTintMatrix(settings.temperature, settings.tint));
|
||||
}
|
||||
|
||||
if (settings.saturation !== 0) {
|
||||
image = await adjustSaturation(image, {
|
||||
value: settings.saturation,
|
||||
});
|
||||
}
|
||||
// Detail
|
||||
if (settings.sharpness > 0) {
|
||||
image = await adjustSharpen(image, { value: settings.sharpness });
|
||||
}
|
||||
|
||||
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
|
||||
image = await colorChannels(image, {
|
||||
red: settings.red,
|
||||
green: settings.green,
|
||||
blue: settings.blue,
|
||||
});
|
||||
}
|
||||
|
||||
switch (settings.effect) {
|
||||
case "grayscale":
|
||||
image = await grayscale(image);
|
||||
break;
|
||||
case "sepia":
|
||||
image = await sepia(image);
|
||||
break;
|
||||
case "invert":
|
||||
image = await invert(image);
|
||||
break;
|
||||
}
|
||||
|
||||
const buffer = await image
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
// Channels
|
||||
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
|
||||
image = await colorChannels(image, {
|
||||
red: settings.red,
|
||||
green: settings.green,
|
||||
blue: settings.blue,
|
||||
});
|
||||
}
|
||||
|
||||
// Effects
|
||||
switch (settings.effect) {
|
||||
case "grayscale":
|
||||
image = await grayscale(image);
|
||||
break;
|
||||
case "sepia":
|
||||
image = await sepia(image);
|
||||
break;
|
||||
case "invert":
|
||||
image = await invert(image);
|
||||
break;
|
||||
}
|
||||
|
||||
const buffer = await image
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
}
|
||||
|
||||
export function registerColorAdjustments(app: FastifyInstance) {
|
||||
const allIds = [
|
||||
"adjust-colors",
|
||||
"brightness-contrast",
|
||||
"saturation",
|
||||
"color-channels",
|
||||
"color-effects",
|
||||
];
|
||||
for (const toolId of allIds) {
|
||||
createToolRoute(app, { toolId, settingsSchema, process: processColorAdjustments });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, extname } from "node:path";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
@@ -13,9 +14,14 @@ const FAVICON_SIZES = [
|
||||
{ name: "android-chrome-512x512.png", size: 512, format: "png" as const },
|
||||
];
|
||||
|
||||
interface UploadedFile {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function registerFavicon(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/favicon", async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
const uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
@@ -25,7 +31,9 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const filename = basename(part.filename ?? `image-${uploadedFiles.length + 1}`);
|
||||
uploadedFiles.push({ buffer, filename });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -35,15 +43,13 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
fileBuffer = await ensureSharpCompat(fileBuffer);
|
||||
|
||||
const jobId = randomUUID();
|
||||
const isSingleFile = uploadedFiles.length === 1;
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(200, {
|
||||
@@ -55,44 +61,50 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
archive.pipe(reply.raw);
|
||||
|
||||
// Generate each size
|
||||
for (const icon of FAVICON_SIZES) {
|
||||
const buffer = await sharp(fileBuffer)
|
||||
.resize(icon.size, icon.size, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
for (const file of uploadedFiles) {
|
||||
// Decode HEIC/HEIF if needed
|
||||
const decoded = await ensureSharpCompat(file.buffer);
|
||||
const stem = basename(file.filename, extname(file.filename));
|
||||
// Single file: flat structure. Multiple files: per-image folders.
|
||||
const prefix = isSingleFile ? "" : `${stem}/`;
|
||||
|
||||
archive.append(buffer, { name: icon.name });
|
||||
}
|
||||
// Generate each size
|
||||
for (const icon of FAVICON_SIZES) {
|
||||
const buffer = await sharp(decoded)
|
||||
.resize(icon.size, icon.size, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
archive.append(buffer, { name: `${prefix}${icon.name}` });
|
||||
}
|
||||
|
||||
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
|
||||
// Simple ICO format: just include the 32x32 PNG as an ICO
|
||||
const ico32 = await sharp(fileBuffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: "favicon.ico" });
|
||||
// Generate ICO (32x32 PNG as ICO)
|
||||
const ico32 = await sharp(decoded).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: `${prefix}favicon.ico` });
|
||||
|
||||
// Generate manifest.json (for PWA)
|
||||
const manifest = {
|
||||
name: "App",
|
||||
short_name: "App",
|
||||
icons: [
|
||||
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
theme_color: "#ffffff",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
};
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
|
||||
// Generate manifest.json (for PWA)
|
||||
const manifest = {
|
||||
name: stem,
|
||||
short_name: stem,
|
||||
icons: [
|
||||
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
theme_color: "#ffffff",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
};
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: `${prefix}manifest.json` });
|
||||
|
||||
// Generate HTML snippet
|
||||
const htmlSnippet = `<!-- Favicons -->
|
||||
// Generate HTML snippet
|
||||
const htmlSnippet = `<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
`;
|
||||
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
|
||||
archive.append(htmlSnippet, { name: `${prefix}favicon-snippet.html` });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { removeBackground } from "@stirling-image/ai";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { applyEffects } from "../../lib/bg-effects.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
backgroundType: z.enum(["transparent", "color", "gradient", "blur", "image"]).optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
gradientColor1: z.string().optional(),
|
||||
gradientColor2: z.string().optional(),
|
||||
gradientAngle: z.number().optional(),
|
||||
blurEnabled: z.boolean().optional(),
|
||||
blurIntensity: z.number().min(0).max(100).optional(),
|
||||
shadowEnabled: z.boolean().optional(),
|
||||
shadowOpacity: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* AI background removal route.
|
||||
* Uses Python + rembg under the hood.
|
||||
* AI background removal with two-phase flow:
|
||||
*
|
||||
* Phase 1 (POST /remove-background): Python/rembg removes background.
|
||||
* Returns transparent PNG + caches mask & original for effects re-apply.
|
||||
* Also returns maskUrl and originalUrl for frontend CSS preview.
|
||||
*
|
||||
* Phase 2 (POST /remove-background/effects): Node.js/Sharp applies effects.
|
||||
* Uses cached mask + original. No AI re-run. Instant response.
|
||||
* Called when user adjusts blur/shadow/background and clicks download.
|
||||
*/
|
||||
export function registerRemoveBackground(app: FastifyInstance) {
|
||||
// ── Phase 1: Background removal ──────────────────────────────────
|
||||
app.post(
|
||||
"/api/v1/tools/remove-background",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
@@ -28,9 +51,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
for await (const chunk of part.file) chunks.push(chunk);
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = basename(part.filename ?? "image");
|
||||
} else if (part.fieldname === "settings") {
|
||||
@@ -58,7 +79,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
try {
|
||||
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
|
||||
// Auto-orient to fix EXIF rotation before processing
|
||||
// Decode HEIC/HEIF before processing
|
||||
if (validation.format === "heif") {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
}
|
||||
|
||||
// Auto-orient to fix EXIF rotation
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
|
||||
request.log.info(
|
||||
@@ -72,7 +100,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
const inputPath = join(workspacePath, "input", filename);
|
||||
await writeFile(inputPath, fileBuffer);
|
||||
|
||||
// Process
|
||||
// Progress callback
|
||||
const jobIdForProgress = clientJobId;
|
||||
const onProgress = jobIdForProgress
|
||||
? (percent: number, stage: string) => {
|
||||
@@ -80,22 +108,24 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
jobId: jobIdForProgress,
|
||||
phase: "processing",
|
||||
stage,
|
||||
percent,
|
||||
percent: Math.min(percent, 95),
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const resultBuffer = await removeBackground(
|
||||
// Phase 1: AI background removal -> transparent PNG
|
||||
const transparentResult = await removeBackground(
|
||||
fileBuffer,
|
||||
join(workspacePath, "output"),
|
||||
{ model: settings.model, backgroundColor: settings.backgroundColor },
|
||||
{ model: settings.model },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// Save output
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, resultBuffer);
|
||||
// Cache the mask (transparent PNG) and original for effects re-apply
|
||||
const maskFilename = `${filename.replace(/\.[^.]+$/, "")}_mask.png`;
|
||||
const originalFilename = `${filename.replace(/\.[^.]+$/, "")}_original.png`;
|
||||
await writeFile(join(workspacePath, "output", maskFilename), transparentResult);
|
||||
await writeFile(join(workspacePath, "output", originalFilename), fileBuffer);
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
@@ -107,9 +137,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
// The mask (transparent PNG) is the main preview
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
|
||||
// Separate URLs for frontend CSS preview compositing
|
||||
maskUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
|
||||
originalUrl: `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: resultBuffer.length,
|
||||
processedSize: transparentResult.length,
|
||||
filename,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "remove-background" }, "Background removal failed");
|
||||
@@ -121,23 +156,124 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry so this tool can be used
|
||||
// as a step in automation pipelines (without progress callbacks).
|
||||
// ── Phase 2: Effects-only (no AI re-run) ─────────────────────────
|
||||
app.post(
|
||||
"/api/v1/tools/remove-background/effects",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let settingsRaw: string | null = null;
|
||||
let bgImageBuffer: Buffer | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file" && part.fieldname === "backgroundImage") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) chunks.push(chunk);
|
||||
bgImageBuffer = Buffer.concat(chunks);
|
||||
} else if (part.type === "field" && part.fieldname === "settings") {
|
||||
settingsRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!settingsRaw) {
|
||||
return reply.status(400).send({ error: "No settings provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = JSON.parse(settingsRaw);
|
||||
const { jobId, filename } = settings;
|
||||
|
||||
if (!jobId || !filename) {
|
||||
return reply.status(400).send({ error: "jobId and filename are required" });
|
||||
}
|
||||
|
||||
const workspacePath = getWorkspacePath(jobId);
|
||||
|
||||
const baseName = filename.replace(/\.[^.]+$/, "");
|
||||
const maskPath = join(workspacePath, "output", `${baseName}_mask.png`);
|
||||
const originalPath = join(workspacePath, "output", `${baseName}_original.png`);
|
||||
|
||||
const [maskBuffer, originalBuffer] = await Promise.all([
|
||||
readFile(maskPath),
|
||||
readFile(originalPath),
|
||||
]);
|
||||
|
||||
// Decode HEIC/HEIF background image if needed
|
||||
if (bgImageBuffer) {
|
||||
const bgValidation = await validateImageBuffer(bgImageBuffer);
|
||||
if (bgValidation.valid && bgValidation.format === "heif") {
|
||||
bgImageBuffer = await decodeHeic(bgImageBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply effects using cached mask + original
|
||||
const resultBuffer = await applyEffects(maskBuffer, originalBuffer, {
|
||||
backgroundType: settings.backgroundType,
|
||||
backgroundColor: settings.backgroundColor,
|
||||
gradientColor1: settings.gradientColor1,
|
||||
gradientColor2: settings.gradientColor2,
|
||||
gradientAngle: settings.gradientAngle,
|
||||
backgroundImageBuffer: bgImageBuffer ?? undefined,
|
||||
blurEnabled: settings.blurEnabled,
|
||||
blurIntensity: settings.blurIntensity,
|
||||
shadowEnabled: settings.shadowEnabled,
|
||||
shadowOpacity: settings.shadowOpacity,
|
||||
});
|
||||
|
||||
// Save the final output
|
||||
const outputFilename = `${baseName}_nobg.png`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, resultBuffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
processedSize: resultBuffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "Effects processing failed");
|
||||
return reply.status(422).send({
|
||||
error: "Effects processing failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Pipeline/batch registry ──────────────────────────────────────
|
||||
registerToolProcessFn({
|
||||
toolId: "remove-background",
|
||||
settingsSchema: z.object({
|
||||
model: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
}),
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const s = settings as { model?: string; backgroundColor?: string };
|
||||
const s = settings as z.infer<typeof settingsSchema>;
|
||||
const orientedBuffer = await autoOrient(inputBuffer);
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const resultBuffer = await removeBackground(orientedBuffer, join(workspacePath, "output"), {
|
||||
model: s.model,
|
||||
|
||||
const transparentResult = await removeBackground(
|
||||
orientedBuffer,
|
||||
join(workspacePath, "output"),
|
||||
{ model: s.model },
|
||||
);
|
||||
|
||||
const resultBuffer = await applyEffects(transparentResult, orientedBuffer, {
|
||||
backgroundType: s.backgroundType,
|
||||
backgroundColor: s.backgroundColor,
|
||||
gradientColor1: s.gradientColor1,
|
||||
gradientColor2: s.gradientColor2,
|
||||
gradientAngle: s.gradientAngle,
|
||||
blurEnabled: s.blurEnabled,
|
||||
blurIntensity: s.blurIntensity,
|
||||
shadowEnabled: s.shadowEnabled,
|
||||
shadowOpacity: s.shadowOpacity,
|
||||
});
|
||||
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
|
||||
return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" };
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user