refactor: improve tool processing, dropzone, seam carving, and format encoding

- Refactor use-tool-processor and use-pipeline-processor hooks
- Enhance dropzone component with improved UX
- Improve seam carving with better error handling and tests
- Add JXL format encoding support to format-encoders
- Update tool routes for consistent format handling
- Add dropzone unit tests
This commit is contained in:
SnapOtter
2026-05-11 21:57:40 +08:00
parent 656a8d3d44
commit b06906025c
25 changed files with 1368 additions and 436 deletions
+28
View File
@@ -103,3 +103,31 @@ export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4);
return Buffer.from(encoded);
}
export async function encodeJxl(inputBuffer: Buffer, quality?: number): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jxl-enc-in-${id}.png`);
const outputPath = join(tmpdir(), `jxl-enc-out-${id}.jxl`);
try {
const pngBuffer = await sharp(inputBuffer).png().toBuffer();
await writeFile(inputPath, pngBuffer);
try {
const q = String(quality ?? 75);
await execFileAsync("cjxl", [inputPath, outputPath, "-q", q], {
timeout: 120_000,
});
return await readFile(outputPath);
} catch {
/* cjxl not available, fall back to ImageMagick */
}
const cmd = await findMagickCmd();
const q = quality ? ["-quality", String(quality)] : [];
await execFileAsync(cmd, magickArgs(cmd, [inputPath, ...q, `jxl:${outputPath}`]), {
timeout: 120_000,
});
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
}
}
+20 -2
View File
@@ -30,7 +30,7 @@ import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
import { createWorkspace } from "../lib/workspace.js";
import { hasEffectivePermission } from "../permissions.js";
import { requireAuth } from "../plugins/auth.js";
import { type JobProgress, updateJobProgress } from "./progress.js";
import { type JobProgress, updateJobProgress, updateSingleFileProgress } from "./progress.js";
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
/** Schema for a single pipeline step. */
@@ -75,6 +75,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
let fileBuffer: Buffer | null = null;
let filename = "image";
let pipelineRaw: string | null = null;
let clientJobId: string | null = null;
// Parse multipart
try {
@@ -89,6 +90,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
filename = sanitizeFilename(part.filename ?? "image");
} else if (part.fieldname === "pipeline") {
pipelineRaw = part.value as string;
} else if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
}
} catch (err) {
@@ -215,10 +218,23 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
let currentBuffer = fileBuffer;
let currentFilename = filename;
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
const totalSteps = pipeline.steps.length;
const reportProgress = (percent: number, stage?: string) => {
if (!clientJobId) return;
updateSingleFileProgress({
jobId: clientJobId,
phase: "processing",
percent,
stage,
});
};
try {
for (let i = 0; i < pipeline.steps.length; i++) {
for (let i = 0; i < totalSteps; i++) {
const step = pipeline.steps[i];
const stepPercent = Math.round((i / totalSteps) * 90);
reportProgress(stepPercent, `Step ${i + 1}/${totalSteps}: ${step.toolId}`);
// Route content-aware resize to its dedicated tool
const resolvedToolId =
@@ -250,6 +266,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
throw new Error(`Step ${i + 1} (${step.toolId}): ${msg}`);
}
}
reportProgress(95, "Saving...");
} catch (err) {
const message = err instanceof Error ? err.message : "Pipeline processing failed";
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
+43 -2
View File
@@ -20,6 +20,7 @@ import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
import { computeTimeout } from "../lib/timeout.js";
import { getWorkerPool } from "../lib/worker-pool.js";
import { createWorkspace } from "../lib/workspace.js";
import { updateSingleFileProgress } from "./progress.js";
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
@@ -112,6 +113,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
let filename = "image";
let settingsRaw: string | null = null;
let fileId: string | null = null;
let clientJobId: string | null = null;
let fileCount = 0;
// Parse multipart parts
@@ -143,6 +145,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
if (part.fieldname === "fileId") {
fileId = part.value as string;
}
if (part.fieldname === "clientJobId") {
clientJobId = part.value as string;
}
}
}
} catch (err) {
@@ -167,6 +172,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// mutates fileBuffer into a larger intermediate PNG.
const uploadedSize = fileBuffer.length;
const reportProgress = (percent: number, stage?: string) => {
if (!clientJobId) return;
updateSingleFileProgress({
jobId: clientJobId,
phase: "processing",
percent,
stage,
});
};
reportProgress(5, "Validating...");
// Validate the uploaded image
const validation = await validateImageBuffer(fileBuffer, filename);
if (!validation.valid) {
@@ -178,6 +195,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// The decoded buffer is PNG, so update the filename extension to match.
const isHeif = validation.format === "heif";
if (isHeif) {
reportProgress(10, "Decoding HEIC...");
try {
fileBuffer = await decodeHeic(fileBuffer);
const ext = filename.match(/\.[^.]+$/)?.[0];
@@ -195,6 +213,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
// Pass the original file extension so RAW decoder can use the correct
// temp file suffix (e.g. .cr3, .nef) for format identification.
if (needsCliDecode(validation.format)) {
reportProgress(10, "Decoding...");
try {
const fileExt = filename.split(".").pop()?.toLowerCase();
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
@@ -225,6 +244,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
}
}
reportProgress(15, "Preparing...");
// Parse and validate settings
let settings: T;
try {
@@ -259,6 +280,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
try {
let result: { buffer: Buffer; filename: string; contentType: string };
reportProgress(20, "Processing...");
// Offload to worker thread for non-AI tools.
// Falls back to main-thread processing on any worker error.
// Disabled in test environments where worker_threads can't load .ts files.
@@ -299,6 +322,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
result = await config.process(processBuffer, settings, filename);
}
reportProgress(75, "Saving...");
// Add a tool-specific suffix to the filename so the download
// doesn't silently overwrite the user's original file.
// Skip if the tool already changed the filename (e.g. convert, split).
@@ -362,6 +387,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
]);
let previewUrl: string | undefined;
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
reportProgress(85, "Generating preview...");
try {
let previewInput = result.buffer;
// Sharp can't decode HEIC - use system decoder first
@@ -372,8 +398,21 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, previewBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Non-fatal - frontend will show the success card fallback
} catch (previewErr) {
request.log.warn(
{ previewErr, contentType: result.contentType, toolId: config.toolId },
"Failed to generate preview thumbnail, falling back to input buffer",
);
// Retry with the original input buffer (pre-processing) which
// was already validated and decoded during the intake phase.
try {
const fallbackBuffer = await sharp(fileBuffer).webp({ quality: 80 }).toBuffer();
const previewPath = join(workspacePath, "output", "preview.webp");
await writeFile(previewPath, fallbackBuffer);
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
} catch {
// Both attempts failed - frontend will use the upload preview as fallback
}
}
}
@@ -381,6 +420,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
const inputPath = join(workspacePath, "input", filename);
await writeFile(inputPath, fileBuffer);
reportProgress(95, "Finishing...");
// Auto-save to persistent file store when a fileId is provided
let savedFileId: string | undefined;
if (fileId) {
+5 -3
View File
@@ -8,6 +8,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -643,7 +644,7 @@ export function registerCollage(app: FastifyInstance) {
outputExt = "avif";
break;
case "jxl":
pipeline = pipeline.jxl({ quality: settings.quality });
pipeline = pipeline.png();
outputExt = "jxl";
break;
default:
@@ -653,18 +654,19 @@ export function registerCollage(app: FastifyInstance) {
}
const result = await pipeline.toBuffer();
const finalBuffer = outputExt === "jxl" ? await encodeJxl(result, settings.quality) : result;
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const filename = `collage.${outputExt}`;
const outputPath = join(workspacePath, "output", filename);
await writeFile(outputPath, result);
await writeFile(outputPath, finalBuffer);
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
processedSize: result.length,
processedSize: finalBuffer.length,
});
} catch (err) {
return reply.status(422).send({
+8 -1
View File
@@ -8,7 +8,13 @@ import { convert } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js";
import {
encodeBmp,
encodeIco,
encodeJp2,
encodeJxl,
encodeQoi,
} from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -56,6 +62,7 @@ const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Bu
bmp: encodeBmp,
ico: encodeIco,
jp2: encodeJp2,
jxl: encodeJxl,
qoi: encodeQoi,
};
+2 -1
View File
@@ -11,6 +11,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -213,7 +214,7 @@ export function registerEraseObject(app: FastifyInstance) {
outputBuffer = await encodeHeic(resultBuffer, quality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
outputBuffer = await encodeJxl(resultBuffer, quality);
finalFormat = "jxl";
} else {
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
+5 -2
View File
@@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
const settingsSchema = z.object({
@@ -145,10 +146,12 @@ export function registerImageToBase64(app: FastifyInstance) {
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
mimeType = "image/avif";
break;
case "jxl":
outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer();
case "jxl": {
const pngBuf = await pipeline.png().toBuffer();
outputBuffer = await encodeJxl(pngBuf, opts.quality);
mimeType = "image/jxl";
break;
}
default:
outputBuffer = await pipeline.toBuffer();
mimeType = detectMimeType(ext);
+10 -2
View File
@@ -8,6 +8,7 @@ import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic } from "../../lib/heic-converter.js";
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createToolRoute } from "../tool-factory.js";
@@ -40,9 +41,16 @@ const settingsSchema = z.object({
type Settings = z.infer<typeof settingsSchema>;
async function processImage(inputBuffer: Buffer, settings: Settings, filename: string) {
const isJxl = settings.format === "jxl";
const engineSettings = isJxl ? { ...settings, format: "png" as const } : settings;
const image = sharp(inputBuffer);
const result = await optimizeForWeb(image, settings);
const buffer = await result.toBuffer();
const result = await optimizeForWeb(image, engineSettings);
let buffer = await result.toBuffer();
if (isJxl) {
buffer = await encodeJxl(buffer, settings.quality);
}
const ext = extname(filename);
const baseName = ext ? filename.slice(0, -ext.length) : filename;
+5 -2
View File
@@ -9,6 +9,7 @@ import sharp from "sharp";
import { z } from "zod";
import { env } from "../../config.js";
import { formatZodErrors } from "../../lib/errors.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { encodeHeic } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -117,8 +118,10 @@ async function convertWithSharp(
return s.tiff().toBuffer();
case "gif":
return s.gif().toBuffer();
case "jxl":
return s.jxl({ quality }).toBuffer();
case "jxl": {
const pngBuf = await s.png().toBuffer();
return encodeJxl(pngBuf, quality);
}
case "heic":
case "heif": {
const pngBuf = await s.png().toBuffer();
+12 -3
View File
@@ -7,6 +7,7 @@ import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -31,7 +32,7 @@ function resolveOutputFormat(
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
webp: { sharpFormat: "webp", ext: ".webp" },
avif: { sharpFormat: "avif", ext: ".avif" },
jxl: { sharpFormat: "jxl", ext: ".jxl" },
jxl: { sharpFormat: "png", ext: ".jxl" },
};
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
}
@@ -150,7 +151,11 @@ export function registerSplit(app: FastifyInstance) {
}
const partBuffer = await pipeline.toBuffer();
archive.append(partBuffer, {
const finalBuffer =
settings.outputFormat === "jxl"
? await encodeJxl(partBuffer, settings.quality)
: partBuffer;
archive.append(finalBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`,
});
}
@@ -236,7 +241,11 @@ export function registerSplit(app: FastifyInstance) {
}
const partBuffer = await pipeline.toBuffer();
archive.append(partBuffer, {
const finalBuffer =
settings.outputFormat === "jxl"
? await encodeJxl(partBuffer, settings.quality)
: partBuffer;
archive.append(finalBuffer, {
name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`,
});
}
+7 -2
View File
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { ensureSharpCompat } from "../../lib/heic-converter.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -204,13 +205,17 @@ export function registerStitch(app: FastifyInstance) {
} else if (settings.format === "avif") {
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
} else if (settings.format === "jxl") {
pipeline = pipeline.jxl({ quality: settings.quality });
pipeline = pipeline.png();
} else {
pipeline = pipeline.png();
}
let result = await pipeline.toBuffer();
if (settings.format === "jxl") {
result = await encodeJxl(result, settings.quality);
}
if (settings.cornerRadius > 0) {
const meta = await sharp(result).metadata();
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
@@ -238,7 +243,7 @@ export function registerStitch(app: FastifyInstance) {
} else if (settings.format === "avif") {
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
} else if (settings.format === "jxl") {
result = await sharp(result).jxl({ quality: settings.quality }).toBuffer();
result = await encodeJxl(result, settings.quality);
}
}
+5 -2
View File
@@ -10,6 +10,7 @@ import { env } from "../../config.js";
import { resolveConcurrency } from "../../lib/env.js";
import { formatZodErrors } from "../../lib/errors.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -77,10 +78,12 @@ async function convertSvg(
buffer = await image.gif().toBuffer();
ext = "gif";
break;
case "jxl":
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
case "jxl": {
const pngBuf = await image.png().toBuffer();
buffer = await encodeJxl(pngBuf, settings.quality);
ext = "jxl";
break;
}
case "heif": {
const pngBuffer = await image.png().toBuffer();
buffer = await encodeHeic(pngBuffer, settings.quality);
+2 -1
View File
@@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { sanitizeFilename } from "../../lib/filename.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { encodeJxl } from "../../lib/format-encoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
@@ -186,7 +187,7 @@ export function registerUpscale(app: FastifyInstance) {
outputBuffer = await encodeHeic(result.buffer, outputQuality);
finalFormat = format;
} else if (format === "jxl") {
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
outputBuffer = await encodeJxl(result.buffer, outputQuality);
finalFormat = "jxl";
} else if (format === "avif") {
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
+72 -14
View File
@@ -1,5 +1,5 @@
import { FileImage, Upload } from "lucide-react";
import { type DragEvent, useCallback, useState } from "react";
import { FileImage, ImageUp, Upload } from "lucide-react";
import { type DragEvent, useCallback, useEffect, useState } from "react";
import { cn } from "@/lib/utils";
const IMAGE_EXTENSIONS = new Set([
@@ -134,6 +134,35 @@ export function Dropzone({
input.click();
};
useEffect(() => {
const handlePaste = (e: ClipboardEvent) => {
const clip = e.clipboardData;
if (!clip) return;
const files: File[] = [];
if (clip.files.length > 0) {
for (const file of clip.files) {
if (isImageFile(file)) files.push(file);
}
} else if (clip.items) {
for (const item of clip.items) {
if (item.kind === "file") {
const file = item.getAsFile();
if (file && isImageFile(file)) files.push(file);
}
}
}
if (files.length > 0) {
e.preventDefault();
onFiles?.(files);
}
};
document.addEventListener("paste", handlePaste);
return () => document.removeEventListener("paste", handlePaste);
}, [onFiles]);
const hasMultipleFiles = currentFiles.length > 1;
return (
@@ -143,31 +172,60 @@ export function Dropzone({
onDragOver={handleDrag}
onDragLeave={handleDrag}
onDrop={handleDrop}
onClick={handleClick}
className={cn(
"flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors mx-auto max-w-2xl w-full",
"group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer",
compact ? "min-h-0 h-full" : "min-h-[400px]",
isDragging
? "border-primary bg-primary/5"
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
? "border-primary bg-primary/10 scale-[1.01]"
: "border-border/60 bg-muted/20 hover:border-primary/40 hover:bg-muted/40",
)}
>
<div className={cn("flex flex-col items-center", compact ? "gap-2 p-4" : "gap-4 p-8")}>
<div className="text-3xl font-bold text-muted-foreground/30">
<span className="text-primary/30">SnapOtter</span>
<div className={cn("flex flex-col items-center", compact ? "gap-2 p-4" : "gap-5 p-8")}>
<div
className={cn(
"rounded-2xl bg-primary/8 p-4 transition-colors duration-200",
isDragging ? "bg-primary/15" : "group-hover:bg-primary/12",
)}
>
<ImageUp
className={cn(
"transition-all duration-200",
compact ? "h-8 w-8" : "h-10 w-10",
isDragging ? "text-primary" : "text-primary/50 group-hover:text-primary/70",
)}
strokeWidth={1.5}
/>
</div>
<div className="flex flex-col items-center gap-1.5">
<p className={cn("font-medium", compact ? "text-sm" : "text-base", "text-foreground/80")}>
Drop your images here
</p>
<p className="text-sm text-muted-foreground/70">
click anywhere to browse, or paste from clipboard
</p>
</div>
<button
type="button"
onClick={handleClick}
className="flex items-center gap-2 px-6 py-2.5 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium"
onClick={(e) => {
e.stopPropagation();
handleClick();
}}
className={cn(
"flex items-center gap-2 rounded-lg bg-primary text-primary-foreground transition-all duration-200 text-sm font-medium shadow-sm",
compact ? "px-5 py-2" : "px-8 py-3",
"hover:bg-primary/90 hover:shadow-md active:scale-[0.98]",
)}
>
<Upload className="h-4 w-4" />
Upload from computer
Upload
</button>
<p className="text-sm text-muted-foreground">Drop files here or click the upload button</p>
<p className="text-xs text-muted-foreground/50">
PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats
</p>
{/* Show file count badge and list when multiple files are dropped */}
{hasMultipleFiles && (
<div className="flex flex-col items-center gap-2 mt-2">
<div className="flex flex-col items-center gap-2 mt-1">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary/10 text-primary text-xs font-medium">
<FileImage className="h-3.5 w-3.5" />
{currentFiles.length} files selected
@@ -51,7 +51,12 @@ export function MultiImageViewer() {
hasProcessed && currentEntry.processedUrl
? canBrowserPreview(currentEntry.processedUrl)
: false;
const displayUrl = currentEntry.processedPreviewUrl ?? currentEntry.processedUrl;
const processedRenderable =
currentEntry.processedUrl && canBrowserPreview(currentEntry.processedUrl)
? currentEntry.processedUrl
: null;
const displayUrl =
currentEntry.processedPreviewUrl ?? processedRenderable ?? currentEntry.blobUrl;
const processedFilename = currentEntry.processedUrl
? decodeURIComponent(currentEntry.processedUrl.split("/").pop() ?? "processed")
@@ -77,7 +82,10 @@ export function MultiImageViewer() {
</button>
)}
<div className="w-full h-full min-h-0">
{hasProcessed && !isPreviewable && !currentEntry.processedPreviewUrl ? (
{hasProcessed &&
!isPreviewable &&
!currentEntry.processedPreviewUrl &&
!currentEntry.blobUrl ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center p-8">
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />
@@ -12,9 +12,6 @@ interface ProgressCardProps {
export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) {
if (!active) return null;
// No real-time server progress: non-AI tools sit at 100% while the server works
const isIndeterminate = phase === "processing" && percent >= 100;
const icon =
phase === "uploading" ? (
<Upload className="h-4 w-4 text-primary" />
@@ -22,8 +19,7 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
<Loader2 className="h-4 w-4 text-primary animate-spin" />
);
const slowHint = phase === "processing" && elapsed >= 10 ? "This may take a moment" : undefined;
const sublabel = [stage, slowHint, `${elapsed}s`].filter(Boolean).join(" \u00b7 ");
const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · ");
return (
<div className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5">
@@ -41,7 +37,7 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
</div>
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
<div
className={`h-full bg-primary rounded-full transition-all duration-500 ease-out ${isIndeterminate ? "animate-pulse" : ""}`}
className="h-full bg-primary rounded-full transition-all duration-500 ease-out"
style={{ width: `${Math.min(100, percent)}%` }}
/>
</div>
@@ -2,6 +2,18 @@ import { CheckCircle2, Loader2, XCircle } from "lucide-react";
import { useEffect, useRef } from "react";
import type { FileEntry } from "@/stores/file-store";
const BROWSER_IMG_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]);
function thumbnailSrc(entry: FileEntry): string {
if (entry.processedPreviewUrl) return entry.processedPreviewUrl;
if (entry.processedUrl) {
if (entry.processedUrl.startsWith("blob:")) return entry.processedUrl;
const ext = decodeURIComponent(entry.processedUrl).split(".").pop()?.toLowerCase() ?? "";
if (BROWSER_IMG_EXTS.has(ext)) return entry.processedUrl;
}
return entry.blobUrl;
}
interface ThumbnailStripProps {
entries: FileEntry[];
selectedIndex: number;
@@ -50,7 +62,7 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
</div>
) : (
<img
src={entry.processedPreviewUrl ?? entry.processedUrl ?? entry.blobUrl}
src={thumbnailSrc(entry)}
alt={entry.file.name}
className="w-full h-full object-cover"
draggable={false}
+178 -209
View File
@@ -5,7 +5,7 @@ import { ProgressCard } from "@/components/common/progress-card";
import { useToolProcessor } from "@/hooks/use-tool-processor";
import { useFileStore } from "@/stores/file-store";
type ResizeTab = "presets" | "custom" | "scale";
type ResizeTab = "presets" | "custom" | "scale" | "content-aware";
type FitMode = "cover" | "contain" | "fill";
const FIT_LABELS: Record<FitMode, string> = {
@@ -42,7 +42,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
const [fit, setFit] = useState<FitMode>("cover");
const [lockAspect, setLockAspect] = useState(true);
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
const [contentAware, setContentAware] = useState(false);
const contentAware = tab === "content-aware";
const [protectFaces, setProtectFaces] = useState(false);
const [blurRadius, setBlurRadius] = useState(4);
const [sobelThreshold, setSobelThreshold] = useState(2);
@@ -58,17 +58,14 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
if (initialSettings.fit != null) setFit(initialSettings.fit as FitMode);
if (initialSettings.withoutEnlargement != null)
setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement));
if (initialSettings.contentAware != null)
setContentAware(Boolean(initialSettings.contentAware));
if (initialSettings.protectFaces != null)
setProtectFaces(Boolean(initialSettings.protectFaces));
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
if (initialSettings.sobelThreshold != null)
setSobelThreshold(Number(initialSettings.sobelThreshold));
if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square));
// Infer tab from settings
if (initialSettings.percentage != null) setTab("scale");
else if (initialSettings.contentAware) setTab("custom");
if (initialSettings.contentAware) setTab("content-aware");
else if (initialSettings.percentage != null) setTab("scale");
}, [initialSettings]);
const onChangeRef = useRef(onChange);
@@ -104,7 +101,6 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
percentage,
fit,
withoutEnlargement,
contentAware,
protectFaces,
blurRadius,
sobelThreshold,
@@ -183,218 +179,191 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
return (
<div className="space-y-4">
{/* Standard resize tabs */}
{!contentAware && (
<>
{/* Tab selector */}
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
</button>
<button
type="button"
onClick={() => setTab("presets")}
className={tabClass("presets")}
>
Presets
</button>
</div>
</div>
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
<div className="space-y-1">
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
const key = `${preset.platform}-${preset.name}`;
const isSelected = selectedPreset === key;
return (
<button
key={key}
type="button"
onClick={() => handlePreset(preset)}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
}`}
>
<span>{preset.name}</span>
<span className="text-xs tabular-nums">
{preset.width} x {preset.height}
</span>
</button>
);
})}
</div>
</div>
))}
{enlargementCheckbox}
</div>
)}
{/* Custom Size tab */}
{tab === "custom" && (
<div className="space-y-3">
{dimensionInputs}
{/* Fit mode */}
<div>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFit(f)}
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
{FIT_LABELS[f]}
</button>
))}
</div>
</div>
{enlargementCheckbox}
</div>
)}
{/* Scale tab */}
{tab === "scale" && (
<div className="space-y-3">
<div>
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
Scale (%)
</label>
<input
id="resize-scale"
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex gap-1">
{[25, 50, 75].map((pct) => (
<button
key={pct}
type="button"
onClick={() => setPercentage(String(pct))}
className={`flex-1 text-xs py-1.5 rounded ${
percentage === String(pct)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{pct}%
</button>
))}
</div>
</div>
)}
</>
)}
{/* Content-aware section - positioned below standard resize */}
<div className="border-t border-border pt-3">
<div className="flex items-center justify-between">
<div>
<span className="text-xs font-medium text-muted-foreground">Content-aware</span>
</div>
{/* Tab selector */}
<div>
<div className="flex gap-1">
<button type="button" onClick={() => setTab("custom")} className={tabClass("custom")}>
Custom Size
</button>
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
Scale
</button>
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
Presets
</button>
<button
type="button"
role="switch"
aria-checked={contentAware}
onClick={() => setContentAware(!contentAware)}
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
contentAware ? "bg-primary" : "bg-muted"
}`}
onClick={() => setTab("content-aware")}
className={tabClass("content-aware")}
>
<span
className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${
contentAware ? "translate-x-3.5" : "translate-x-0.5"
}`}
/>
Content-Aware
</button>
</div>
</div>
{/* Content-aware options (expanded when toggled) */}
{contentAware && (
<div className="mt-3 space-y-3">
{/* Dimensions */}
{dimensionInputs}
{/* Square mode */}
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={squareMode}
onChange={(e) => setSquareMode(e.target.checked)}
className="rounded"
/>
Resize to square
</label>
{/* Face protection */}
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={protectFaces}
onChange={(e) => setProtectFaces(e.target.checked)}
className="rounded"
/>
Protect faces
</label>
{/* Blur radius */}
<div>
<div className="flex items-center justify-between">
<label htmlFor="blur-radius" className="text-xs text-muted-foreground">
Smoothing
</label>
<span className="text-xs tabular-nums text-muted-foreground">{blurRadius}</span>
{/* Presets tab */}
{tab === "presets" && (
<div className="space-y-3 max-h-[50vh] overflow-y-auto pr-1">
{platforms.map((platform) => (
<div key={platform}>
<p className="text-xs font-medium text-muted-foreground mb-1.5">{platform}</p>
<div className="space-y-1">
{SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => {
const key = `${preset.platform}-${preset.name}`;
const isSelected = selectedPreset === key;
return (
<button
key={key}
type="button"
onClick={() => handlePreset(preset)}
className={`w-full flex items-center justify-between px-2.5 py-1.5 rounded border text-sm transition-colors ${
isSelected
? "border-primary bg-primary/10 text-foreground"
: "border-border text-muted-foreground hover:border-primary/50 hover:text-foreground"
}`}
>
<span>{preset.name}</span>
<span className="text-xs tabular-nums">
{preset.width} x {preset.height}
</span>
</button>
);
})}
</div>
<input
id="blur-radius"
type="range"
min={0}
max={20}
value={blurRadius}
onChange={(e) => setBlurRadius(Number(e.target.value))}
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
/>
</div>
))}
{/* Sobel threshold */}
<div>
<div className="flex items-center justify-between">
<label htmlFor="sobel-threshold" className="text-xs text-muted-foreground">
Edge sensitivity
</label>
<span className="text-xs tabular-nums text-muted-foreground">{sobelThreshold}</span>
</div>
<input
id="sobel-threshold"
type="range"
min={1}
max={20}
value={sobelThreshold}
onChange={(e) => setSobelThreshold(Number(e.target.value))}
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
/>
{enlargementCheckbox}
</div>
)}
{/* Custom Size tab */}
{tab === "custom" && (
<div className="space-y-3">
{dimensionInputs}
{/* Fit mode */}
<div>
<p className="text-xs text-muted-foreground">Fit Mode</p>
<div className="flex gap-1 mt-1">
{(Object.keys(FIT_LABELS) as FitMode[]).map((f) => (
<button
key={f}
type="button"
onClick={() => setFit(f)}
className={`flex-1 text-xs py-1.5 rounded ${fit === f ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground"}`}
>
{FIT_LABELS[f]}
</button>
))}
</div>
</div>
)}
</div>
{enlargementCheckbox}
</div>
)}
{/* Scale tab */}
{tab === "scale" && (
<div className="space-y-3">
<div>
<label htmlFor="resize-scale" className="text-xs text-muted-foreground">
Scale (%)
</label>
<input
id="resize-scale"
type="number"
value={percentage}
onChange={(e) => setPercentage(e.target.value)}
min={1}
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
/>
</div>
<div className="flex gap-1">
{[25, 50, 75].map((pct) => (
<button
key={pct}
type="button"
onClick={() => setPercentage(String(pct))}
className={`flex-1 text-xs py-1.5 rounded ${
percentage === String(pct)
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground"
}`}
>
{pct}%
</button>
))}
</div>
</div>
)}
{/* Content-aware tab */}
{contentAware && (
<div className="space-y-3">
{dimensionInputs}
{/* Square mode */}
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={squareMode}
onChange={(e) => setSquareMode(e.target.checked)}
className="rounded"
/>
Resize to square
</label>
{/* Face protection */}
<label className="flex items-center gap-2 text-xs text-foreground">
<input
type="checkbox"
checked={protectFaces}
onChange={(e) => setProtectFaces(e.target.checked)}
className="rounded"
/>
Protect faces
</label>
{/* Blur radius */}
<div>
<div className="flex items-center justify-between">
<label htmlFor="blur-radius" className="text-xs text-muted-foreground">
Smoothing
</label>
<span className="text-xs tabular-nums text-muted-foreground">{blurRadius}</span>
</div>
<input
id="blur-radius"
type="range"
min={0}
max={20}
value={blurRadius}
onChange={(e) => setBlurRadius(Number(e.target.value))}
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
/>
</div>
{/* Sobel threshold */}
<div>
<div className="flex items-center justify-between">
<label htmlFor="sobel-threshold" className="text-xs text-muted-foreground">
Edge sensitivity
</label>
<span className="text-xs tabular-nums text-muted-foreground">{sobelThreshold}</span>
</div>
<input
id="sobel-threshold"
type="range"
min={1}
max={20}
value={sobelThreshold}
onChange={(e) => setSobelThreshold(Number(e.target.value))}
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
/>
</div>
</div>
)}
</div>
);
}
+49 -27
View File
@@ -26,6 +26,8 @@ const IDLE_PROGRESS: PipelineProgress = {
elapsed: 0,
};
const UPLOAD_WEIGHT = 15;
export function usePipelineProcessor() {
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
useFileStore();
@@ -35,13 +37,11 @@ export function usePipelineProcessor() {
const xhrRef = useRef<XMLHttpRequest | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const abortRef = useRef<AbortController | null>(null);
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Clean up on unmount
useEffect(() => {
return () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) eventSourceRef.current.close();
if (xhrRef.current) xhrRef.current.abort();
if (abortRef.current) abortRef.current.abort();
@@ -50,12 +50,9 @@ export function usePipelineProcessor() {
const processSingle = useCallback(
(file: File, steps: PipelineStep[]) => {
// Capture the file index at request time so results are written
// to the correct entry even if the user navigates away.
const capturedIndex = useFileStore.getState().selectedIndex;
setError(null);
// Mark the target entry as processing and clear any old result
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: null,
processedPreviewUrl: null,
@@ -66,7 +63,6 @@ export function usePipelineProcessor() {
setProcessing(true);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
// Start elapsed timer
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({
@@ -75,7 +71,40 @@ export function usePipelineProcessor() {
}));
}, 1000);
// Build pipeline payload
const clientJobId = generateId();
// Open SSE for real-time progress from the server
try {
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
if (typeof data.percent === "number") {
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
setProgress((prev) => ({
...prev,
phase: "processing",
percent: Math.max(prev.percent, scaled),
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
};
} catch {
// EventSource creation failed -- proceed without SSE
}
const pipeline = {
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
};
@@ -83,17 +112,13 @@ export function usePipelineProcessor() {
const formData = new FormData();
formData.append("file", file);
formData.append("pipeline", JSON.stringify(pipeline));
formData.append("clientJobId", clientJobId);
// Use XHR for upload progress tracking
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Pipeline runs multiple steps sequentially, allow up to 10 minutes
xhr.timeout = 600_000;
// Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95%
const UPLOAD_WEIGHT = 40;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT;
@@ -111,23 +136,14 @@ export function usePipelineProcessor() {
percent: UPLOAD_WEIGHT,
stage: "Processing...",
}));
// Gradually fill from upload weight to 95% over ~45s
const start = UPLOAD_WEIGHT;
const target = 95;
const step = (target - start) / 90; // 90 ticks over ~45s
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
const next = Math.min(target, prev.percent + step);
return { ...prev, percent: next };
});
}, 500);
};
xhr.onload = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
if (xhr.status >= 200 && xhr.status < 300) {
try {
@@ -166,7 +182,10 @@ export function usePipelineProcessor() {
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Network error - check your connection");
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -174,7 +193,10 @@ export function usePipelineProcessor() {
xhr.ontimeout = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Request timed out - the server may be overloaded. Try again.");
setProcessing(false);
setProgress(IDLE_PROGRESS);
+66 -121
View File
@@ -27,13 +27,10 @@ const IDLE_PROGRESS: ToolProgress = {
elapsed: 0,
};
// AI tools that go through Python/bridge.ts and can emit SSE progress.
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
// AI tools return 202 and deliver results via SSE (not XHR response).
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
// Tools that take a few seconds (not instant like Sharp, not minutes like AI).
// Uses a smoother progress: upload 0-40%, then a gradual fill during processing.
const MEDIUM_TOOLS = new Set(["content-aware-resize", "convert"]);
const UPLOAD_WEIGHT = 15;
export function useToolProcessor(toolId: string) {
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
@@ -47,15 +44,12 @@ export function useToolProcessor(toolId: string) {
const abortRef = useRef<AbortController | null>(null);
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
const isMediumTool = MEDIUM_TOOLS.has(toolId);
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
// Clean up on unmount
useEffect(() => {
return () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) eventSourceRef.current.close();
if (xhrRef.current) xhrRef.current.abort();
if (abortRef.current) abortRef.current.abort();
@@ -69,13 +63,10 @@ export function useToolProcessor(toolId: string) {
return;
}
// Capture the file index at request time so results are written
// to the correct entry even if the user navigates away.
const capturedIndex = useFileStore.getState().selectedIndex;
setError(null);
setWarning(null);
// Mark the target entry as processing and clear any old result
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: null,
processedPreviewUrl: null,
@@ -86,7 +77,6 @@ export function useToolProcessor(toolId: string) {
setProcessing(true);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
// Start elapsed timer
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({
@@ -95,80 +85,76 @@ export function useToolProcessor(toolId: string) {
}));
}, 1000);
// Generate client job ID for SSE correlation
const clientJobId = generateId();
let asyncMode = false;
// For AI tools, open SSE before uploading
if (isAiTool) {
try {
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
eventSourceRef.current = es;
// Open SSE for real-time progress from the server (all tools)
try {
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
if (data.phase === "complete" && data.result) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
processedFilename: null,
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (data.phase === "failed" && asyncMode) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (typeof data.percent === "number") {
const scaled = 15 + (data.percent / 100) * 85;
setProgress((prev) => ({
...prev,
phase: "processing",
percent: Math.max(prev.percent, scaled),
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
if (!asyncMode) {
// AI tools deliver results via SSE (they return 202 from the XHR)
if (data.phase === "complete" && data.result) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
processedFilename: null,
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
};
} catch {
// EventSource creation failed -- proceed without SSE
}
if (data.phase === "failed" && asyncMode) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (typeof data.percent === "number") {
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
setProgress((prev) => ({
...prev,
phase: "processing",
percent: Math.max(prev.percent, scaled),
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
if (!asyncMode) {
es.close();
eventSourceRef.current = null;
}
};
} catch {
// EventSource creation failed -- proceed without SSE
}
// Build form data - extract any File objects from settings before JSON serialization
// Build form data
const cleanSettings = { ...settings };
const bgImageFile = cleanSettings._bgImageFile as File | undefined;
delete cleanSettings._bgImageFile;
@@ -179,27 +165,17 @@ export function useToolProcessor(toolId: string) {
if (bgImageFile) {
formData.append("backgroundImage", bgImageFile);
}
if (isAiTool) {
formData.append("clientJobId", clientJobId);
}
formData.append("clientJobId", clientJobId);
// If this file came from the Files page, include its ID for version tracking
const capturedEntry = useFileStore.getState().entries[capturedIndex];
if (capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
}
// Use XHR for upload progress tracking
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Timeout: 2 min for fast tools, 5 min for medium (seam carving), 10 min for AI
xhr.timeout = isAiTool ? 600_000 : isMediumTool ? 300_000 : 120_000;
// For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven)
// For medium tools: upload = 0-40%, processing = 40-95% (gradual fill)
// For fast tools: upload = 0-100%, processing = brief 100% hold
const UPLOAD_WEIGHT = isAiTool ? 15 : isMediumTool ? 40 : 100;
xhr.timeout = isAiTool ? 600_000 : 120_000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
@@ -216,36 +192,8 @@ export function useToolProcessor(toolId: string) {
...prev,
phase: "processing",
percent: UPLOAD_WEIGHT,
stage: isAiTool ? "Starting..." : "Processing...",
stage: "Processing...",
}));
// Medium tools: gradually fill from upload weight to 95% over ~45s
if (isMediumTool) {
const start = UPLOAD_WEIGHT;
const target = 95;
const step = (target - start) / 90; // 90 ticks over ~45s
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
const next = Math.min(target, prev.percent + step);
return { ...prev, percent: next };
});
}, 500);
}
// AI tools: asymptotic fill during long processing gaps.
// Slowly creeps toward 88% so the bar never stalls visually.
// Real SSE events always win via Math.max in the handler.
if (isAiTool) {
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
const remaining = 88 - prev.percent;
if (remaining <= 0.5) return prev;
return { ...prev, percent: prev.percent + remaining * 0.015 };
});
}, 1000);
}
};
xhr.onload = () => {
@@ -255,7 +203,6 @@ export function useToolProcessor(toolId: string) {
}
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -299,7 +246,6 @@ export function useToolProcessor(toolId: string) {
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -311,7 +257,6 @@ export function useToolProcessor(toolId: string) {
xhr.ontimeout = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -327,7 +272,7 @@ export function useToolProcessor(toolId: string) {
});
xhr.send(formData);
},
[toolId, isAiTool, isMediumTool, setProcessing, setError, toolName],
[toolId, isAiTool, setProcessing, setError, toolName],
);
const processAllFiles = useCallback(