diff --git a/apps/api/src/lib/bg-effects.ts b/apps/api/src/lib/bg-effects.ts new file mode 100644 index 00000000..42ceca3f --- /dev/null +++ b/apps/api/src/lib/bg-effects.ts @@ -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 { + 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(); + 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 { + 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(); + 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 { + 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 { + 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; +} diff --git a/apps/api/src/routes/batch.ts b/apps/api/src/routes/batch.ts index d71ffc0d..ab8ae24f 100644 --- a/apps/api/src/routes/batch.ts +++ b/apps/api/src/routes/batch.ts @@ -146,15 +146,19 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise { 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 }; diff --git a/apps/api/src/routes/tools/color-adjustments.ts b/apps/api/src/routes/tools/color-adjustments.ts index 9611c8f7..edcc8724 100644 --- a/apps/api/src/routes/tools/color-adjustments.ts +++ b/apps/api/src/routes/tools/color-adjustments.ts @@ -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, + 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 }); + } } diff --git a/apps/api/src/routes/tools/favicon.ts b/apps/api/src/routes/tools/favicon.ts index 1721fd09..ffe872ae 100644 --- a/apps/api/src/routes/tools/favicon.ts +++ b/apps/api/src/routes/tools/favicon.ts @@ -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 = ` + // Generate HTML snippet + const htmlSnippet = ` `; - archive.append(htmlSnippet, { name: "favicon-snippet.html" }); + archive.append(htmlSnippet, { name: `${prefix}favicon-snippet.html` }); + } await archive.finalize(); } catch (err) { diff --git a/apps/api/src/routes/tools/remove-background.ts b/apps/api/src/routes/tools/remove-background.ts index 2afa6271..709311d9 100644 --- a/apps/api/src/routes/tools/remove-background.ts +++ b/apps/api/src/routes/tools/remove-background.ts @@ -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; 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" }; }, diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0bf0b862..aa0f3fae 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -106,6 +106,14 @@ export function App() { } /> } /> } /> + {/* Redirects: old color tools consolidated into adjust-colors */} + } + /> + } /> + } /> + } /> } /> } /> diff --git a/apps/web/src/components/common/image-viewer.tsx b/apps/web/src/components/common/image-viewer.tsx index 6ad0c741..a0b48a93 100644 --- a/apps/web/src/components/common/image-viewer.tsx +++ b/apps/web/src/components/common/image-viewer.tsx @@ -2,6 +2,19 @@ import { Maximize, Minimize2, ZoomIn, ZoomOut } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { formatFileSize } from "@/lib/download"; +export interface BgPreviewState { + /** URL of the original image (for blur background) */ + backgroundSrc?: string; + /** CSS blur filter value for the background, e.g. "blur(15px)" */ + backgroundBlur?: string; + /** CSS background for the container (color, gradient), e.g. "#FFFFFF" or "linear-gradient(...)" */ + containerBackground?: string; + /** CSS drop-shadow filter for the subject */ + dropShadow?: string; + /** Whether to show checkered (transparent) background */ + showCheckerboard?: boolean; +} + interface ImageViewerProps { src: string; filename: string; @@ -10,6 +23,7 @@ interface ImageViewerProps { cssFlipH?: boolean; cssFlipV?: boolean; cssFilter?: string; + bgPreview?: BgPreviewState; } const ZOOM_STEPS = [25, 50, 75, 100, 125, 150, 200, 300]; @@ -23,6 +37,7 @@ export function ImageViewer({ cssFlipH, cssFlipV, cssFilter, + bgPreview, }: ImageViewerProps) { const [zoom, setZoom] = useState(DEFAULT_ZOOM); const [naturalWidth, setNaturalWidth] = useState(null); @@ -155,7 +170,13 @@ export function ImageViewer({ {/* Image area */}
{loadError ? (
@@ -164,6 +185,71 @@ export function ImageViewer({ This format cannot be displayed in the browser

+ ) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? ( + /* Layered bg-removal preview: background layer + subject layer */ +
+ {/* Background layer: blurred original or solid/gradient */} + {bgPreview.backgroundSrc ? ( + background + ) : ( + /* Solid color or gradient - use subject dimensions */ + background-sizer + )} + + {/* Container background (color or gradient) behind subject but on top of bg image */} + {bgPreview.containerBackground && !bgPreview.backgroundSrc && ( +
+ )} + + {/* Subject layer: transparent PNG with optional drop shadow */} + {filename} +
) : ( (() => { - if (toolId === "color-channels") return "channels"; - if (toolId === "color-effects") return "effects"; - return "basic"; - }); - - // Basic adjustments + // Light const [brightness, setBrightness] = useState(0); const [contrast, setContrast] = useState(0); - const [saturation, setSaturation] = useState(0); + const [exposure, setExposure] = useState(0); - // Color channels + // Color + const [saturation, setSaturation] = useState(0); + const [temperature, setTemperature] = useState(0); + const [tint, setTint] = useState(0); + const [hue, setHue] = useState(0); + + // Detail + const [sharpness, setSharpness] = useState(0); + + // Channels const [red, setRed] = useState(100); const [green, setGreen] = useState(100); const [blue, setBlue] = useState(100); + const [channelsOpen, setChannelsOpen] = useState(false); // Effects const [effect, setEffect] = useState("none"); @@ -36,20 +39,51 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro const onChangeRef = useRef(onChange); onChangeRef.current = onChange; - // Report settings on change useEffect(() => { - onChangeRef.current?.({ brightness, contrast, saturation, red, green, blue, effect }); - }, [brightness, contrast, saturation, red, green, blue, effect]); + onChangeRef.current?.({ + brightness, + contrast, + exposure, + saturation, + temperature, + tint, + hue, + sharpness, + red, + green, + blue, + effect, + }); + }, [ + brightness, + contrast, + exposure, + saturation, + temperature, + tint, + hue, + sharpness, + red, + green, + blue, + effect, + ]); - // Emit CSS filter for live preview + // CSS filter preview const hasChannelChanges = red !== 100 || green !== 100 || blue !== 100; + const hasTempTint = temperature !== 0 || tint !== 0; + useEffect(() => { if (!onPreviewFilter) return; const parts: string[] = []; if (brightness !== 0) parts.push(`brightness(${1 + brightness / 100})`); if (contrast !== 0) parts.push(`contrast(${1 + contrast / 100})`); + if (exposure !== 0) parts.push(`brightness(${1 + exposure / 200})`); if (saturation !== 0) parts.push(`saturate(${1 + saturation / 100})`); + if (hue !== 0) parts.push(`hue-rotate(${hue}deg)`); + if (hasTempTint) parts.push("url(#stirling-temp-tint-filter)"); if (hasChannelChanges) parts.push("url(#stirling-channel-filter)"); + if (sharpness > 0) parts.push("url(#stirling-sharpen-filter)"); if (effect === "grayscale") parts.push("grayscale(1)"); if (effect === "sepia") parts.push("sepia(1)"); if (effect === "invert") parts.push("invert(1)"); @@ -57,33 +91,49 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro }, [ brightness, contrast, + exposure, saturation, - red, - green, - blue, - effect, + temperature, + tint, + hue, + sharpness, hasChannelChanges, + hasTempTint, + effect, onPreviewFilter, ]); const hasChanges = brightness !== 0 || contrast !== 0 || + exposure !== 0 || saturation !== 0 || + temperature !== 0 || + tint !== 0 || + hue !== 0 || + sharpness !== 0 || red !== 100 || green !== 100 || blue !== 100 || effect !== "none"; - const tabs: { id: Tab; label: string }[] = [ - { id: "basic", label: "Basic" }, - { id: "channels", label: "Channels" }, - { id: "effects", label: "Effects" }, - ]; + // Build SVG filter matrices + const tempT = temperature / 100; + const tintN = tint / 100; return ( <> - {/* Hidden SVG filter for color channel preview */} + {/* Hidden SVG filters for live preview */} + {hasTempTint && ( + + + + + + )} {hasChannelChanges && ( @@ -94,52 +144,116 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro )} - {/* Tabs */} -
- {tabs.map((t) => ( + {sharpness > 0 && ( + + + + + + )} + + {/* Light section */} + Light +
+ + + +
+ + {/* Color section */} + Color +
+ + + + +
+ + {/* Detail section */} + Detail +
+ +
+ + {/* Effects section */} + Effects +
+ {(["none", "grayscale", "sepia", "invert"] as const).map((e) => ( ))}
- {/* Basic Adjustments */} - {tab === "basic" && ( -
- - - -
- )} - - {/* Color Channels */} - {tab === "channels" && ( -
+ {/* Color Channels (expandable) */} + + {channelsOpen && ( +
)} - {/* Effects */} - {tab === "effects" && ( -
-

Color Effect

-
- {(["none", "grayscale", "sepia", "invert"] as const).map((e) => ( - - ))} -
-
- )} - - {/* Reset button */} + {/* Reset */} {hasChanges && ( )} - {/* Download */} - {downloadUrl && ( + {/* Download (single-file only - batch uses Download All ZIP in tool-page) */} + {downloadUrl && files.length <= 1 && ( @@ -316,7 +399,16 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) { ); } -/** Reusable slider control */ +// ── Shared sub-components ───────────────────────────────────────── + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + function SliderControl({ label, value, @@ -324,6 +416,7 @@ function SliderControl({ min, max, color, + hint, }: { label: string; value: number; @@ -331,6 +424,7 @@ function SliderControl({ min: number; max: number; color?: string; + hint?: string; }) { const id = `color-slider-${label.toLowerCase()}`; return ( @@ -338,8 +432,11 @@ function SliderControl({
- {value} + + {value} +
(null); + const [busy, setBusy] = useState(false); + const [progress, setProgress] = useState({ + phase: "idle" as "idle" | "uploading" | "processing" | "complete", + percent: 0, + elapsed: 0, + }); + const elapsedRef = useRef | null>(null); + const processingTimerRef = useRef | null>(null); + const xhrRef = useRef(null); - const handleProcess = async () => { - if (files.length === 0) return; + useEffect(() => { + return () => { + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (processingTimerRef.current) clearInterval(processingTimerRef.current); + if (xhrRef.current) xhrRef.current.abort(); + }; + }, []); - setProcessing(true); - setError(null); - setDownloadReady(false); - - try { - const formData = new FormData(); - formData.append("file", files[0]); - - const res = await fetch("/api/v1/tools/favicon", { - method: "POST", - headers: formatHeaders(), - body: formData, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(text || `Failed: ${res.status}`); - } - - const blob = await res.blob(); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "favicons.zip"; - a.click(); - URL.revokeObjectURL(url); - setDownloadReady(true); - } catch (err) { - setError(err instanceof Error ? err.message : "Generation failed"); - } finally { - setProcessing(false); - } + const cleanup = () => { + if (elapsedRef.current) clearInterval(elapsedRef.current); + if (processingTimerRef.current) clearInterval(processingTimerRef.current); + elapsedRef.current = null; + processingTimerRef.current = null; + setBusy(false); + setProcessing(false); }; - const hasFile = files.length > 0; + const handleProcess = useCallback(() => { + if (files.length === 0) return; + + flushSync(() => { + setBusy(true); + setProcessing(true); + setError(null); + if (downloadUrl) { + URL.revokeObjectURL(downloadUrl); + setDownloadUrl(null); + } + setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); + }); + + const startTime = Date.now(); + elapsedRef.current = setInterval(() => { + setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) })); + }, 1000); + + const formData = new FormData(); + for (const file of files) { + formData.append("file", file); + } + + const xhr = new XMLHttpRequest(); + xhrRef.current = xhr; + xhr.responseType = "blob"; + xhr.timeout = 180_000; + + xhr.upload.onprogress = (event) => { + if (event.lengthComputable) { + const uploadPercent = (event.loaded / event.total) * 40; + setProgress((prev) => + prev.phase === "uploading" ? { ...prev, percent: uploadPercent } : prev, + ); + } + }; + + xhr.upload.onload = () => { + setProgress((prev) => ({ ...prev, phase: "processing", percent: 40 })); + const step = (95 - 40) / 90; + processingTimerRef.current = setInterval(() => { + setProgress((prev) => { + if (prev.phase !== "processing") return prev; + return { ...prev, percent: Math.min(95, prev.percent + step) }; + }); + }, 500); + }; + + xhr.onload = () => { + if (xhr.status >= 200 && xhr.status < 300) { + const blob = xhr.response as Blob; + setDownloadUrl(URL.createObjectURL(blob)); + setProgress((prev) => ({ ...prev, phase: "complete", percent: 100 })); + } else { + setError(`Favicon generation failed: ${xhr.status}`); + } + cleanup(); + }; + + xhr.onerror = () => { + setError("Network error during favicon generation"); + cleanup(); + }; + + xhr.ontimeout = () => { + setError("Request timed out - the server may be overloaded"); + cleanup(); + }; + + xhr.open("POST", "/api/v1/tools/favicon"); + formatHeaders().forEach((value, key) => { + xhr.setRequestHeader(key, value); + }); + xhr.send(formData); + }, [files, setProcessing, setError, downloadUrl]); + + const hasFiles = files.length > 0; return (

- Upload a square image (recommended 512x512 or larger) to generate all favicon and app icon - sizes. + Upload square images (recommended 512x512 or larger) to generate all favicon and app icon + sizes.{" "} + {files.length > 1 && `Each of the ${files.length} images gets its own folder in the ZIP.`}

-

Generated Sizes

+

Generated Sizes (per image)

{SIZES.map((s) => ( ); diff --git a/apps/web/src/components/tools/pipeline-step-settings.tsx b/apps/web/src/components/tools/pipeline-step-settings.tsx index 09025855..e4b369fe 100644 --- a/apps/web/src/components/tools/pipeline-step-settings.tsx +++ b/apps/web/src/components/tools/pipeline-step-settings.tsx @@ -15,12 +15,7 @@ import { TextOverlayControls } from "./text-overlay-settings"; import { UpscaleControls } from "./upscale-settings"; import { WatermarkTextControls } from "./watermark-text-settings"; -const COLOR_TOOL_IDS = new Set([ - "brightness-contrast", - "saturation", - "color-channels", - "color-effects", -]); +const COLOR_TOOL_IDS = new Set(["adjust-colors"]); interface PipelineStepSettingsProps { toolId: string; diff --git a/apps/web/src/components/tools/remove-bg-settings.tsx b/apps/web/src/components/tools/remove-bg-settings.tsx index 43d284ef..a6a7a6e6 100644 --- a/apps/web/src/components/tools/remove-bg-settings.tsx +++ b/apps/web/src/components/tools/remove-bg-settings.tsx @@ -1,4 +1,12 @@ -import { Download, ImageIcon, Package, User } from "lucide-react"; +import { + ChevronDown, + ChevronRight, + Download, + ImageIcon, + Package, + Upload, + User, +} from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; @@ -6,6 +14,7 @@ import { useFileStore } from "@/stores/file-store"; type SubjectType = "people" | "products" | "general"; type Quality = "fast" | "balanced" | "best"; +type BackgroundType = "transparent" | "color" | "gradient" | "image"; type BgModel = | "birefnet-general" @@ -32,15 +41,33 @@ const QUALITY_OPTIONS: { value: Quality; label: string }[] = [ { value: "best", label: "Best" }, ]; -const BG_PRESETS = [ - { color: "", label: "Transparent", preview: "checkerboard" }, - { color: "#FFFFFF", label: "White", preview: "#FFFFFF" }, - { color: "#000000", label: "Black", preview: "#000000" }, - { color: "#FF0000", label: "Red", preview: "#FF0000" }, - { color: "#00FF00", label: "Green", preview: "#00FF00" }, - { color: "#0000FF", label: "Blue", preview: "#0000FF" }, +const COLOR_PRESETS = [ + { color: "#FFFFFF", label: "White" }, + { color: "#000000", label: "Black" }, + { color: "#FF0000", label: "Red" }, + { color: "#00FF00", label: "Green" }, + { color: "#0000FF", label: "Blue" }, ]; +const GRADIENT_PRESETS = [ + { color1: "#667eea", color2: "#764ba2", label: "Purple" }, + { color1: "#f093fb", color2: "#f5576c", label: "Pink" }, + { color1: "#4facfe", color2: "#00f2fe", label: "Blue" }, + { color1: "#43e97b", color2: "#38f9d7", label: "Green" }, + { color1: "#fa709a", color2: "#fee140", label: "Sunset" }, + { color1: "#a18cd1", color2: "#fbc2eb", label: "Lavender" }, +]; + +// ── Section label ── + +function SectionLabel({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ); +} + // ── Shared controls (used by both standalone page and pipeline steps) ── export interface RemoveBgControlsProps { @@ -51,10 +78,27 @@ export interface RemoveBgControlsProps { export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) { const [subject, setSubject] = useState("people"); const [quality, setQuality] = useState("balanced"); - const [isPassport, setIsPassport] = useState(false); - const [bgColor, setBgColor] = useState((settings.backgroundColor as string) || ""); + const [isPassport, setIsPassport] = useState(true); - const model = isPassport ? "birefnet-portrait" : MODEL_MAP[subject][quality]; + // Background + const [bgType, setBgType] = useState("transparent"); + const [bgColor, setBgColor] = useState("#FFFFFF"); + const [gradColor1, setGradColor1] = useState("#667eea"); + const [gradColor2, setGradColor2] = useState("#764ba2"); + const [gradAngle, setGradAngle] = useState(180); + const [bgImageFile, setBgImageFile] = useState(null); + + // Effects + const [blurEnabled, setBlurEnabled] = useState(false); + const [blurIntensity, setBlurIntensity] = useState(50); + const [shadowEnabled, setShadowEnabled] = useState(false); + const [shadowOpacity, setShadowOpacity] = useState(35); + + // Expandable sections + const [effectsOpen, setEffectsOpen] = useState(false); + + const model = + isPassport && subject === "people" ? "birefnet-portrait" : MODEL_MAP[subject][quality]; const onChangeRef = useRef(onChange); useEffect(() => { @@ -63,42 +107,75 @@ export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps) // Sync settings on every control change useEffect(() => { - const next: Record = { model }; - if (bgColor) next.backgroundColor = bgColor; + const next: Record = { model, backgroundType: bgType }; + + if (bgType === "color") next.backgroundColor = bgColor; + if (bgType === "gradient") { + next.gradientColor1 = gradColor1; + next.gradientColor2 = gradColor2; + next.gradientAngle = gradAngle; + } + + // Blur: enabled as effect on transparent bg means "blur original background" + if (blurEnabled) { + next.blurEnabled = true; + next.blurIntensity = blurIntensity; + } + if (shadowEnabled) { + next.shadowEnabled = true; + next.shadowOpacity = shadowOpacity; + } + + // Pass bgImageFile reference for the standalone wrapper to include in FormData + if (bgType === "image" && bgImageFile) { + next._bgImageFile = bgImageFile; + } + onChangeRef.current(next); - }, [model, bgColor]); + }, [ + model, + bgType, + bgColor, + gradColor1, + gradColor2, + gradAngle, + bgImageFile, + blurEnabled, + blurIntensity, + shadowEnabled, + shadowOpacity, + ]); return ( -
+
{/* Subject type */} -
-

What's in the photo?

-
- {SUBJECT_OPTIONS.map((opt) => { - const Icon = opt.icon; - return ( - - ); - })} -
+ Subject +
+ {SUBJECT_OPTIONS.map((opt) => { + const Icon = opt.icon; + return ( + + ); + })}
- {/* Passport checkbox - only for people */} + {/* Passport checkbox - only for people, default ON */} {subject === "people" && (
+ )} - {/* Custom color picker */} -
- setBgColor(e.target.value)} - className="w-8 h-8 rounded border border-border cursor-pointer" - /> - setBgColor(e.target.value)} - placeholder="Custom hex (#FF5500)" - className="flex-1 px-2 py-1.5 rounded border border-border bg-background text-xs text-foreground" - /> -
+ {/* Gradient options */} + {bgType === "gradient" && ( +
+
+ {GRADIENT_PRESETS.map((preset) => ( +
+
+ setGradColor1(e.target.value)} + className="w-7 h-7 rounded border border-border cursor-pointer" + title="Start color" + /> + to + setGradColor2(e.target.value)} + className="w-7 h-7 rounded border border-border cursor-pointer" + title="End color" + /> +
+
+
+ Direction + {gradAngle}° +
+ setGradAngle(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+
+ )} + + {/* Image upload */} + {bgType === "image" && ( +
+ {bgImageFile ? ( +
+ {bgImageFile.name} + +
+ ) : ( + + )} +
+ )}
+ + {/* Effects */} + + + {effectsOpen && ( +
+ {/* Blur */} +
+ + {blurEnabled && ( +
+
+ Intensity + + {blurIntensity} + +
+ setBlurIntensity(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ )} +
+ + {/* Shadow */} +
+ + {shadowEnabled && ( +
+
+ Opacity + + {shadowOpacity} + +
+ setShadowOpacity(Number(e.target.value))} + className="w-full mt-0.5" + /> +
+ )} +
+
+ )}
); } -// ── Standalone tool page wrapper ────────────────────────────────────── +// ── Background type button ── -export function RemoveBgSettings() { +function BgTypeButton({ + active, + onClick, + label, + color, + gradient, + checkerboard, + isImage, +}: { + active: boolean; + onClick: () => void; + label: string; + color?: string; + gradient?: { color1: string; color2: string }; + checkerboard?: boolean; + isImage?: boolean; +}) { + let swatchStyle: React.CSSProperties = {}; + if (checkerboard) { + swatchStyle = { + backgroundImage: + "linear-gradient(45deg, #ccc 25%, transparent 25%), linear-gradient(-45deg, #ccc 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #ccc 75%), linear-gradient(-45deg, transparent 75%, #ccc 75%)", + backgroundSize: "8px 8px", + backgroundPosition: "0 0, 0 4px, 4px -4px, -4px 0px", + }; + } else if (gradient) { + swatchStyle = { + background: `linear-gradient(180deg, ${gradient.color1}, ${gradient.color2})`, + }; + } else if (color) { + swatchStyle = { backgroundColor: color }; + } + + return ( + + ); +} + +// ── Standalone tool page wrapper (two-phase flow) ── + +interface RemoveBgSettingsProps { + onBgPreview?: (state: import("@/components/common/image-viewer").BgPreviewState | null) => void; +} + +export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) { const { files } = useFileStore(); - const { processFiles, processing, error, downloadUrl, originalSize, processedSize, progress } = - useToolProcessor("remove-background"); + const { + processFiles, + processAllFiles, + processing, + error, + downloadUrl, + originalSize, + processedSize, + progress, + } = useToolProcessor("remove-background"); const [settings, setSettings] = useState>({}); - const handleProcess = () => { - processFiles(files, settings); - }; + // Two-phase state: after Phase 1 (bg removal), store job info for Phase 2 (effects) + const [bgJobId, setBgJobId] = useState(null); + const [bgFilename, setBgFilename] = useState(null); + const [bgOriginalUrl, setBgOriginalUrl] = useState(null); + const [effectsDownloadUrl, setEffectsDownloadUrl] = useState(null); + const [applyingEffects, setApplyingEffects] = useState(false); + const [effectsError, setEffectsError] = useState(null); + + // Create a blob URL for the uploaded background image (for CSS preview). + // HEIC/HEIF files can't be displayed by browsers, so we decode them via the + // server preview endpoint first. + const [bgImageBlobUrl, setBgImageBlobUrl] = useState(null); + const bgImageFileRef = useRef(null); + useEffect(() => { + const file = settings._bgImageFile as File | undefined; + if (file && file !== bgImageFileRef.current) { + bgImageFileRef.current = file; + let revoke: (() => void) | null = null; + + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + const isHeic = ext === "heic" || ext === "heif" || ext === "hif"; + + if (isHeic) { + // Decode HEIC via server preview endpoint + const formData = new FormData(); + formData.append("file", file); + import("@/lib/api").then(({ formatHeaders }) => { + fetch("/api/v1/preview", { + method: "POST", + headers: formatHeaders(), + body: formData, + }) + .then((res) => (res.ok ? res.blob() : null)) + .then((blob) => { + if (blob && bgImageFileRef.current === file) { + const url = URL.createObjectURL(blob); + revoke = () => URL.revokeObjectURL(url); + setBgImageBlobUrl(url); + } + }) + .catch(() => {}); + }); + } else { + const url = URL.createObjectURL(file); + revoke = () => URL.revokeObjectURL(url); + setBgImageBlobUrl(url); + } + + return () => revoke?.(); + } + if (!file && bgImageFileRef.current) { + bgImageFileRef.current = null; + setBgImageBlobUrl(null); + } + }, [settings._bgImageFile]); const hasFile = files.length > 0; + const bgRemoved = bgJobId !== null && !processing; + + // Build CSS preview state from current settings and send to tool-page + useEffect(() => { + if (!bgRemoved || !onBgPreview) return; + + const bgType = (settings.backgroundType as string) || "transparent"; + const blurEnabled = settings.blurEnabled as boolean; + const blurIntensity = (settings.blurIntensity as number) ?? 50; + const shadowEnabled = settings.shadowEnabled as boolean; + const shadowOpacity = (settings.shadowOpacity as number) ?? 35; + + // When no effects are active and background is transparent, show the + // before/after slider instead of the CSS preview (pass null). + const hasAnyEffect = blurEnabled || shadowEnabled || bgType !== "transparent"; + if (!hasAnyEffect) { + onBgPreview(null); + return; + } + + const preview: import("@/components/common/image-viewer").BgPreviewState = {}; + const sigma = 1 + (blurIntensity / 100) * 49; + + // Determine background source and blur + if (bgType === "image" && bgImageBlobUrl) { + preview.backgroundSrc = bgImageBlobUrl; + if (blurEnabled) { + preview.backgroundBlur = `blur(${sigma}px)`; + } + } else if (blurEnabled && (bgType === "transparent" || bgType === "blur")) { + preview.backgroundSrc = bgOriginalUrl || undefined; + preview.backgroundBlur = `blur(${sigma}px)`; + } else if (bgType === "color") { + preview.containerBackground = (settings.backgroundColor as string) || "#FFFFFF"; + } else if (bgType === "gradient") { + const c1 = (settings.gradientColor1 as string) || "#667eea"; + const c2 = (settings.gradientColor2 as string) || "#764ba2"; + const angle = (settings.gradientAngle as number) ?? 180; + preview.containerBackground = `linear-gradient(${angle}deg, ${c1}, ${c2})`; + } else { + preview.showCheckerboard = true; + } + + // Shadow + if (shadowEnabled) { + const alpha = Math.round((shadowOpacity / 100) * 255) + .toString(16) + .padStart(2, "0"); + preview.dropShadow = `drop-shadow(0px 10px 15px #000000${alpha})`; + } + + onBgPreview(preview); + }, [ + bgRemoved, + settings.backgroundType, + settings.backgroundColor, + settings.gradientColor1, + settings.gradientColor2, + settings.gradientAngle, + settings.blurEnabled, + settings.blurIntensity, + settings.shadowEnabled, + settings.shadowOpacity, + bgOriginalUrl, + bgImageBlobUrl, + onBgPreview, + ]); + + // Clear bg preview when no bg removal is active + useEffect(() => { + if (!bgRemoved && onBgPreview) onBgPreview(null); + }, [bgRemoved, onBgPreview]); + + // Phase 1: Run AI background removal + const handleRemoveBg = () => { + // Reset Phase 2 state + setBgJobId(null); + setBgFilename(null); + setBgOriginalUrl(null); + setEffectsDownloadUrl(null); + + if (files.length > 1) { + processAllFiles(files, settings); + return; + } + + // Custom XHR to capture the extended response (jobId, maskUrl, originalUrl) + const formData = new FormData(); + formData.append("file", files[0]); + + const cleanSettings = { ...settings }; + delete cleanSettings._bgImageFile; + formData.append("settings", JSON.stringify({ model: cleanSettings.model })); + + const clientJobId = `bg-${Date.now()}`; + formData.append("clientJobId", clientJobId); + + // Use processFiles for the progress/SSE flow - it handles everything + // But we need the extended response. Override via a fetch after processFiles completes. + // Actually, let's use processFiles and then fetch the job info. + processFiles(files, { model: settings.model }); + }; + + // After processFiles completes, extract jobId from downloadUrl + useEffect(() => { + if (!downloadUrl || processing) return; + // downloadUrl format: /api/v1/download/{jobId}/{filename} + const parts = downloadUrl.split("/"); + const jobId = parts[4]; // [0]='' [1]='api' [2]='v1' [3]='download' [4]=jobId [5]=filename + const filename = decodeURIComponent(parts[5] || ""); + if (jobId && filename) { + setBgJobId(jobId); + // Derive the cached filenames from the mask filename + const baseName = filename.replace(/_mask\.png$|_nobg\.png$/, ""); + setBgFilename(baseName || filename.replace(/\.[^.]+$/, "")); + // Build original URL from the job + const origFilename = `${baseName || filename.replace(/\.[^.]+$/, "")}_original.png`; + setBgOriginalUrl(`/api/v1/download/${jobId}/${encodeURIComponent(origFilename)}`); + } + }, [downloadUrl, processing]); + + // Phase 2: Apply effects and download + const handleDownloadWithEffects = async () => { + if (!bgJobId || !bgFilename) return; + + setApplyingEffects(true); + try { + const formData = new FormData(); + const effectSettings: Record = { + jobId: bgJobId, + filename: `${bgFilename}.png`, + backgroundType: settings.backgroundType, + backgroundColor: settings.backgroundColor, + gradientColor1: settings.gradientColor1, + gradientColor2: settings.gradientColor2, + gradientAngle: settings.gradientAngle, + blurEnabled: settings.blurEnabled, + blurIntensity: settings.blurIntensity, + shadowEnabled: settings.shadowEnabled, + shadowOpacity: settings.shadowOpacity, + }; + formData.append("settings", JSON.stringify(effectSettings)); + + const bgImageFile = settings._bgImageFile as File | undefined; + if (bgImageFile) { + formData.append("backgroundImage", bgImageFile); + } + + const headers = (await import("@/lib/api")).formatHeaders(); + const response = await fetch("/api/v1/tools/remove-background/effects", { + method: "POST", + headers, + body: formData, + }); + + if (!response.ok) { + const body = await response.json().catch(() => null); + throw new Error(body?.details || body?.error || `Effects failed: ${response.status}`); + } + + const result = await response.json(); + setEffectsDownloadUrl(result.downloadUrl); + setEffectsError(null); + + // Auto-trigger download + const a = document.createElement("a"); + a.href = result.downloadUrl; + a.download = ""; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + } catch (err) { + setEffectsError(err instanceof Error ? err.message : "Effects processing failed"); + } finally { + setApplyingEffects(false); + } + }; + + const hasEffectsToApply = + settings.blurEnabled || + settings.shadowEnabled || + ((settings.backgroundType as string) || "transparent") !== "transparent"; return (
- {/* Error */} + {/* Errors */} {error &&

{error}

} + {effectsError &&

{effectsError}

} {/* Size info */} {originalSize != null && processedSize != null && !processing && ( @@ -216,7 +778,7 @@ export function RemoveBgSettings() {
)} - {/* Process button */} + {/* Phase 1: Remove Background button */} {processing ? ( - ) : ( + ) : !bgRemoved ? ( - )} + ) : null} - {/* Download */} - {downloadUrl && !processing && ( - - - Download - + {/* Phase 2: Single smart download button */} + {bgRemoved && files.length <= 1 && ( +
+ {hasEffectsToApply ? ( + + ) : ( + + + Download + + )} +
)}
); diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index b98bfc1b..5a39eb72 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -124,10 +124,17 @@ export function useToolProcessor(toolId: string) { } } - // Build form data + // Build form data - extract any File objects from settings before JSON serialization + const cleanSettings = { ...settings }; + const bgImageFile = cleanSettings._bgImageFile as File | undefined; + delete cleanSettings._bgImageFile; + const formData = new FormData(); formData.append("file", files[0]); - formData.append("settings", JSON.stringify(settings)); + formData.append("settings", JSON.stringify(cleanSettings)); + if (bgImageFile) { + formData.append("backgroundImage", bgImageFile); + } if (isAiTool) { formData.append("clientJobId", clientJobId); } @@ -368,6 +375,7 @@ export function useToolProcessor(toolId: string) { const blob = new Blob([extracted[processedName] as BlobPart]); updateEntry(i, { processedUrl: URL.createObjectURL(blob), + processedFilename: processedName, processedSize: blob.size, status: "completed", error: null, diff --git a/apps/web/src/lib/suggested-tools.ts b/apps/web/src/lib/suggested-tools.ts index 83ea9e25..f451f2bc 100644 --- a/apps/web/src/lib/suggested-tools.ts +++ b/apps/web/src/lib/suggested-tools.ts @@ -5,10 +5,7 @@ const TOOL_SUGGESTIONS: Record = { convert: ["compress", "strip-metadata", "watermark-text"], compress: ["convert", "strip-metadata", "watermark-text"], "strip-metadata": ["compress", "convert"], - "brightness-contrast": ["compress", "convert", "resize"], - saturation: ["compress", "convert", "resize"], - "color-channels": ["compress", "convert"], - "color-effects": ["compress", "convert", "resize"], + "adjust-colors": ["compress", "convert", "resize"], "replace-color": ["compress", "convert"], "remove-background": ["resize", "compress", "convert"], upscale: ["compress", "convert"], diff --git a/apps/web/src/lib/tool-registry.tsx b/apps/web/src/lib/tool-registry.tsx index e257962b..c59e085f 100644 --- a/apps/web/src/lib/tool-registry.tsx +++ b/apps/web/src/lib/tool-registry.tsx @@ -7,6 +7,7 @@ import type React from "react"; import { lazy } from "react"; import type { Crop } from "react-image-crop"; +import type { BgPreviewState } from "@/components/common/image-viewer"; import type { EraserCanvasRef } from "@/components/tools/eraser-canvas"; import type { PreviewTransform } from "@/components/tools/rotate-settings"; @@ -53,6 +54,7 @@ export interface ToolRegistryEntry { Settings: React.ComponentType<{ onPreviewTransform?: (t: PreviewTransform) => void; onPreviewFilter?: (filter: string) => void; + onBgPreview?: (state: BgPreviewState | null) => void; cropProps?: CropProps; eraserProps?: EraserProps; }>; @@ -253,18 +255,15 @@ export const toolRegistry = new Map([ ["strip-metadata", { displayMode: "no-comparison", Settings: StripMetadataSettings }], ["edit-metadata", { displayMode: "no-comparison", Settings: EditMetadataSettings }], - // Color adjustments (all share ColorSettings with different toolId) - ...(["brightness-contrast", "saturation", "color-channels", "color-effects"] as const).map( - (id) => - [ - id, - { - displayMode: "live-preview" as DisplayMode, - livePreview: true, - Settings: makeColorSettingsComponent(id) as never, - }, - ] as const, - ), + // Color adjustments (consolidated) + [ + "adjust-colors", + { + displayMode: "live-preview" as DisplayMode, + livePreview: true, + Settings: makeColorSettingsComponent("adjust-colors") as never, + }, + ], // Watermark & Overlay ["watermark-text", { displayMode: "before-after", Settings: WatermarkTextSettings }], diff --git a/apps/web/src/pages/tool-page.tsx b/apps/web/src/pages/tool-page.tsx index 5661f331..2897a98a 100644 --- a/apps/web/src/pages/tool-page.tsx +++ b/apps/web/src/pages/tool-page.tsx @@ -6,7 +6,7 @@ import type { Crop } from "react-image-crop"; import { useParams } from "react-router-dom"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { Dropzone } from "@/components/common/dropzone"; -import { ImageViewer } from "@/components/common/image-viewer"; +import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer"; import { ReviewPanel } from "@/components/common/review-panel"; import { SideBySideComparison } from "@/components/common/side-by-side-comparison"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; @@ -33,8 +33,10 @@ const BROWSER_PREVIEWABLE_EXTS = new Set([ "avif", ]); -function canBrowserPreview(url: string): boolean { - const ext = decodeURIComponent(url).split(".").pop()?.toLowerCase() ?? ""; +function canBrowserPreview(url: string, filename?: string | null): boolean { + // For blob URLs from batch processing, check the real filename instead + const source = filename ?? url; + const ext = decodeURIComponent(source).split(".").pop()?.toLowerCase() ?? ""; return BROWSER_PREVIEWABLE_EXTS.has(ext); } @@ -137,6 +139,7 @@ export function ToolPage() { const [mobileSettingsOpen, setMobileSettingsOpen] = useState(true); const [previewTransform, setPreviewTransform] = useState(null); const [previewFilter, setPreviewFilter] = useState(""); + const [bgPreview, setBgPreview] = useState(null); const [cropCrop, setCropCrop] = useState({ unit: "%", @@ -227,12 +230,17 @@ export function ToolPage() { const isNoDropzone = displayMode === "no-dropzone"; const isLivePreview = registryEntry.livePreview ?? false; - // Derive processed file info from the actual download URL (has correct extension) - const processedFileName = processedUrl - ? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image") - : "processed-image"; + // Derive processed file info: use stored filename for batch results (blob URLs), + // fall back to parsing the download URL for single-file results + const processedFileName = + currentEntry?.processedFilename ?? + (processedUrl + ? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image") + : "processed-image"); const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "IMAGE"; - const isProcessedPreviewable = processedUrl ? canBrowserPreview(processedUrl) : false; + const isProcessedPreviewable = processedUrl + ? canBrowserPreview(processedUrl, currentEntry?.processedFilename) + : false; // Use server-generated preview for non-previewable formats (HEIC, TIFF). // Always a string when hasProcessed is true (processedUrl is non-null). const displayUrl = (processedPreviewUrl ?? processedUrl) as string; @@ -241,6 +249,7 @@ export function ToolPage() { const settingsProps = { onPreviewTransform: isLivePreview ? setPreviewTransform : undefined, onPreviewFilter: isLivePreview ? setPreviewFilter : undefined, + onBgPreview: setBgPreview, cropProps: displayMode === "interactive-crop" ? { @@ -360,6 +369,18 @@ export function ToolPage() { } if (hasProcessed && originalBlobUrl) { + // When bg preview state is set (remove-background effects mode), + // show the ImageViewer with layered CSS preview instead of before/after slider + if (bgPreview) { + return ( + + ); + } return (
+ {/* Batch download — shown right after settings for easy access */} + {entries.length > 1 && hasProcessed && batchZipBlob && ( + + )} + {hasProcessed && processedSize != null && ( {renderSettingsContent()} - - {/* Batch download */} - {entries.length > 1 && hasProcessed && batchZipBlob && ( -
-
- -
- )}
{/* Main area: image viewer */} diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index 207b51eb..e49a0dd3 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -7,6 +7,7 @@ export interface FileEntry { previewLoading: boolean; processedUrl: string | null; processedPreviewUrl: string | null; + processedFilename: string | null; processedSize: number | null; originalSize: number; status: "pending" | "processing" | "completed" | "failed"; @@ -25,6 +26,7 @@ function createEntry(file: File): FileEntry { previewLoading: needsServerPreview(file), processedUrl: null, processedPreviewUrl: null, + processedFilename: null, processedSize: null, originalSize: file.size, status: "pending", @@ -280,6 +282,7 @@ export const useFileStore = create((set, get) => ({ ...updated[selectedIndex], processedUrl: url, processedPreviewUrl: previewUrl ?? null, + processedFilename: null, status: "completed", }; } else { @@ -287,6 +290,7 @@ export const useFileStore = create((set, get) => ({ ...updated[selectedIndex], processedUrl: null, processedPreviewUrl: null, + processedFilename: null, status: "pending", }; } @@ -314,6 +318,7 @@ export const useFileStore = create((set, get) => ({ ...e, processedUrl: null, processedPreviewUrl: null, + processedFilename: null, processedSize: null, status: "pending" as const, error: null, diff --git a/packages/ai/python/remove_bg.py b/packages/ai/python/remove_bg.py index 89f86f5f..0dd2b77c 100644 --- a/packages/ai/python/remove_bg.py +++ b/packages/ai/python/remove_bg.py @@ -15,7 +15,6 @@ def main(): settings = json.loads(sys.argv[3]) if len(sys.argv) > 3 else {} model = settings.get("model", "birefnet-general-lite") - bg_color = settings.get("backgroundColor", "") # Redirect stdout to stderr so library download/progress output # cannot contaminate our JSON result on stdout. @@ -25,7 +24,6 @@ def main(): try: from rembg import remove, new_session from gpu import onnx_providers - import io emit_progress(10, "Loading model") @@ -51,21 +49,8 @@ def main(): emit_progress(80, "Background removed") - # If a background color is specified, composite onto it - if bg_color and bg_color.startswith("#"): - emit_progress(85, "Compositing background") - from PIL import Image - - img = Image.open(io.BytesIO(output_data)).convert("RGBA") - hex_color = bg_color.lstrip("#") - r = int(hex_color[0:2], 16) - g = int(hex_color[2:4], 16) - b = int(hex_color[4:6], 16) - bg = Image.new("RGBA", img.size, (r, g, b, 255)) - bg.paste(img, mask=img.split()[3]) - buf = io.BytesIO() - bg.save(buf, format="PNG") - output_data = buf.getvalue() + # Always return transparent PNG. All background compositing + # (solid color, gradient, blur, shadow) is handled by Node.js/Sharp. emit_progress(95, "Saving result") with open(output_path, "wb") as f: diff --git a/packages/image-engine/src/index.ts b/packages/image-engine/src/index.ts index c9d173be..22f8b7ad 100644 --- a/packages/image-engine/src/index.ts +++ b/packages/image-engine/src/index.ts @@ -14,6 +14,7 @@ export { resize } from "./operations/resize.js"; export { rotate } from "./operations/rotate.js"; export { saturation } from "./operations/saturation.js"; export { sepia } from "./operations/sepia.js"; +export { sharpen } from "./operations/sharpen.js"; export { stripMetadata } from "./operations/strip-metadata.js"; export * from "./types.js"; export * from "./utils/metadata.js"; diff --git a/packages/image-engine/src/operations/sharpen.ts b/packages/image-engine/src/operations/sharpen.ts new file mode 100644 index 00000000..8988a8d5 --- /dev/null +++ b/packages/image-engine/src/operations/sharpen.ts @@ -0,0 +1,15 @@ +import type { Sharp, SharpenOptions } from "../types.js"; + +export async function sharpen(image: Sharp, options: SharpenOptions): Promise { + const { value } = options; + + if (value <= 0) return image; + if (value > 100) { + throw new Error("Sharpness value must be between 0 and 100"); + } + + // Map 0-100 to sigma 0.5-10 + const sigma = 0.5 + (value / 100) * 9.5; + + return image.sharpen({ sigma }); +} diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 0d9049e1..fe02edd7 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -104,3 +104,7 @@ export interface ColorChannelOptions { green: number; // 0-200 blue: number; // 0-200 } + +export interface SharpenOptions { + value: number; // 0 to 100 +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index f30fdc64..a3a7d232 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -97,36 +97,12 @@ export const TOOLS: Tool[] = [ }, // Adjustments { - id: "brightness-contrast", - name: "Brightness & Contrast", - description: "Adjust brightness and contrast levels", + id: "adjust-colors", + name: "Adjust Colors", + description: "Brightness, contrast, exposure, saturation, temperature, sharpness, and effects", category: "adjustments", - icon: "Sun", - route: "/brightness-contrast", - }, - { - id: "saturation", - name: "Saturation & Exposure", - description: "Adjust color saturation and exposure", - category: "adjustments", - icon: "Palette", - route: "/saturation", - }, - { - id: "color-channels", - name: "Color Channels", - description: "Adjust individual R, G, B channels", - category: "adjustments", - icon: "CircleDot", - route: "/color-channels", - }, - { - id: "color-effects", - name: "Color Effects", - description: "Grayscale, Sepia, Invert, Tint", - category: "adjustments", - icon: "Paintbrush", - route: "/color-effects", + icon: "SlidersHorizontal", + route: "/adjust-colors", }, { id: "replace-color", diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts index 37af1cfa..8c18d14b 100644 --- a/packages/shared/src/i18n/en.ts +++ b/packages/shared/src/i18n/en.ts @@ -44,16 +44,11 @@ export const en = { "image-to-pdf": { name: "Image to PDF", description: "Combine images into a PDF document" }, "pdf-to-image": { name: "PDF to Image", description: "Convert PDF pages to images" }, favicon: { name: "Favicon Generator", description: "Generate all favicon and app icon sizes" }, - "brightness-contrast": { - name: "Brightness & Contrast", - description: "Adjust brightness and contrast levels", + "adjust-colors": { + name: "Adjust Colors", + description: + "Brightness, contrast, exposure, saturation, temperature, sharpness, and effects", }, - saturation: { - name: "Saturation & Exposure", - description: "Adjust color saturation and exposure", - }, - "color-channels": { name: "Color Channels", description: "Adjust individual R, G, B channels" }, - "color-effects": { name: "Color Effects", description: "Grayscale, Sepia, Invert, Tint" }, "replace-color": { name: "Replace & Invert Color", description: "Replace specific colors or invert", diff --git a/tests/e2e/batch-preview.spec.ts b/tests/e2e/batch-preview.spec.ts new file mode 100644 index 00000000..7d3a99df --- /dev/null +++ b/tests/e2e/batch-preview.spec.ts @@ -0,0 +1,135 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, getTestHeicPath, test } from "./helpers"; + +// --------------------------------------------------------------------------- +// Tests for batch processing preview and download fixes. +// Verifies that batch results show proper image previews (not UUID text) +// and that downloads produce correctly named files. +// --------------------------------------------------------------------------- + +function getFixturePath(name: string): string { + return path.join(process.cwd(), "tests", "fixtures", name); +} + +function uploadMultipleFiles(page: import("@playwright/test").Page, filePaths: string[]) { + return async () => { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePaths); + await page.waitForTimeout(1000); + }; +} + +test.describe("Batch processing preview and download", () => { + test("batch adjust-colors shows image preview, not UUID text", async ({ loggedInPage: page }) => { + await page.goto("/adjust-colors"); + + // Upload 2 images (PNG + JPG) + const files = [getFixturePath("test-200x150.png"), getFixturePath("test-100x100.jpg")]; + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(files); + await page.waitForTimeout(1000); + + // Verify 2 files are loaded + await expect(page.getByText("Files (2)")).toBeVisible(); + + // Select Grayscale effect (effects are always visible, no tab click needed) + await page.getByRole("button", { name: "Grayscale" }).click(); + + // Click Apply (batch mode for multiple files) + await page.getByRole("button", { name: /apply.*2 files/i }).click(); + + // Wait for processing to complete + await expect(page.getByText(/conversion complete/i)).not.toBeVisible({ timeout: 30_000 }); + + // The image preview area should show an actual image + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible({ + timeout: 15_000, + }); + + // Should NOT show UUID-like text as filename + await expect(page.getByText(/files cannot be previewed/i)).not.toBeVisible(); + + // The Review panel should show a real filename (with extension) + const reviewFilename = page + .locator("text=test-200x150.png") + .or(page.locator("text=test-200x150")); + await expect(reviewFilename.first()).toBeVisible({ timeout: 5_000 }); + + // The Download All (ZIP) button should be visible + await expect(page.getByRole("button", { name: /download all/i })).toBeVisible(); + }); + + test("batch adjust-colors with HEIC shows preview", async ({ loggedInPage: page }) => { + await page.goto("/adjust-colors"); + + // Upload HEIC + PNG + const heicPath = getTestHeicPath(); + const pngPath = getFixturePath("test-200x150.png"); + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles([heicPath, pngPath]); + await page.waitForTimeout(2000); + + // Select Grayscale + await page.getByRole("button", { name: "Grayscale" }).click(); + + // Apply batch + await page.getByRole("button", { name: /apply.*2 files/i }).click(); + + // Wait for processing - should show image preview + await page.waitForTimeout(3000); + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible({ + timeout: 20_000, + }); + + // Navigate to second image and verify it also has a preview + await page.getByRole("button", { name: "Next image" }).click(); + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible({ + timeout: 5_000, + }); + }); + + test("old route /brightness-contrast redirects to /adjust-colors", async ({ + loggedInPage: page, + }) => { + await page.goto("/brightness-contrast"); + await page.waitForURL("/adjust-colors"); + await expect(page.getByText("Adjust Colors")).toBeVisible(); + }); +}); + +test.describe("Favicon download button", () => { + test("favicon shows download button instead of auto-downloading", async ({ + loggedInPage: page, + }) => { + await page.goto("/favicon"); + + // Upload a test image + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(getFixturePath("test-200x150.png")); + await page.waitForTimeout(500); + + // Click generate + await page.getByTestId("favicon-submit").click(); + + // Wait for the download button to appear (not an auto-download) + const downloadLink = page.getByTestId("favicon-download"); + await expect(downloadLink).toBeVisible({ timeout: 30_000 }); + + // Verify it's an tag with download attribute (not a button that auto-triggers) + await expect(downloadLink).toHaveAttribute("download", "favicons.zip"); + await expect(downloadLink).toHaveAttribute("href", /^blob:/); + }); +}); diff --git a/tests/e2e/remove-bg.spec.ts b/tests/e2e/remove-bg.spec.ts new file mode 100644 index 00000000..4e4ae037 --- /dev/null +++ b/tests/e2e/remove-bg.spec.ts @@ -0,0 +1,272 @@ +import path from "node:path"; +import { expect, test } from "./helpers"; + +// --------------------------------------------------------------------------- +// Remove Background tool - comprehensive e2e tests. +// Tests HEIC/JPG support, all background types, blur, shadow, and batch. +// --------------------------------------------------------------------------- + +function fixturePath(name: string): string { + return path.join(process.cwd(), "tests", "fixtures", name); +} + +async function uploadFile(page: import("@playwright/test").Page, filePath: string) { + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(filePath); + await page.waitForTimeout(1000); +} + +/** Phase 1 helper: remove bg, wait for download button */ +async function removeBgAndWait(page: import("@playwright/test").Page) { + await page.getByTestId("remove-background-submit").click(); + // After Phase 1, either download or download-effects button appears + await expect( + page + .getByTestId("remove-background-download") + .or(page.getByTestId("remove-background-download-effects")), + ).toBeVisible({ timeout: 120_000 }); +} + +test.describe("Remove Background tool", () => { + test("page loads with correct UI sections", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + + await expect(page.getByText("People")).toBeVisible(); + await expect(page.getByText("Products")).toBeVisible(); + await expect(page.getByText("General")).toBeVisible(); + await expect(page.getByText("Fast")).toBeVisible(); + await expect(page.getByText("Balanced")).toBeVisible(); + await expect(page.getByText("Best")).toBeVisible(); + + // Passport checkbox visible and checked by default + const passportCheckbox = page.locator("input[type='checkbox']").first(); + await expect(passportCheckbox).toBeChecked(); + + // Background type buttons + await expect(page.getByText("Transparent")).toBeVisible(); + await expect(page.getByText("Color")).toBeVisible(); + await expect(page.getByText("Gradient")).toBeVisible(); + await expect(page.getByRole("button", { name: "Image" })).toBeVisible(); + + // Effects section + await expect(page.getByText("Effects")).toBeVisible(); + }); + + test("passport checkbox defaults ON for people, OFF for other subjects", async ({ + loggedInPage: page, + }) => { + await page.goto("/remove-background"); + + const passportCheckbox = page.locator("input[type='checkbox']").first(); + await expect(passportCheckbox).toBeChecked(); + + await page.getByText("Products").click(); + await expect(page.getByText("Passport / ID photo")).not.toBeVisible(); + + await page.getByText("People").click(); + await expect(page.getByText("Passport / ID photo")).toBeVisible(); + await expect(passportCheckbox).toBeChecked(); + }); + + test("background type controls show/hide sub-options", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + + await page.getByRole("button", { name: "Color" }).click(); + await expect(page.locator("input[type='color']").first()).toBeVisible(); + + await page.getByRole("button", { name: "Gradient" }).click(); + await expect(page.getByText("Direction")).toBeVisible(); + + await page.getByRole("button", { name: "Image" }).click(); + await expect(page.getByText("Choose background image")).toBeVisible(); + + await page.getByRole("button", { name: "Transparent" }).click(); + }); + + test("effects section expands with blur and shadow controls", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + + await page.getByText("Effects").click(); + await expect(page.getByText("Blur Background")).toBeVisible(); + await expect(page.getByText("Add Shadow")).toBeVisible(); + + await page.getByText("Blur Background").click(); + await expect(page.getByText("Intensity")).toBeVisible(); + + await page.getByText("Add Shadow").click(); + await expect(page.getByText("Opacity")).toBeVisible(); + }); + + test("JPG portrait - transparent background removal", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await removeBgAndWait(page); + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible(); + }); + + test("HEIC portrait - processes without error", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.heic")); + + await removeBgAndWait(page); + await expect(page.locator("text=Background removal failed")).not.toBeVisible(); + }); + + test("two-phase: remove bg then download with color background", async ({ + loggedInPage: page, + }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + // Phase 1 + await removeBgAndWait(page); + + // Phase 2: Select color background + await page.getByRole("button", { name: "Color" }).click(); + + // Download button should switch to effects mode + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + + // Button should show "Rendering..." briefly then return to "Download" + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("two-phase: remove bg then download with gradient", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await removeBgAndWait(page); + + await page.getByRole("button", { name: "Gradient" }).click(); + + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("two-phase: remove bg then download with blur", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await removeBgAndWait(page); + + // Enable blur + await page.getByText("Effects").click(); + await page.getByText("Blur Background").click(); + + // Preview should show blurred original background + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible(); + + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("two-phase: remove bg then download with shadow", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await removeBgAndWait(page); + + await page.getByText("Effects").click(); + await page.getByText("Add Shadow").click(); + + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("two-phase: remove bg then download with blur + shadow", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await removeBgAndWait(page); + + await page.getByText("Effects").click(); + await page.getByText("Blur Background").click(); + await page.getByText("Add Shadow").click(); + + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("two-phase: custom bg image + blur shows uploaded bg", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await page.getByRole("button", { name: "Image" }).click(); + const bgFileInput = page.locator("input[type='file'][accept*='image']"); + await bgFileInput.setInputFiles(fixturePath("test-200x150.png")); + + await page.getByText("Effects").click(); + await page.getByText("Blur Background").click(); + const blurSlider = page.locator("input[type='range']").first(); + await blurSlider.fill("100"); + + await removeBgAndWait(page); + + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible(); + + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("two-phase: HEIC background image works for preview and download", async ({ + loggedInPage: page, + }) => { + await page.goto("/remove-background"); + await uploadFile(page, fixturePath("test-portrait.jpg")); + + await page.getByRole("button", { name: "Image" }).click(); + const bgFileInput = page.locator("input[type='file'][accept*='image']"); + await bgFileInput.setInputFiles(fixturePath("test-portrait.heic")); + await page.waitForTimeout(3000); + + await removeBgAndWait(page); + + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible(); + + const dlBtn = page.getByTestId("remove-background-download-effects"); + await expect(dlBtn).toBeVisible(); + await dlBtn.click(); + await expect(dlBtn).not.toHaveText("Rendering...", { timeout: 30_000 }); + }); + + test("batch - JPG + HEIC processes both", async ({ loggedInPage: page }) => { + await page.goto("/remove-background"); + + const files = [fixturePath("test-portrait.jpg"), fixturePath("test-portrait.heic")]; + const fileChooserPromise = page.waitForEvent("filechooser"); + const dropzone = page.locator("[class*='border-dashed']").first(); + await dropzone.click(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles(files); + await page.waitForTimeout(2000); + + await expect(page.getByText("Files (2)")).toBeVisible(); + await expect(page.getByText(/remove background.*2 files/i)).toBeVisible(); + + await page.getByTestId("remove-background-submit").click(); + + await expect(page.getByRole("button", { name: /download all/i })).toBeVisible({ + timeout: 180_000, + }); + + await expect(page.locator("section[aria-label='Image area'] img").first()).toBeVisible({ + timeout: 10_000, + }); + }); +}); diff --git a/tests/e2e/tools-all.spec.ts b/tests/e2e/tools-all.spec.ts index 5f616f34..e1d4ef3c 100644 --- a/tests/e2e/tools-all.spec.ts +++ b/tests/e2e/tools-all.spec.ts @@ -16,10 +16,7 @@ const TOOLS_WITH_DROPZONE = [ { id: "bulk-rename", name: "Bulk Rename" }, { id: "image-to-pdf", name: "Image to PDF" }, { id: "favicon", name: "Favicon" }, - { id: "brightness-contrast", name: "Brightness" }, - { id: "saturation", name: "Saturation" }, - { id: "color-channels", name: "Color Channels" }, - { id: "color-effects", name: "Color Effects" }, + { id: "adjust-colors", name: "Adjust Colors" }, { id: "replace-color", name: "Replace" }, { id: "remove-background", name: "Remove Background" }, { id: "upscale", name: "Upscal" }, @@ -88,7 +85,7 @@ test.describe("Tool pages accept file upload", () => { "compress", "convert", "strip-metadata", - "brightness-contrast", + "adjust-colors", "watermark-text", "info", "border", diff --git a/tests/e2e/tools-process.spec.ts b/tests/e2e/tools-process.spec.ts index 6dac1a98..92addaa2 100644 --- a/tests/e2e/tools-process.spec.ts +++ b/tests/e2e/tools-process.spec.ts @@ -82,7 +82,7 @@ test.describe("Tool processing (core tools)", () => { test("strip-metadata processes image", async ({ loggedInPage: page }) => { await page.goto("/strip-metadata"); await uploadTestImage(page); - await page.getByRole("button", { name: /strip metadata/i }).click(); + await page.getByRole("button", { name: /remove metadata/i }).click(); await waitForProcessing(page); await expect(page.getByRole("link", { name: /download/i }).first()).toBeVisible({ timeout: 15_000, @@ -106,8 +106,8 @@ test.describe("Tool processing (core tools)", () => { }); }); - test("brightness-contrast processes image", async ({ loggedInPage: page }) => { - await page.goto("/brightness-contrast"); + test("adjust-colors processes image", async ({ loggedInPage: page }) => { + await page.goto("/adjust-colors"); await uploadTestImage(page); // Adjust brightness to non-zero so processing makes a change const brightnessSlider = page.locator("input[type='range']").first(); diff --git a/tests/fixtures/test-portrait.heic b/tests/fixtures/test-portrait.heic new file mode 100644 index 00000000..229b9017 Binary files /dev/null and b/tests/fixtures/test-portrait.heic differ diff --git a/tests/fixtures/test-portrait.jpg b/tests/fixtures/test-portrait.jpg new file mode 100644 index 00000000..d1117518 Binary files /dev/null and b/tests/fixtures/test-portrait.jpg differ