mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: overhaul remove-background with effects pipeline, consolidate color tools
Remove Background: - Two-phase flow: AI removes bg once, then effects adjust instantly - Blur background effect with real-time CSS preview (portrait mode) - Drop shadow effect with opacity control - Gradient backgrounds with presets, custom colors, and angle - Custom background image upload (including HEIC/HEIF) - Solid color backgrounds moved from Python to Node.js/Sharp - Effects-only API endpoint for instant re-renders without AI re-run - HEIC/HEIF input support (decoded before passing to Python/rembg) - Passport/ID photo checkbox defaults ON for People subject - Before/after slider preserved when no effects active - 15 comprehensive Playwright e2e tests Color Tools: - Consolidated 4 tools (brightness-contrast, saturation, color-channels, color-effects) into single "Adjust Colors" tool - Added exposure, temperature, tint, hue, sharpness controls - SVG filter-based live preview for all adjustments - Backward-compatible URL redirects from old tool paths Other fixes: - Favicon tool: download button instead of auto-download - Batch processing: HEIC filename extension fix - File store: processedFilename field for proper batch downloads
This commit is contained in:
@@ -0,0 +1,249 @@
|
||||
import sharp from "sharp";
|
||||
|
||||
/**
|
||||
* Background removal post-processing effects.
|
||||
* All effects use Sharp (libvips) for fast server-side image manipulation.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Blur the original background and composite the sharp subject on top.
|
||||
* Produces a "portrait mode" / bokeh effect.
|
||||
*
|
||||
* @param originalBuffer - The original image before bg removal
|
||||
* @param subjectBuffer - The bg-removed PNG with alpha channel
|
||||
* @param intensity - 0-100 slider value, mapped to sigma 1-50
|
||||
*/
|
||||
export async function blurBackground(
|
||||
originalBuffer: Buffer,
|
||||
subjectBuffer: Buffer,
|
||||
intensity: number,
|
||||
): Promise<Buffer> {
|
||||
const sigma = 1 + (Math.max(0, Math.min(100, intensity)) / 100) * 49;
|
||||
|
||||
// Ensure both images are the same dimensions
|
||||
const subjectMeta = await sharp(subjectBuffer).metadata();
|
||||
const { width, height } = subjectMeta;
|
||||
|
||||
const blurredBg = await sharp(originalBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.blur(sigma)
|
||||
.toBuffer();
|
||||
|
||||
return sharp(blurredBg)
|
||||
.composite([{ input: subjectBuffer, blend: "over" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a drop shadow generated from the subject's alpha mask.
|
||||
* Shadow is offset downward and blurred for a natural look.
|
||||
*
|
||||
* @param subjectBuffer - PNG with alpha channel
|
||||
* @param opacity - 0-100 slider value
|
||||
*/
|
||||
export async function addDropShadow(subjectBuffer: Buffer, opacity: number): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
const normalizedOpacity = Math.max(0, Math.min(100, opacity)) / 100;
|
||||
|
||||
// Shadow parameters
|
||||
const offsetY = Math.max(4, Math.round(height * 0.015));
|
||||
const blurSigma = Math.max(5, Math.round(height * 0.02));
|
||||
|
||||
// Extract alpha channel
|
||||
const alphaRaw = await sharp(subjectBuffer).extractChannel(3).raw().toBuffer();
|
||||
|
||||
// Build shadow RGBA: black pixels with scaled alpha
|
||||
const shadowPixels = Buffer.alloc(width * height * 4);
|
||||
for (let i = 0; i < width * height; i++) {
|
||||
shadowPixels[i * 4] = 0;
|
||||
shadowPixels[i * 4 + 1] = 0;
|
||||
shadowPixels[i * 4 + 2] = 0;
|
||||
shadowPixels[i * 4 + 3] = Math.round(alphaRaw[i] * normalizedOpacity);
|
||||
}
|
||||
|
||||
// Blur the shadow
|
||||
const shadowBlurred = await sharp(shadowPixels, {
|
||||
raw: { width, height, channels: 4 },
|
||||
})
|
||||
.blur(blurSigma)
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
// Composite: transparent canvas -> shadow (offset) -> subject (centered)
|
||||
// Keep same canvas size, shadow clips at edges
|
||||
return sharp({
|
||||
create: { width, height, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
||||
})
|
||||
.composite([
|
||||
{ input: shadowBlurred, left: 0, top: offsetY, blend: "over" },
|
||||
{ input: subjectBuffer, left: 0, top: 0, blend: "over" },
|
||||
])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a linear gradient background image as SVG, rendered via Sharp.
|
||||
*/
|
||||
export async function createGradientBackground(
|
||||
width: number,
|
||||
height: number,
|
||||
color1: string,
|
||||
color2: string,
|
||||
angle = 180,
|
||||
): Promise<Buffer> {
|
||||
const rad = (angle * Math.PI) / 180;
|
||||
const x1 = 50 - Math.sin(rad) * 50;
|
||||
const y1 = 50 - Math.cos(rad) * 50;
|
||||
const x2 = 50 + Math.sin(rad) * 50;
|
||||
const y2 = 50 + Math.cos(rad) * 50;
|
||||
|
||||
const svg = Buffer.from(
|
||||
`<svg width="${width}" height="${height}" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="${x1}%" y1="${y1}%" x2="${x2}%" y2="${y2}%">
|
||||
<stop offset="0%" stop-color="${color1}"/>
|
||||
<stop offset="100%" stop-color="${color2}"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#g)"/>
|
||||
</svg>`,
|
||||
);
|
||||
|
||||
return sharp(svg).resize(width, height).png().toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a subject (PNG with alpha) onto a solid color background.
|
||||
*/
|
||||
export async function compositeOnColor(subjectBuffer: Buffer, hexColor: string): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const hex = hexColor.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
|
||||
return sharp({
|
||||
create: {
|
||||
width: meta.width!,
|
||||
height: meta.height!,
|
||||
channels: 4,
|
||||
background: { r, g, b, alpha: 255 },
|
||||
},
|
||||
})
|
||||
.composite([{ input: subjectBuffer, blend: "over" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite a subject onto a background image.
|
||||
* The background image is resized to cover the subject dimensions.
|
||||
*/
|
||||
export async function compositeOnImage(
|
||||
subjectBuffer: Buffer,
|
||||
backgroundBuffer: Buffer,
|
||||
): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
|
||||
const resizedBg = await sharp(backgroundBuffer)
|
||||
.resize(width, height, { fit: "cover" })
|
||||
.toBuffer();
|
||||
|
||||
return sharp(resizedBg)
|
||||
.composite([{ input: subjectBuffer, blend: "over" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the full effects pipeline to a bg-removed subject.
|
||||
*
|
||||
* Order: shadow -> blur/background compositing
|
||||
* Shadow is applied to the transparent subject first, then composited onto background.
|
||||
*/
|
||||
export async function applyEffects(
|
||||
subjectBuffer: Buffer,
|
||||
originalBuffer: Buffer,
|
||||
settings: {
|
||||
backgroundColor?: string;
|
||||
backgroundType?: string;
|
||||
gradientColor1?: string;
|
||||
gradientColor2?: string;
|
||||
gradientAngle?: number;
|
||||
backgroundImageBuffer?: Buffer;
|
||||
blurEnabled?: boolean;
|
||||
blurIntensity?: number;
|
||||
shadowEnabled?: boolean;
|
||||
shadowOpacity?: number;
|
||||
},
|
||||
): Promise<Buffer> {
|
||||
const meta = await sharp(subjectBuffer).metadata();
|
||||
const width = meta.width!;
|
||||
const height = meta.height!;
|
||||
const bgType = settings.backgroundType || "transparent";
|
||||
|
||||
// Step 1: Add shadow to the subject (before background compositing)
|
||||
let subject = subjectBuffer;
|
||||
if (settings.shadowEnabled && settings.shadowOpacity && settings.shadowOpacity > 0) {
|
||||
subject = await addDropShadow(subject, settings.shadowOpacity);
|
||||
}
|
||||
|
||||
// Step 2: Build the background layer
|
||||
let background: Buffer | null = null;
|
||||
|
||||
if (bgType === "image" && settings.backgroundImageBuffer) {
|
||||
// Custom uploaded background image
|
||||
background = await sharp(settings.backgroundImageBuffer)
|
||||
.resize(width, height, { fit: "cover" })
|
||||
.toBuffer();
|
||||
// Apply blur to the uploaded bg image if enabled
|
||||
if (settings.blurEnabled) {
|
||||
const intensity = settings.blurIntensity ?? 50;
|
||||
const sigma = 1 + (Math.max(0, Math.min(100, intensity)) / 100) * 49;
|
||||
background = await sharp(background).blur(sigma).toBuffer();
|
||||
}
|
||||
} else if (settings.blurEnabled && (bgType === "transparent" || bgType === "blur")) {
|
||||
// Blur the original background (portrait mode)
|
||||
const intensity = settings.blurIntensity ?? 50;
|
||||
const sigma = 1 + (Math.max(0, Math.min(100, intensity)) / 100) * 49;
|
||||
background = await sharp(originalBuffer)
|
||||
.resize(width, height, { fit: "fill" })
|
||||
.blur(sigma)
|
||||
.toBuffer();
|
||||
} else if (bgType === "color" && settings.backgroundColor) {
|
||||
const hex = settings.backgroundColor.replace("#", "");
|
||||
const r = parseInt(hex.substring(0, 2), 16);
|
||||
const g = parseInt(hex.substring(2, 4), 16);
|
||||
const b = parseInt(hex.substring(4, 6), 16);
|
||||
background = await sharp({
|
||||
create: { width, height, channels: 4, background: { r, g, b, alpha: 255 } },
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
} else if (bgType === "gradient" && settings.gradientColor1 && settings.gradientColor2) {
|
||||
background = await createGradientBackground(
|
||||
width,
|
||||
height,
|
||||
settings.gradientColor1,
|
||||
settings.gradientColor2,
|
||||
settings.gradientAngle ?? 180,
|
||||
);
|
||||
}
|
||||
// else: transparent - no background layer
|
||||
|
||||
// Step 3: Composite subject onto background
|
||||
if (background) {
|
||||
return sharp(background)
|
||||
.composite([{ input: subject, blend: "over" }])
|
||||
.png()
|
||||
.toBuffer();
|
||||
}
|
||||
|
||||
return subject;
|
||||
}
|
||||
@@ -146,15 +146,19 @@ export async function registerBatchRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
try {
|
||||
let processBuffer = file.buffer;
|
||||
let processFilename = file.filename;
|
||||
// Skip HEIC decode and auto-orient for edit-metadata (ExifTool handles all formats natively)
|
||||
const skipPreprocess = toolId === "edit-metadata" || toolId === "strip-metadata";
|
||||
if (!skipPreprocess && validation.format === "heif") {
|
||||
processBuffer = await decodeHeic(processBuffer);
|
||||
// Update extension to match decoded format (HEIC/HEIF → PNG)
|
||||
const ext = processFilename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) processFilename = processFilename.slice(0, -ext.length) + ".png";
|
||||
}
|
||||
if (!skipPreprocess) {
|
||||
processBuffer = await autoOrient(processBuffer);
|
||||
}
|
||||
const result = await toolConfig.process(processBuffer, settings, file.filename);
|
||||
const result = await toolConfig.process(processBuffer, settings, processFilename);
|
||||
|
||||
results[index] = { buffer: result.buffer, filename: result.filename };
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
brightness as adjustBrightness,
|
||||
contrast as adjustContrast,
|
||||
saturation as adjustSaturation,
|
||||
sharpen as adjustSharpen,
|
||||
colorChannels,
|
||||
grayscale,
|
||||
invert,
|
||||
@@ -14,73 +15,123 @@ import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||
import { createToolRoute } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
// Light
|
||||
brightness: z.number().min(-100).max(100).default(0),
|
||||
contrast: z.number().min(-100).max(100).default(0),
|
||||
exposure: z.number().min(-100).max(100).default(0),
|
||||
// Color
|
||||
saturation: z.number().min(-100).max(100).default(0),
|
||||
temperature: z.number().min(-100).max(100).default(0),
|
||||
tint: z.number().min(-100).max(100).default(0),
|
||||
hue: z.number().min(-180).max(180).default(0),
|
||||
// Detail
|
||||
sharpness: z.number().min(0).max(100).default(0),
|
||||
// Channels
|
||||
red: z.number().min(0).max(200).default(100),
|
||||
green: z.number().min(0).max(200).default(100),
|
||||
blue: z.number().min(0).max(200).default(100),
|
||||
// Effects
|
||||
effect: z.enum(["none", "grayscale", "sepia", "invert"]).default("none"),
|
||||
});
|
||||
|
||||
/**
|
||||
* Combined color adjustment route that handles brightness, contrast,
|
||||
* saturation, color channels, and color effects in a single request.
|
||||
*
|
||||
* Serves tool IDs: brightness-contrast, saturation, color-channels, color-effects
|
||||
* Build a 3x3 recomb matrix for color temperature + tint shift.
|
||||
* Temperature: cool (blue) ←→ warm (orange) on the blue-orange axis.
|
||||
* Tint: green ←→ magenta on the green-magenta axis.
|
||||
*/
|
||||
export function registerColorAdjustments(app: FastifyInstance) {
|
||||
const toolIds = ["brightness-contrast", "saturation", "color-channels", "color-effects"];
|
||||
function colorTempTintMatrix(
|
||||
temp: number,
|
||||
tintVal: number,
|
||||
): [[number, number, number], [number, number, number], [number, number, number]] {
|
||||
const t = temp / 100;
|
||||
const n = tintVal / 100;
|
||||
return [
|
||||
[1 + t * 0.15 + n * 0.1, 0, 0],
|
||||
[0, 1 + t * 0.05 - n * 0.15, 0],
|
||||
[0, 0, 1 - t * 0.15 + n * 0.1],
|
||||
];
|
||||
}
|
||||
|
||||
for (const toolId of toolIds) {
|
||||
createToolRoute(app, {
|
||||
toolId,
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let image = sharp(inputBuffer);
|
||||
/**
|
||||
* Consolidated color adjustment route.
|
||||
*
|
||||
* Replaces the old brightness-contrast, saturation, color-channels,
|
||||
* and color-effects tools with a single "adjust-colors" endpoint.
|
||||
*/
|
||||
async function processColorAdjustments(
|
||||
inputBuffer: Buffer,
|
||||
settings: z.infer<typeof settingsSchema>,
|
||||
filename: string,
|
||||
) {
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
let image = sharp(inputBuffer);
|
||||
|
||||
if (settings.brightness !== 0) {
|
||||
image = await adjustBrightness(image, {
|
||||
value: settings.brightness,
|
||||
});
|
||||
}
|
||||
// Light
|
||||
if (settings.brightness !== 0) {
|
||||
image = await adjustBrightness(image, { value: settings.brightness });
|
||||
}
|
||||
if (settings.contrast !== 0) {
|
||||
image = await adjustContrast(image, { value: settings.contrast });
|
||||
}
|
||||
if (settings.exposure !== 0) {
|
||||
// Map -100..+100 to gamma 3.0..0.33 (lower gamma = brighter midtones)
|
||||
const gamma = 1 / (1 + settings.exposure / 100);
|
||||
image = image.gamma(gamma);
|
||||
}
|
||||
|
||||
if (settings.contrast !== 0) {
|
||||
image = await adjustContrast(image, { value: settings.contrast });
|
||||
}
|
||||
// Color
|
||||
if (settings.saturation !== 0 || settings.hue !== 0) {
|
||||
const modOpts: { saturation?: number; hue?: number } = {};
|
||||
if (settings.saturation !== 0) modOpts.saturation = 1 + settings.saturation / 100;
|
||||
if (settings.hue !== 0) modOpts.hue = settings.hue;
|
||||
image = image.modulate(modOpts);
|
||||
}
|
||||
if (settings.temperature !== 0 || settings.tint !== 0) {
|
||||
image = image.recomb(colorTempTintMatrix(settings.temperature, settings.tint));
|
||||
}
|
||||
|
||||
if (settings.saturation !== 0) {
|
||||
image = await adjustSaturation(image, {
|
||||
value: settings.saturation,
|
||||
});
|
||||
}
|
||||
// Detail
|
||||
if (settings.sharpness > 0) {
|
||||
image = await adjustSharpen(image, { value: settings.sharpness });
|
||||
}
|
||||
|
||||
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
|
||||
image = await colorChannels(image, {
|
||||
red: settings.red,
|
||||
green: settings.green,
|
||||
blue: settings.blue,
|
||||
});
|
||||
}
|
||||
|
||||
switch (settings.effect) {
|
||||
case "grayscale":
|
||||
image = await grayscale(image);
|
||||
break;
|
||||
case "sepia":
|
||||
image = await sepia(image);
|
||||
break;
|
||||
case "invert":
|
||||
image = await invert(image);
|
||||
break;
|
||||
}
|
||||
|
||||
const buffer = await image
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
// Channels
|
||||
if (settings.red !== 100 || settings.green !== 100 || settings.blue !== 100) {
|
||||
image = await colorChannels(image, {
|
||||
red: settings.red,
|
||||
green: settings.green,
|
||||
blue: settings.blue,
|
||||
});
|
||||
}
|
||||
|
||||
// Effects
|
||||
switch (settings.effect) {
|
||||
case "grayscale":
|
||||
image = await grayscale(image);
|
||||
break;
|
||||
case "sepia":
|
||||
image = await sepia(image);
|
||||
break;
|
||||
case "invert":
|
||||
image = await invert(image);
|
||||
break;
|
||||
}
|
||||
|
||||
const buffer = await image
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
}
|
||||
|
||||
export function registerColorAdjustments(app: FastifyInstance) {
|
||||
const allIds = [
|
||||
"adjust-colors",
|
||||
"brightness-contrast",
|
||||
"saturation",
|
||||
"color-channels",
|
||||
"color-effects",
|
||||
];
|
||||
for (const toolId of allIds) {
|
||||
createToolRoute(app, { toolId, settingsSchema, process: processColorAdjustments });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { basename, extname } from "node:path";
|
||||
import archiver from "archiver";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import sharp from "sharp";
|
||||
@@ -13,9 +14,14 @@ const FAVICON_SIZES = [
|
||||
{ name: "android-chrome-512x512.png", size: 512, format: "png" as const },
|
||||
];
|
||||
|
||||
interface UploadedFile {
|
||||
buffer: Buffer;
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export function registerFavicon(app: FastifyInstance) {
|
||||
app.post("/api/v1/tools/favicon", async (request, reply) => {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
const uploadedFiles: UploadedFile[] = [];
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
@@ -25,7 +31,9 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
const buffer = Buffer.concat(chunks);
|
||||
const filename = basename(part.filename ?? `image-${uploadedFiles.length + 1}`);
|
||||
uploadedFiles.push({ buffer, filename });
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -35,15 +43,13 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
if (!fileBuffer || fileBuffer.length === 0) {
|
||||
if (uploadedFiles.length === 0) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode HEIC/HEIF if needed
|
||||
fileBuffer = await ensureSharpCompat(fileBuffer);
|
||||
|
||||
const jobId = randomUUID();
|
||||
const isSingleFile = uploadedFiles.length === 1;
|
||||
|
||||
reply.hijack();
|
||||
reply.raw.writeHead(200, {
|
||||
@@ -55,44 +61,50 @@ export function registerFavicon(app: FastifyInstance) {
|
||||
const archive = archiver("zip", { zlib: { level: 5 } });
|
||||
archive.pipe(reply.raw);
|
||||
|
||||
// Generate each size
|
||||
for (const icon of FAVICON_SIZES) {
|
||||
const buffer = await sharp(fileBuffer)
|
||||
.resize(icon.size, icon.size, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
for (const file of uploadedFiles) {
|
||||
// Decode HEIC/HEIF if needed
|
||||
const decoded = await ensureSharpCompat(file.buffer);
|
||||
const stem = basename(file.filename, extname(file.filename));
|
||||
// Single file: flat structure. Multiple files: per-image folders.
|
||||
const prefix = isSingleFile ? "" : `${stem}/`;
|
||||
|
||||
archive.append(buffer, { name: icon.name });
|
||||
}
|
||||
// Generate each size
|
||||
for (const icon of FAVICON_SIZES) {
|
||||
const buffer = await sharp(decoded)
|
||||
.resize(icon.size, icon.size, { fit: "cover" })
|
||||
.png()
|
||||
.toBuffer();
|
||||
archive.append(buffer, { name: `${prefix}${icon.name}` });
|
||||
}
|
||||
|
||||
// Generate ICO (use 16x16 and 32x32 PNGs embedded)
|
||||
// Simple ICO format: just include the 32x32 PNG as an ICO
|
||||
const ico32 = await sharp(fileBuffer).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: "favicon.ico" });
|
||||
// Generate ICO (32x32 PNG as ICO)
|
||||
const ico32 = await sharp(decoded).resize(32, 32, { fit: "cover" }).png().toBuffer();
|
||||
archive.append(ico32, { name: `${prefix}favicon.ico` });
|
||||
|
||||
// Generate manifest.json (for PWA)
|
||||
const manifest = {
|
||||
name: "App",
|
||||
short_name: "App",
|
||||
icons: [
|
||||
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
theme_color: "#ffffff",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
};
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: "manifest.json" });
|
||||
// Generate manifest.json (for PWA)
|
||||
const manifest = {
|
||||
name: stem,
|
||||
short_name: stem,
|
||||
icons: [
|
||||
{ src: "/android-chrome-192x192.png", sizes: "192x192", type: "image/png" },
|
||||
{ src: "/android-chrome-512x512.png", sizes: "512x512", type: "image/png" },
|
||||
],
|
||||
theme_color: "#ffffff",
|
||||
background_color: "#ffffff",
|
||||
display: "standalone",
|
||||
};
|
||||
archive.append(JSON.stringify(manifest, null, 2), { name: `${prefix}manifest.json` });
|
||||
|
||||
// Generate HTML snippet
|
||||
const htmlSnippet = `<!-- Favicons -->
|
||||
// Generate HTML snippet
|
||||
const htmlSnippet = `<!-- Favicons -->
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
`;
|
||||
archive.append(htmlSnippet, { name: "favicon-snippet.html" });
|
||||
archive.append(htmlSnippet, { name: `${prefix}favicon-snippet.html` });
|
||||
}
|
||||
|
||||
await archive.finalize();
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,20 +1,43 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { removeBackground } from "@stirling-image/ai";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { applyEffects } from "../../lib/bg-effects.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||
import { createWorkspace, getWorkspacePath } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
|
||||
const settingsSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
backgroundType: z.enum(["transparent", "color", "gradient", "blur", "image"]).optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
gradientColor1: z.string().optional(),
|
||||
gradientColor2: z.string().optional(),
|
||||
gradientAngle: z.number().optional(),
|
||||
blurEnabled: z.boolean().optional(),
|
||||
blurIntensity: z.number().min(0).max(100).optional(),
|
||||
shadowEnabled: z.boolean().optional(),
|
||||
shadowOpacity: z.number().min(0).max(100).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* AI background removal route.
|
||||
* Uses Python + rembg under the hood.
|
||||
* AI background removal with two-phase flow:
|
||||
*
|
||||
* Phase 1 (POST /remove-background): Python/rembg removes background.
|
||||
* Returns transparent PNG + caches mask & original for effects re-apply.
|
||||
* Also returns maskUrl and originalUrl for frontend CSS preview.
|
||||
*
|
||||
* Phase 2 (POST /remove-background/effects): Node.js/Sharp applies effects.
|
||||
* Uses cached mask + original. No AI re-run. Instant response.
|
||||
* Called when user adjusts blur/shadow/background and clicks download.
|
||||
*/
|
||||
export function registerRemoveBackground(app: FastifyInstance) {
|
||||
// ── Phase 1: Background removal ──────────────────────────────────
|
||||
app.post(
|
||||
"/api/v1/tools/remove-background",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
@@ -28,9 +51,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
for await (const chunk of part.file) chunks.push(chunk);
|
||||
fileBuffer = Buffer.concat(chunks);
|
||||
filename = basename(part.filename ?? "image");
|
||||
} else if (part.fieldname === "settings") {
|
||||
@@ -58,7 +79,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
try {
|
||||
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
|
||||
// Auto-orient to fix EXIF rotation before processing
|
||||
// Decode HEIC/HEIF before processing
|
||||
if (validation.format === "heif") {
|
||||
fileBuffer = await decodeHeic(fileBuffer);
|
||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||
}
|
||||
|
||||
// Auto-orient to fix EXIF rotation
|
||||
fileBuffer = await autoOrient(fileBuffer);
|
||||
|
||||
request.log.info(
|
||||
@@ -72,7 +100,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
const inputPath = join(workspacePath, "input", filename);
|
||||
await writeFile(inputPath, fileBuffer);
|
||||
|
||||
// Process
|
||||
// Progress callback
|
||||
const jobIdForProgress = clientJobId;
|
||||
const onProgress = jobIdForProgress
|
||||
? (percent: number, stage: string) => {
|
||||
@@ -80,22 +108,24 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
jobId: jobIdForProgress,
|
||||
phase: "processing",
|
||||
stage,
|
||||
percent,
|
||||
percent: Math.min(percent, 95),
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const resultBuffer = await removeBackground(
|
||||
// Phase 1: AI background removal -> transparent PNG
|
||||
const transparentResult = await removeBackground(
|
||||
fileBuffer,
|
||||
join(workspacePath, "output"),
|
||||
{ model: settings.model, backgroundColor: settings.backgroundColor },
|
||||
{ model: settings.model },
|
||||
onProgress,
|
||||
);
|
||||
|
||||
// Save output
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, resultBuffer);
|
||||
// Cache the mask (transparent PNG) and original for effects re-apply
|
||||
const maskFilename = `${filename.replace(/\.[^.]+$/, "")}_mask.png`;
|
||||
const originalFilename = `${filename.replace(/\.[^.]+$/, "")}_original.png`;
|
||||
await writeFile(join(workspacePath, "output", maskFilename), transparentResult);
|
||||
await writeFile(join(workspacePath, "output", originalFilename), fileBuffer);
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
@@ -107,9 +137,14 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
// The mask (transparent PNG) is the main preview
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
|
||||
// Separate URLs for frontend CSS preview compositing
|
||||
maskUrl: `/api/v1/download/${jobId}/${encodeURIComponent(maskFilename)}`,
|
||||
originalUrl: `/api/v1/download/${jobId}/${encodeURIComponent(originalFilename)}`,
|
||||
originalSize: fileBuffer.length,
|
||||
processedSize: resultBuffer.length,
|
||||
processedSize: transparentResult.length,
|
||||
filename,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err, toolId: "remove-background" }, "Background removal failed");
|
||||
@@ -121,23 +156,124 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry so this tool can be used
|
||||
// as a step in automation pipelines (without progress callbacks).
|
||||
// ── Phase 2: Effects-only (no AI re-run) ─────────────────────────
|
||||
app.post(
|
||||
"/api/v1/tools/remove-background/effects",
|
||||
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
let settingsRaw: string | null = null;
|
||||
let bgImageBuffer: Buffer | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
for await (const part of parts) {
|
||||
if (part.type === "file" && part.fieldname === "backgroundImage") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of part.file) chunks.push(chunk);
|
||||
bgImageBuffer = Buffer.concat(chunks);
|
||||
} else if (part.type === "field" && part.fieldname === "settings") {
|
||||
settingsRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
return reply.status(400).send({
|
||||
error: "Failed to parse request",
|
||||
details: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
|
||||
if (!settingsRaw) {
|
||||
return reply.status(400).send({ error: "No settings provided" });
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = JSON.parse(settingsRaw);
|
||||
const { jobId, filename } = settings;
|
||||
|
||||
if (!jobId || !filename) {
|
||||
return reply.status(400).send({ error: "jobId and filename are required" });
|
||||
}
|
||||
|
||||
const workspacePath = getWorkspacePath(jobId);
|
||||
|
||||
const baseName = filename.replace(/\.[^.]+$/, "");
|
||||
const maskPath = join(workspacePath, "output", `${baseName}_mask.png`);
|
||||
const originalPath = join(workspacePath, "output", `${baseName}_original.png`);
|
||||
|
||||
const [maskBuffer, originalBuffer] = await Promise.all([
|
||||
readFile(maskPath),
|
||||
readFile(originalPath),
|
||||
]);
|
||||
|
||||
// Decode HEIC/HEIF background image if needed
|
||||
if (bgImageBuffer) {
|
||||
const bgValidation = await validateImageBuffer(bgImageBuffer);
|
||||
if (bgValidation.valid && bgValidation.format === "heif") {
|
||||
bgImageBuffer = await decodeHeic(bgImageBuffer);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply effects using cached mask + original
|
||||
const resultBuffer = await applyEffects(maskBuffer, originalBuffer, {
|
||||
backgroundType: settings.backgroundType,
|
||||
backgroundColor: settings.backgroundColor,
|
||||
gradientColor1: settings.gradientColor1,
|
||||
gradientColor2: settings.gradientColor2,
|
||||
gradientAngle: settings.gradientAngle,
|
||||
backgroundImageBuffer: bgImageBuffer ?? undefined,
|
||||
blurEnabled: settings.blurEnabled,
|
||||
blurIntensity: settings.blurIntensity,
|
||||
shadowEnabled: settings.shadowEnabled,
|
||||
shadowOpacity: settings.shadowOpacity,
|
||||
});
|
||||
|
||||
// Save the final output
|
||||
const outputFilename = `${baseName}_nobg.png`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, resultBuffer);
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
processedSize: resultBuffer.length,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "Effects processing failed");
|
||||
return reply.status(422).send({
|
||||
error: "Effects processing failed",
|
||||
details: err instanceof Error ? err.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── Pipeline/batch registry ──────────────────────────────────────
|
||||
registerToolProcessFn({
|
||||
toolId: "remove-background",
|
||||
settingsSchema: z.object({
|
||||
model: z.string().optional(),
|
||||
backgroundColor: z.string().optional(),
|
||||
}),
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const s = settings as { model?: string; backgroundColor?: string };
|
||||
const s = settings as z.infer<typeof settingsSchema>;
|
||||
const orientedBuffer = await autoOrient(inputBuffer);
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const resultBuffer = await removeBackground(orientedBuffer, join(workspacePath, "output"), {
|
||||
model: s.model,
|
||||
|
||||
const transparentResult = await removeBackground(
|
||||
orientedBuffer,
|
||||
join(workspacePath, "output"),
|
||||
{ model: s.model },
|
||||
);
|
||||
|
||||
const resultBuffer = await applyEffects(transparentResult, orientedBuffer, {
|
||||
backgroundType: s.backgroundType,
|
||||
backgroundColor: s.backgroundColor,
|
||||
gradientColor1: s.gradientColor1,
|
||||
gradientColor2: s.gradientColor2,
|
||||
gradientAngle: s.gradientAngle,
|
||||
blurEnabled: s.blurEnabled,
|
||||
blurIntensity: s.blurIntensity,
|
||||
shadowEnabled: s.shadowEnabled,
|
||||
shadowOpacity: s.shadowOpacity,
|
||||
});
|
||||
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_nobg.png`;
|
||||
return { buffer: resultBuffer, filename: outputFilename, contentType: "image/png" };
|
||||
},
|
||||
|
||||
@@ -106,6 +106,14 @@ export function App() {
|
||||
<Route path="/files" element={<FilesPage />} />
|
||||
<Route path="/fullscreen" element={<FullscreenGridPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
{/* Redirects: old color tools consolidated into adjust-colors */}
|
||||
<Route
|
||||
path="/brightness-contrast"
|
||||
element={<Navigate to="/adjust-colors" replace />}
|
||||
/>
|
||||
<Route path="/saturation" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/color-channels" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/color-effects" element={<Navigate to="/adjust-colors" replace />} />
|
||||
<Route path="/:toolId" element={<ToolPage />} />
|
||||
<Route path="/" element={<HomePage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -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<number | null>(null);
|
||||
@@ -155,7 +170,13 @@ export function ImageViewer({
|
||||
{/* Image area */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 flex items-center justify-center overflow-auto bg-muted/20 p-4"
|
||||
className="flex-1 flex items-center justify-center overflow-auto p-4"
|
||||
style={{
|
||||
background: bgPreview?.showCheckerboard
|
||||
? "repeating-conic-gradient(#d0d0d0 0% 25%, #f0f0f0 0% 50%) 0 0 / 20px 20px"
|
||||
: undefined,
|
||||
backgroundColor: !bgPreview?.showCheckerboard ? "hsl(var(--muted) / 0.2)" : undefined,
|
||||
}}
|
||||
>
|
||||
{loadError ? (
|
||||
<div className="flex flex-col items-center gap-2 text-center">
|
||||
@@ -164,6 +185,71 @@ export function ImageViewer({
|
||||
This format cannot be displayed in the browser
|
||||
</p>
|
||||
</div>
|
||||
) : bgPreview?.backgroundSrc || bgPreview?.containerBackground ? (
|
||||
/* Layered bg-removal preview: background layer + subject layer */
|
||||
<div
|
||||
className="relative rounded-sm overflow-hidden"
|
||||
style={{
|
||||
...(fitMode === "fit"
|
||||
? { maxWidth: "100%", maxHeight: "100%" }
|
||||
: { transform: `scale(${zoom / 100})`, transformOrigin: "center center" }),
|
||||
display: "inline-block",
|
||||
}}
|
||||
>
|
||||
{/* Background layer: blurred original or solid/gradient */}
|
||||
{bgPreview.backgroundSrc ? (
|
||||
<img
|
||||
src={bgPreview.backgroundSrc}
|
||||
alt="background"
|
||||
className="block select-none"
|
||||
style={{
|
||||
...(fitMode === "fit"
|
||||
? { maxWidth: "100%", maxHeight: "100%", objectFit: "contain" as const }
|
||||
: {}),
|
||||
filter: bgPreview.backgroundBlur || undefined,
|
||||
transition: "filter 0.15s ease",
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
/* Solid color or gradient - use subject dimensions */
|
||||
<img
|
||||
src={src}
|
||||
alt="background-sizer"
|
||||
className="block select-none invisible"
|
||||
style={
|
||||
fitMode === "fit"
|
||||
? { maxWidth: "100%", maxHeight: "100%", objectFit: "contain" as const }
|
||||
: {}
|
||||
}
|
||||
draggable={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Container background (color or gradient) behind subject but on top of bg image */}
|
||||
{bgPreview.containerBackground && !bgPreview.backgroundSrc && (
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ background: bgPreview.containerBackground }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Subject layer: transparent PNG with optional drop shadow */}
|
||||
<img
|
||||
ref={imgRef}
|
||||
src={src}
|
||||
alt={filename}
|
||||
onLoad={handleImageLoad}
|
||||
onError={handleImageError}
|
||||
className="absolute inset-0 w-full h-full select-none"
|
||||
style={{
|
||||
objectFit: "contain" as const,
|
||||
filter: bgPreview.dropShadow || undefined,
|
||||
transition: "filter 0.15s ease",
|
||||
}}
|
||||
draggable={false}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
ref={imgRef}
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { ChevronDown, ChevronRight, Download } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
type Tab = "basic" | "channels" | "effects";
|
||||
type Effect = "none" | "grayscale" | "sepia" | "invert";
|
||||
|
||||
interface ColorControlsProps {
|
||||
@@ -14,21 +13,25 @@ interface ColorControlsProps {
|
||||
}
|
||||
|
||||
export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorControlsProps) {
|
||||
const [tab, setTab] = useState<Tab>(() => {
|
||||
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<Effect>("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 && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<filter id="stirling-temp-tint-filter" colorInterpolationFilters="sRGB">
|
||||
<feColorMatrix
|
||||
type="matrix"
|
||||
values={`${1 + tempT * 0.15 + tintN * 0.1} 0 0 0 0 0 ${1 + tempT * 0.05 - tintN * 0.15} 0 0 0 0 0 ${1 - tempT * 0.15 + tintN * 0.1} 0 0 0 0 0 1 0`}
|
||||
/>
|
||||
</filter>
|
||||
</svg>
|
||||
)}
|
||||
{hasChannelChanges && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<filter id="stirling-channel-filter" colorInterpolationFilters="sRGB">
|
||||
@@ -94,52 +144,116 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
|
||||
</filter>
|
||||
</svg>
|
||||
)}
|
||||
{/* Tabs */}
|
||||
<div className="flex gap-1">
|
||||
{tabs.map((t) => (
|
||||
{sharpness > 0 && (
|
||||
<svg width="0" height="0" style={{ position: "absolute" }}>
|
||||
<filter id="stirling-sharpen-filter" colorInterpolationFilters="sRGB">
|
||||
<feConvolveMatrix
|
||||
order="3"
|
||||
preserveAlpha="true"
|
||||
kernelMatrix={`0 ${-sharpness / 100} 0 ${-sharpness / 100} ${1 + (4 * sharpness) / 100} ${-sharpness / 100} 0 ${-sharpness / 100} 0`}
|
||||
/>
|
||||
</filter>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Light section */}
|
||||
<SectionLabel>Light</SectionLabel>
|
||||
<div className="space-y-2">
|
||||
<SliderControl
|
||||
label="Brightness"
|
||||
value={brightness}
|
||||
onChange={setBrightness}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Contrast"
|
||||
value={contrast}
|
||||
onChange={setContrast}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Exposure"
|
||||
value={exposure}
|
||||
onChange={setExposure}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color section */}
|
||||
<SectionLabel>Color</SectionLabel>
|
||||
<div className="space-y-2">
|
||||
<SliderControl
|
||||
label="Saturation"
|
||||
value={saturation}
|
||||
onChange={setSaturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Temperature"
|
||||
value={temperature}
|
||||
onChange={setTemperature}
|
||||
min={-100}
|
||||
max={100}
|
||||
hint="cool / warm"
|
||||
/>
|
||||
<SliderControl
|
||||
label="Tint"
|
||||
value={tint}
|
||||
onChange={setTint}
|
||||
min={-100}
|
||||
max={100}
|
||||
hint="green / magenta"
|
||||
/>
|
||||
<SliderControl label="Hue" value={hue} onChange={setHue} min={-180} max={180} />
|
||||
</div>
|
||||
|
||||
{/* Detail section */}
|
||||
<SectionLabel>Detail</SectionLabel>
|
||||
<div className="space-y-2">
|
||||
<SliderControl
|
||||
label="Sharpness"
|
||||
value={sharpness}
|
||||
onChange={setSharpness}
|
||||
min={0}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Effects section */}
|
||||
<SectionLabel>Effects</SectionLabel>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex-1 text-xs py-1.5 rounded ${
|
||||
tab === t.id ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"
|
||||
key={e}
|
||||
onClick={() => setEffect(e)}
|
||||
className={`text-xs py-2 rounded capitalize transition-colors ${
|
||||
effect === e
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Basic Adjustments */}
|
||||
{tab === "basic" && (
|
||||
<div className="space-y-3">
|
||||
<SliderControl
|
||||
label="Brightness"
|
||||
value={brightness}
|
||||
onChange={setBrightness}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Contrast"
|
||||
value={contrast}
|
||||
onChange={setContrast}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
<SliderControl
|
||||
label="Saturation"
|
||||
value={saturation}
|
||||
onChange={setSaturation}
|
||||
min={-100}
|
||||
max={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Color Channels */}
|
||||
{tab === "channels" && (
|
||||
<div className="space-y-3">
|
||||
{/* Color Channels (expandable) */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChannelsOpen(!channelsOpen)}
|
||||
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground w-full"
|
||||
>
|
||||
{channelsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Color Channels
|
||||
{hasChannelChanges && <span className="ml-auto text-primary text-[10px]">modified</span>}
|
||||
</button>
|
||||
{channelsOpen && (
|
||||
<div className="space-y-2 pl-1">
|
||||
<SliderControl
|
||||
label="Red"
|
||||
value={red}
|
||||
@@ -167,37 +281,19 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Effects */}
|
||||
{tab === "effects" && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground">Color Effect</p>
|
||||
<div className="grid grid-cols-2 gap-1">
|
||||
{(["none", "grayscale", "sepia", "invert"] as const).map((e) => (
|
||||
<button
|
||||
type="button"
|
||||
key={e}
|
||||
onClick={() => setEffect(e)}
|
||||
className={`text-xs py-2 rounded capitalize transition-colors ${
|
||||
effect === e
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||
}`}
|
||||
>
|
||||
{e}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
{/* Reset */}
|
||||
{hasChanges && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBrightness(0);
|
||||
setContrast(0);
|
||||
setExposure(0);
|
||||
setSaturation(0);
|
||||
setTemperature(0);
|
||||
setTint(0);
|
||||
setHue(0);
|
||||
setSharpness(0);
|
||||
setRed(100);
|
||||
setGreen(100);
|
||||
setBlue(100);
|
||||
@@ -212,8 +308,9 @@ export function ColorControls({ toolId, onChange, onPreviewFilter }: ColorContro
|
||||
);
|
||||
}
|
||||
|
||||
// ── ColorSettings wrapper (handles processing + download) ─────────
|
||||
|
||||
interface ColorSettingsProps {
|
||||
/** The specific tool ID to use for processing */
|
||||
toolId: string;
|
||||
onPreviewFilter?: (filter: string) => void;
|
||||
}
|
||||
@@ -231,15 +328,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
progress,
|
||||
} = useToolProcessor(toolId);
|
||||
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({
|
||||
brightness: 0,
|
||||
contrast: 0,
|
||||
saturation: 0,
|
||||
red: 100,
|
||||
green: 100,
|
||||
blue: 100,
|
||||
effect: "none",
|
||||
});
|
||||
const [settings, setSettings] = useState<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
if (files.length > 1) {
|
||||
@@ -250,14 +339,11 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
const hasChanges =
|
||||
settings.brightness !== 0 ||
|
||||
settings.contrast !== 0 ||
|
||||
settings.saturation !== 0 ||
|
||||
settings.red !== 100 ||
|
||||
settings.green !== 100 ||
|
||||
settings.blue !== 100 ||
|
||||
settings.effect !== "none";
|
||||
const hasChanges = Object.entries(settings).some(([key, val]) => {
|
||||
if (key === "effect") return val !== "none";
|
||||
if (key === "red" || key === "green" || key === "blue") return val !== 100;
|
||||
return val !== 0;
|
||||
});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@@ -265,13 +351,11 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<form onSubmit={handleSubmit} className="space-y-3">
|
||||
<ColorControls toolId={toolId} onChange={setSettings} onPreviewFilter={onPreviewFilter} />
|
||||
|
||||
{/* Error */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
@@ -279,7 +363,6 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process */}
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
@@ -292,7 +375,7 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
data-testid={`${toolId}-submit`}
|
||||
data-testid="adjust-colors-submit"
|
||||
disabled={!hasFile || !hasChanges || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
@@ -300,12 +383,12 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && (
|
||||
{/* Download (single-file only - batch uses Download All ZIP in tool-page) */}
|
||||
{downloadUrl && files.length <= 1 && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid={`${toolId}-download`}
|
||||
data-testid="adjust-colors-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
@@ -316,7 +399,16 @@ export function ColorSettings({ toolId, onPreviewFilter }: ColorSettingsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Reusable slider control */
|
||||
// ── Shared sub-components ─────────────────────────────────────────
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
<div className="flex justify-between items-center">
|
||||
<label htmlFor={id} className={`text-xs ${color || "text-muted-foreground"}`}>
|
||||
{label}
|
||||
{hint && <span className="text-[10px] text-muted-foreground/60 ml-1">({hint})</span>}
|
||||
</label>
|
||||
<span className="text-xs font-mono text-foreground">{value}</span>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id={id}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Download, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Download } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
@@ -14,57 +16,124 @@ const SIZES = [
|
||||
];
|
||||
|
||||
export function FaviconSettings() {
|
||||
const { files, processing, error, setProcessing, setError } = useFileStore();
|
||||
const [downloadReady, setDownloadReady] = useState(false);
|
||||
const { files, error, setProcessing, setError } = useFileStore();
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [progress, setProgress] = useState({
|
||||
phase: "idle" as "idle" | "uploading" | "processing" | "complete",
|
||||
percent: 0,
|
||||
elapsed: 0,
|
||||
});
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(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 (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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.`}
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Generated Sizes</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">Generated Sizes (per image)</p>
|
||||
<div className="mt-1 space-y-0.5">
|
||||
{SIZES.map((s) => (
|
||||
<div key={s.name} className="flex justify-between text-xs text-foreground">
|
||||
@@ -78,21 +147,41 @@ export function FaviconSettings() {
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
data-testid="favicon-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{processing && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{processing ? "Generating..." : "Generate Favicons"}
|
||||
</button>
|
||||
{busy ? (
|
||||
<ProgressCard
|
||||
active={busy}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Generating Favicons"
|
||||
stage={
|
||||
progress.phase === "uploading"
|
||||
? "Uploading images..."
|
||||
: `Processing ${files.length} image${files.length !== 1 ? "s" : ""}...`
|
||||
}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="favicon-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFiles || busy}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Generate Favicons ({files.length} image{files.length !== 1 ? "s" : ""})
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadReady && (
|
||||
<p className="text-xs text-green-600 flex items-center gap-1">
|
||||
<Download className="h-3 w-3" /> ZIP downloaded successfully
|
||||
</p>
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download="favicons.zip"
|
||||
data-testid="favicon-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download Favicons ZIP
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 pt-1">
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<SubjectType>("people");
|
||||
const [quality, setQuality] = useState<Quality>("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<BackgroundType>("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<File | null>(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<string, unknown> = { model };
|
||||
if (bgColor) next.backgroundColor = bgColor;
|
||||
const next: Record<string, unknown> = { 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 (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
{/* Subject type */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">What's in the photo?</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
|
||||
{SUBJECT_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSubject(opt.value);
|
||||
if (opt.value !== "people") setIsPassport(false);
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1 py-2.5 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
subject === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<SectionLabel>Subject</SectionLabel>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{SUBJECT_OPTIONS.map((opt) => {
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSubject(opt.value);
|
||||
if (opt.value !== "people") setIsPassport(false);
|
||||
else setIsPassport(true);
|
||||
}}
|
||||
className={`flex flex-col items-center gap-1 py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
subject === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Passport checkbox - only for people */}
|
||||
{/* Passport checkbox - only for people, default ON */}
|
||||
{subject === "people" && (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
@@ -112,101 +189,586 @@ export function RemoveBgControls({ settings, onChange }: RemoveBgControlsProps)
|
||||
)}
|
||||
|
||||
{/* Quality */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Quality</p>
|
||||
<div className="grid grid-cols-3 gap-1.5 mt-1.5">
|
||||
{QUALITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
quality === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<SectionLabel>Quality</SectionLabel>
|
||||
<div className="grid grid-cols-3 gap-1.5">
|
||||
{QUALITY_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
onClick={() => setQuality(opt.value)}
|
||||
className={`py-2 px-2 rounded-lg border text-xs font-medium transition-colors ${
|
||||
quality === opt.value
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Background color - intuitive preset buttons */}
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Output Background</p>
|
||||
<div className="flex gap-1.5 mt-1.5 flex-wrap">
|
||||
{BG_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setBgColor(preset.color)}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
|
||||
bgColor === preset.color
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className="w-4 h-4 rounded-sm border border-border shrink-0"
|
||||
style={
|
||||
preset.preview === "checkerboard"
|
||||
? {
|
||||
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",
|
||||
}
|
||||
: { backgroundColor: preset.preview }
|
||||
}
|
||||
{/* Background */}
|
||||
<SectionLabel>Background</SectionLabel>
|
||||
<div className="space-y-2">
|
||||
{/* Type buttons */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
<BgTypeButton
|
||||
active={bgType === "transparent"}
|
||||
onClick={() => setBgType("transparent")}
|
||||
checkerboard
|
||||
label="Transparent"
|
||||
/>
|
||||
<BgTypeButton
|
||||
active={bgType === "color"}
|
||||
onClick={() => setBgType("color")}
|
||||
color={bgColor}
|
||||
label="Color"
|
||||
/>
|
||||
<BgTypeButton
|
||||
active={bgType === "gradient"}
|
||||
onClick={() => setBgType("gradient")}
|
||||
gradient={{ color1: gradColor1, color2: gradColor2 }}
|
||||
label="Gradient"
|
||||
/>
|
||||
<BgTypeButton
|
||||
active={bgType === "image"}
|
||||
onClick={() => setBgType("image")}
|
||||
label="Image"
|
||||
isImage
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color options */}
|
||||
{bgType === "color" && (
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{COLOR_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.color}
|
||||
type="button"
|
||||
onClick={() => setBgColor(preset.color)}
|
||||
className={`w-7 h-7 rounded border-2 transition-all ${
|
||||
bgColor === preset.color ? "border-primary scale-110" : "border-border"
|
||||
}`}
|
||||
style={{ backgroundColor: preset.color }}
|
||||
title={preset.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={bgColor}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
placeholder="#FF5500"
|
||||
className="flex-1 px-2 py-1 rounded border border-border bg-background text-xs text-foreground"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom color picker */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<input
|
||||
type="color"
|
||||
value={bgColor || "#ffffff"}
|
||||
onChange={(e) => setBgColor(e.target.value)}
|
||||
className="w-8 h-8 rounded border border-border cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={bgColor}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
{/* Gradient options */}
|
||||
{bgType === "gradient" && (
|
||||
<div className="space-y-2 pl-1">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{GRADIENT_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setGradColor1(preset.color1);
|
||||
setGradColor2(preset.color2);
|
||||
}}
|
||||
className={`w-7 h-7 rounded border-2 transition-all ${
|
||||
gradColor1 === preset.color1 && gradColor2 === preset.color2
|
||||
? "border-primary scale-110"
|
||||
: "border-border"
|
||||
}`}
|
||||
style={{
|
||||
background: `linear-gradient(180deg, ${preset.color1}, ${preset.color2})`,
|
||||
}}
|
||||
title={preset.label}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="color"
|
||||
value={gradColor1}
|
||||
onChange={(e) => setGradColor1(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-border cursor-pointer"
|
||||
title="Start color"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">to</span>
|
||||
<input
|
||||
type="color"
|
||||
value={gradColor2}
|
||||
onChange={(e) => setGradColor2(e.target.value)}
|
||||
className="w-7 h-7 rounded border border-border cursor-pointer"
|
||||
title="End color"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Direction</span>
|
||||
<span className="text-xs font-mono text-foreground">{gradAngle}°</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={360}
|
||||
value={gradAngle}
|
||||
onChange={(e) => setGradAngle(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image upload */}
|
||||
{bgType === "image" && (
|
||||
<div className="pl-1">
|
||||
{bgImageFile ? (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-foreground truncate flex-1">{bgImageFile.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBgImageFile(null)}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="flex items-center gap-2 px-3 py-2 rounded-lg border border-dashed border-border text-xs text-muted-foreground cursor-pointer hover:border-primary/50 hover:text-foreground transition-colors">
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
Choose background image
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*,.heic,.heif,.hif"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setBgImageFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Effects */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEffectsOpen(!effectsOpen)}
|
||||
className="flex items-center gap-1 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70 hover:text-foreground w-full pt-1"
|
||||
>
|
||||
{effectsOpen ? <ChevronDown className="h-3 w-3" /> : <ChevronRight className="h-3 w-3" />}
|
||||
Effects
|
||||
{(blurEnabled || shadowEnabled) && (
|
||||
<span className="ml-auto text-primary text-[10px] normal-case font-normal">active</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{effectsOpen && (
|
||||
<div className="space-y-3 pl-1">
|
||||
{/* Blur */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={blurEnabled}
|
||||
onChange={(e) => setBlurEnabled(e.target.checked)}
|
||||
className="rounded border-border accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Blur Background</span>
|
||||
</label>
|
||||
{blurEnabled && (
|
||||
<div className="mt-1.5 pl-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Intensity</span>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
|
||||
{blurIntensity}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={blurIntensity}
|
||||
onChange={(e) => setBlurIntensity(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Shadow */}
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shadowEnabled}
|
||||
onChange={(e) => setShadowEnabled(e.target.checked)}
|
||||
className="rounded border-border accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">Add Shadow</span>
|
||||
</label>
|
||||
{shadowEnabled && (
|
||||
<div className="mt-1.5 pl-5">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">Opacity</span>
|
||||
<span className="text-xs font-mono text-foreground tabular-nums w-8 text-right">
|
||||
{shadowOpacity}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
value={shadowOpacity}
|
||||
onChange={(e) => setShadowOpacity(Number(e.target.value))}
|
||||
className="w-full mt-0.5"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border text-xs font-medium transition-colors ${
|
||||
active
|
||||
? "border-primary bg-primary/10 text-primary"
|
||||
: "border-border text-muted-foreground hover:border-primary/50"
|
||||
}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<ImageIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<span className="w-4 h-4 rounded-sm border border-border shrink-0" style={swatchStyle} />
|
||||
)}
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<Record<string, unknown>>({});
|
||||
|
||||
const handleProcess = () => {
|
||||
processFiles(files, settings);
|
||||
};
|
||||
// Two-phase state: after Phase 1 (bg removal), store job info for Phase 2 (effects)
|
||||
const [bgJobId, setBgJobId] = useState<string | null>(null);
|
||||
const [bgFilename, setBgFilename] = useState<string | null>(null);
|
||||
const [bgOriginalUrl, setBgOriginalUrl] = useState<string | null>(null);
|
||||
const [effectsDownloadUrl, setEffectsDownloadUrl] = useState<string | null>(null);
|
||||
const [applyingEffects, setApplyingEffects] = useState(false);
|
||||
const [effectsError, setEffectsError] = useState<string | null>(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<string | null>(null);
|
||||
const bgImageFileRef = useRef<File | null>(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<string, unknown> = {
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<RemoveBgControls settings={settings} onChange={setSettings} />
|
||||
|
||||
{/* Error */}
|
||||
{/* Errors */}
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
{effectsError && <p className="text-xs text-red-500">{effectsError}</p>}
|
||||
|
||||
{/* Size info */}
|
||||
{originalSize != null && processedSize != null && !processing && (
|
||||
@@ -216,7 +778,7 @@ export function RemoveBgSettings() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Process button */}
|
||||
{/* Phase 1: Remove Background button */}
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
@@ -226,29 +788,44 @@ export function RemoveBgSettings() {
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
) : !bgRemoved ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="remove-background-submit"
|
||||
onClick={handleProcess}
|
||||
onClick={handleRemoveBg}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
Remove Background
|
||||
{files.length > 1 ? `Remove Background (${files.length} files)` : "Remove Background"}
|
||||
</button>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{/* Download */}
|
||||
{downloadUrl && !processing && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="remove-background-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
{/* Phase 2: Single smart download button */}
|
||||
{bgRemoved && files.length <= 1 && (
|
||||
<div className="space-y-2">
|
||||
{hasEffectsToApply ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="remove-background-download-effects"
|
||||
onClick={handleDownloadWithEffects}
|
||||
disabled={applyingEffects}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{applyingEffects ? "Rendering..." : "Download"}
|
||||
</button>
|
||||
) : (
|
||||
<a
|
||||
href={downloadUrl || ""}
|
||||
download
|
||||
data-testid="remove-background-download"
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium flex items-center justify-center gap-2 hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,10 +5,7 @@ const TOOL_SUGGESTIONS: Record<string, string[]> = {
|
||||
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"],
|
||||
|
||||
@@ -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<string, ToolRegistryEntry>([
|
||||
["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 }],
|
||||
|
||||
@@ -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<PreviewTransform | null>(null);
|
||||
const [previewFilter, setPreviewFilter] = useState<string>("");
|
||||
const [bgPreview, setBgPreview] = useState<BgPreviewState | null>(null);
|
||||
|
||||
const [cropCrop, setCropCrop] = useState<Crop>({
|
||||
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 (
|
||||
<ImageViewer
|
||||
src={displayUrl}
|
||||
filename={processedFileName}
|
||||
fileSize={processedSize ?? 0}
|
||||
bgPreview={bgPreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<BeforeAfterSlider
|
||||
beforeSrc={originalBlobUrl}
|
||||
@@ -460,6 +481,18 @@ export function ToolPage() {
|
||||
</Suspense>
|
||||
</div>
|
||||
|
||||
{/* Batch download — shown right after settings for easy access */}
|
||||
{entries.length > 1 && hasProcessed && batchZipBlob && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download All (ZIP)
|
||||
</button>
|
||||
)}
|
||||
|
||||
{hasProcessed && processedSize != null && (
|
||||
<ReviewPanel
|
||||
filename={processedFileName}
|
||||
@@ -540,21 +573,6 @@ export function ToolPage() {
|
||||
</div>
|
||||
|
||||
{renderSettingsContent()}
|
||||
|
||||
{/* Batch download */}
|
||||
{entries.length > 1 && hasProcessed && batchZipBlob && (
|
||||
<div className="space-y-2">
|
||||
<div className="border-t border-border pt-2" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDownloadAll}
|
||||
className="w-full py-2 rounded-lg bg-primary text-primary-foreground flex items-center justify-center gap-1.5 text-xs font-medium hover:bg-primary/90"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
Download All (ZIP)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Main area: image viewer */}
|
||||
|
||||
@@ -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<FileState>((set, get) => ({
|
||||
...updated[selectedIndex],
|
||||
processedUrl: url,
|
||||
processedPreviewUrl: previewUrl ?? null,
|
||||
processedFilename: null,
|
||||
status: "completed",
|
||||
};
|
||||
} else {
|
||||
@@ -287,6 +290,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
...updated[selectedIndex],
|
||||
processedUrl: null,
|
||||
processedPreviewUrl: null,
|
||||
processedFilename: null,
|
||||
status: "pending",
|
||||
};
|
||||
}
|
||||
@@ -314,6 +318,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
...e,
|
||||
processedUrl: null,
|
||||
processedPreviewUrl: null,
|
||||
processedFilename: null,
|
||||
processedSize: null,
|
||||
status: "pending" as const,
|
||||
error: null,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { Sharp, SharpenOptions } from "../types.js";
|
||||
|
||||
export async function sharpen(image: Sharp, options: SharpenOptions): Promise<Sharp> {
|
||||
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 });
|
||||
}
|
||||
@@ -104,3 +104,7 @@ export interface ColorChannelOptions {
|
||||
green: number; // 0-200
|
||||
blue: number; // 0-200
|
||||
}
|
||||
|
||||
export interface SharpenOptions {
|
||||
value: number; // 0 to 100
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <a> tag with download attribute (not a button that auto-triggers)
|
||||
await expect(downloadLink).toHaveAttribute("download", "favicons.zip");
|
||||
await expect(downloadLink).toHaveAttribute("href", /^blob:/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
|
After Width: | Height: | Size: 342 B |
Reference in New Issue
Block a user