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:
Siddharth Kumar Sah
2026-04-12 17:53:16 +08:00
parent dde70f70ad
commit 6c58f12262
28 changed files with 2236 additions and 525 deletions
+249
View File
@@ -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;
}
+5 -1
View File
@@ -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 };
+102 -51
View File
@@ -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 });
}
}
+46 -34
View File
@@ -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) {
+163 -27
View File
@@ -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" };
},