mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -103,3 +103,31 @@ export async function encodeQoi(inputBuffer: Buffer): Promise<Buffer> {
|
|||||||
const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4);
|
const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4);
|
||||||
return Buffer.from(encoded);
|
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(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js";
|
|||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
import { hasEffectivePermission } from "../permissions.js";
|
import { hasEffectivePermission } from "../permissions.js";
|
||||||
import { requireAuth } from "../plugins/auth.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";
|
import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js";
|
||||||
|
|
||||||
/** Schema for a single pipeline step. */
|
/** Schema for a single pipeline step. */
|
||||||
@@ -75,6 +75,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let pipelineRaw: string | null = null;
|
let pipelineRaw: string | null = null;
|
||||||
|
let clientJobId: string | null = null;
|
||||||
|
|
||||||
// Parse multipart
|
// Parse multipart
|
||||||
try {
|
try {
|
||||||
@@ -89,6 +90,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
filename = sanitizeFilename(part.filename ?? "image");
|
filename = sanitizeFilename(part.filename ?? "image");
|
||||||
} else if (part.fieldname === "pipeline") {
|
} else if (part.fieldname === "pipeline") {
|
||||||
pipelineRaw = part.value as string;
|
pipelineRaw = part.value as string;
|
||||||
|
} else if (part.fieldname === "clientJobId") {
|
||||||
|
clientJobId = part.value as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -215,10 +218,23 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
let currentBuffer = fileBuffer;
|
let currentBuffer = fileBuffer;
|
||||||
let currentFilename = filename;
|
let currentFilename = filename;
|
||||||
const stepResults: Array<{ step: number; toolId: string; size: number }> = [];
|
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 {
|
try {
|
||||||
for (let i = 0; i < pipeline.steps.length; i++) {
|
for (let i = 0; i < totalSteps; i++) {
|
||||||
const step = pipeline.steps[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
|
// Route content-aware resize to its dedicated tool
|
||||||
const resolvedToolId =
|
const resolvedToolId =
|
||||||
@@ -250,6 +266,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise<void
|
|||||||
throw new Error(`Step ${i + 1} (${step.toolId}): ${msg}`);
|
throw new Error(`Step ${i + 1} (${step.toolId}): ${msg}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportProgress(95, "Saving...");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : "Pipeline processing failed";
|
const message = err instanceof Error ? err.message : "Pipeline processing failed";
|
||||||
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
trackEvent(request, ANALYTICS_EVENTS.PIPELINE_EXECUTED, {
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { decompressSvgz, sanitizeSvg } from "../lib/svg-sanitize.js";
|
|||||||
import { computeTimeout } from "../lib/timeout.js";
|
import { computeTimeout } from "../lib/timeout.js";
|
||||||
import { getWorkerPool } from "../lib/worker-pool.js";
|
import { getWorkerPool } from "../lib/worker-pool.js";
|
||||||
import { createWorkspace } from "../lib/workspace.js";
|
import { createWorkspace } from "../lib/workspace.js";
|
||||||
|
import { updateSingleFileProgress } from "./progress.js";
|
||||||
|
|
||||||
export interface ToolRouteConfig<T> {
|
export interface ToolRouteConfig<T> {
|
||||||
/** Unique tool identifier, used as the URL path segment. */
|
/** 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 filename = "image";
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let fileId: string | null = null;
|
let fileId: string | null = null;
|
||||||
|
let clientJobId: string | null = null;
|
||||||
let fileCount = 0;
|
let fileCount = 0;
|
||||||
|
|
||||||
// Parse multipart parts
|
// Parse multipart parts
|
||||||
@@ -143,6 +145,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
if (part.fieldname === "fileId") {
|
if (part.fieldname === "fileId") {
|
||||||
fileId = part.value as string;
|
fileId = part.value as string;
|
||||||
}
|
}
|
||||||
|
if (part.fieldname === "clientJobId") {
|
||||||
|
clientJobId = part.value as string;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -167,6 +172,18 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
// mutates fileBuffer into a larger intermediate PNG.
|
// mutates fileBuffer into a larger intermediate PNG.
|
||||||
const uploadedSize = fileBuffer.length;
|
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
|
// Validate the uploaded image
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
if (!validation.valid) {
|
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.
|
// The decoded buffer is PNG, so update the filename extension to match.
|
||||||
const isHeif = validation.format === "heif";
|
const isHeif = validation.format === "heif";
|
||||||
if (isHeif) {
|
if (isHeif) {
|
||||||
|
reportProgress(10, "Decoding HEIC...");
|
||||||
try {
|
try {
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
const ext = filename.match(/\.[^.]+$/)?.[0];
|
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
|
// Pass the original file extension so RAW decoder can use the correct
|
||||||
// temp file suffix (e.g. .cr3, .nef) for format identification.
|
// temp file suffix (e.g. .cr3, .nef) for format identification.
|
||||||
if (needsCliDecode(validation.format)) {
|
if (needsCliDecode(validation.format)) {
|
||||||
|
reportProgress(10, "Decoding...");
|
||||||
try {
|
try {
|
||||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
const fileExt = filename.split(".").pop()?.toLowerCase();
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
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
|
// Parse and validate settings
|
||||||
let settings: T;
|
let settings: T;
|
||||||
try {
|
try {
|
||||||
@@ -259,6 +280,8 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
try {
|
try {
|
||||||
let result: { buffer: Buffer; filename: string; contentType: string };
|
let result: { buffer: Buffer; filename: string; contentType: string };
|
||||||
|
|
||||||
|
reportProgress(20, "Processing...");
|
||||||
|
|
||||||
// Offload to worker thread for non-AI tools.
|
// Offload to worker thread for non-AI tools.
|
||||||
// Falls back to main-thread processing on any worker error.
|
// Falls back to main-thread processing on any worker error.
|
||||||
// Disabled in test environments where worker_threads can't load .ts files.
|
// 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);
|
result = await config.process(processBuffer, settings, filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
reportProgress(75, "Saving...");
|
||||||
|
|
||||||
// Add a tool-specific suffix to the filename so the download
|
// Add a tool-specific suffix to the filename so the download
|
||||||
// doesn't silently overwrite the user's original file.
|
// doesn't silently overwrite the user's original file.
|
||||||
// Skip if the tool already changed the filename (e.g. convert, split).
|
// 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;
|
let previewUrl: string | undefined;
|
||||||
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
|
if (!BROWSER_PREVIEWABLE.has(result.contentType)) {
|
||||||
|
reportProgress(85, "Generating preview...");
|
||||||
try {
|
try {
|
||||||
let previewInput = result.buffer;
|
let previewInput = result.buffer;
|
||||||
// Sharp can't decode HEIC - use system decoder first
|
// 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");
|
const previewPath = join(workspacePath, "output", "preview.webp");
|
||||||
await writeFile(previewPath, previewBuffer);
|
await writeFile(previewPath, previewBuffer);
|
||||||
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
||||||
|
} 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 {
|
} catch {
|
||||||
// Non-fatal - frontend will show the success card fallback
|
// 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);
|
const inputPath = join(workspacePath, "input", filename);
|
||||||
await writeFile(inputPath, fileBuffer);
|
await writeFile(inputPath, fileBuffer);
|
||||||
|
|
||||||
|
reportProgress(95, "Finishing...");
|
||||||
|
|
||||||
// Auto-save to persistent file store when a fileId is provided
|
// Auto-save to persistent file store when a fileId is provided
|
||||||
let savedFileId: string | undefined;
|
let savedFileId: string | undefined;
|
||||||
if (fileId) {
|
if (fileId) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
|
|||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
@@ -643,7 +644,7 @@ export function registerCollage(app: FastifyInstance) {
|
|||||||
outputExt = "avif";
|
outputExt = "avif";
|
||||||
break;
|
break;
|
||||||
case "jxl":
|
case "jxl":
|
||||||
pipeline = pipeline.jxl({ quality: settings.quality });
|
pipeline = pipeline.png();
|
||||||
outputExt = "jxl";
|
outputExt = "jxl";
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
@@ -653,18 +654,19 @@ export function registerCollage(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await pipeline.toBuffer();
|
const result = await pipeline.toBuffer();
|
||||||
|
const finalBuffer = outputExt === "jxl" ? await encodeJxl(result, settings.quality) : result;
|
||||||
|
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
const workspacePath = await createWorkspace(jobId);
|
const workspacePath = await createWorkspace(jobId);
|
||||||
const filename = `collage.${outputExt}`;
|
const filename = `collage.${outputExt}`;
|
||||||
const outputPath = join(workspacePath, "output", filename);
|
const outputPath = join(workspacePath, "output", filename);
|
||||||
await writeFile(outputPath, result);
|
await writeFile(outputPath, finalBuffer);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
jobId,
|
jobId,
|
||||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||||
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
|
originalSize: files.reduce((s, f) => s + f.buffer.length, 0),
|
||||||
processedSize: result.length,
|
processedSize: finalBuffer.length,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.status(422).send({
|
return reply.status(422).send({
|
||||||
|
|||||||
@@ -8,7 +8,13 @@ import { convert } from "@snapotter/image-engine";
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
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 { encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
import { isSvgBuffer } from "../../lib/svg-sanitize.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
@@ -56,6 +62,7 @@ const CLI_ENCODERS: Record<string, (buf: Buffer, quality?: number) => Promise<Bu
|
|||||||
bmp: encodeBmp,
|
bmp: encodeBmp,
|
||||||
ico: encodeIco,
|
ico: encodeIco,
|
||||||
jp2: encodeJp2,
|
jp2: encodeJp2,
|
||||||
|
jxl: encodeJxl,
|
||||||
qoi: encodeQoi,
|
qoi: encodeQoi,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
|||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.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 { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -213,7 +214,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
|||||||
outputBuffer = await encodeHeic(resultBuffer, quality);
|
outputBuffer = await encodeHeic(resultBuffer, quality);
|
||||||
finalFormat = format;
|
finalFormat = format;
|
||||||
} else if (format === "jxl") {
|
} else if (format === "jxl") {
|
||||||
outputBuffer = await sharp(resultBuffer).jxl({ quality }).toBuffer();
|
outputBuffer = await encodeJxl(resultBuffer, quality);
|
||||||
finalFormat = "jxl";
|
finalFormat = "jxl";
|
||||||
} else {
|
} else {
|
||||||
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
|
outputBuffer = await sharp(resultBuffer).avif({ quality }).toBuffer();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
@@ -145,10 +146,12 @@ export function registerImageToBase64(app: FastifyInstance) {
|
|||||||
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
|
outputBuffer = await pipeline.avif({ quality: opts.quality, effort: 4 }).toBuffer();
|
||||||
mimeType = "image/avif";
|
mimeType = "image/avif";
|
||||||
break;
|
break;
|
||||||
case "jxl":
|
case "jxl": {
|
||||||
outputBuffer = await pipeline.jxl({ quality: opts.quality }).toBuffer();
|
const pngBuf = await pipeline.png().toBuffer();
|
||||||
|
outputBuffer = await encodeJxl(pngBuf, opts.quality);
|
||||||
mimeType = "image/jxl";
|
mimeType = "image/jxl";
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
outputBuffer = await pipeline.toBuffer();
|
outputBuffer = await pipeline.toBuffer();
|
||||||
mimeType = detectMimeType(ext);
|
mimeType = detectMimeType(ext);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { formatZodErrors } from "../../lib/errors.js";
|
|||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { decodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
import { sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
import { createToolRoute } from "../tool-factory.js";
|
import { createToolRoute } from "../tool-factory.js";
|
||||||
@@ -40,9 +41,16 @@ const settingsSchema = z.object({
|
|||||||
type Settings = z.infer<typeof settingsSchema>;
|
type Settings = z.infer<typeof settingsSchema>;
|
||||||
|
|
||||||
async function processImage(inputBuffer: Buffer, settings: Settings, filename: string) {
|
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 image = sharp(inputBuffer);
|
||||||
const result = await optimizeForWeb(image, settings);
|
const result = await optimizeForWeb(image, engineSettings);
|
||||||
const buffer = await result.toBuffer();
|
let buffer = await result.toBuffer();
|
||||||
|
|
||||||
|
if (isJxl) {
|
||||||
|
buffer = await encodeJxl(buffer, settings.quality);
|
||||||
|
}
|
||||||
|
|
||||||
const ext = extname(filename);
|
const ext = extname(filename);
|
||||||
const baseName = ext ? filename.slice(0, -ext.length) : filename;
|
const baseName = ext ? filename.slice(0, -ext.length) : filename;
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { env } from "../../config.js";
|
import { env } from "../../config.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { encodeHeic } from "../../lib/heic-converter.js";
|
import { encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
@@ -117,8 +118,10 @@ async function convertWithSharp(
|
|||||||
return s.tiff().toBuffer();
|
return s.tiff().toBuffer();
|
||||||
case "gif":
|
case "gif":
|
||||||
return s.gif().toBuffer();
|
return s.gif().toBuffer();
|
||||||
case "jxl":
|
case "jxl": {
|
||||||
return s.jxl({ quality }).toBuffer();
|
const pngBuf = await s.png().toBuffer();
|
||||||
|
return encodeJxl(pngBuf, quality);
|
||||||
|
}
|
||||||
case "heic":
|
case "heic":
|
||||||
case "heif": {
|
case "heif": {
|
||||||
const pngBuf = await s.png().toBuffer();
|
const pngBuf = await s.png().toBuffer();
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
|||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.js";
|
import { registerToolProcessFn } from "../tool-factory.js";
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ function resolveOutputFormat(
|
|||||||
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
|
jpg: { sharpFormat: "jpeg", ext: ".jpg" },
|
||||||
webp: { sharpFormat: "webp", ext: ".webp" },
|
webp: { sharpFormat: "webp", ext: ".webp" },
|
||||||
avif: { sharpFormat: "avif", ext: ".avif" },
|
avif: { sharpFormat: "avif", ext: ".avif" },
|
||||||
jxl: { sharpFormat: "jxl", ext: ".jxl" },
|
jxl: { sharpFormat: "png", ext: ".jxl" },
|
||||||
};
|
};
|
||||||
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
|
return map[outputFormat] ?? { sharpFormat: null, ext: originalExt };
|
||||||
}
|
}
|
||||||
@@ -150,7 +151,11 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const partBuffer = await pipeline.toBuffer();
|
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}`,
|
name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -236,7 +241,11 @@ export function registerSplit(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const partBuffer = await pipeline.toBuffer();
|
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}`,
|
name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js";
|
|||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
import { ensureSharpCompat } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
|
|
||||||
@@ -204,13 +205,17 @@ export function registerStitch(app: FastifyInstance) {
|
|||||||
} else if (settings.format === "avif") {
|
} else if (settings.format === "avif") {
|
||||||
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
|
pipeline = pipeline.avif({ quality: settings.quality, effort: 4 });
|
||||||
} else if (settings.format === "jxl") {
|
} else if (settings.format === "jxl") {
|
||||||
pipeline = pipeline.jxl({ quality: settings.quality });
|
pipeline = pipeline.png();
|
||||||
} else {
|
} else {
|
||||||
pipeline = pipeline.png();
|
pipeline = pipeline.png();
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = await pipeline.toBuffer();
|
let result = await pipeline.toBuffer();
|
||||||
|
|
||||||
|
if (settings.format === "jxl") {
|
||||||
|
result = await encodeJxl(result, settings.quality);
|
||||||
|
}
|
||||||
|
|
||||||
if (settings.cornerRadius > 0) {
|
if (settings.cornerRadius > 0) {
|
||||||
const meta = await sharp(result).metadata();
|
const meta = await sharp(result).metadata();
|
||||||
if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions");
|
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") {
|
} else if (settings.format === "avif") {
|
||||||
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
|
result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer();
|
||||||
} else if (settings.format === "jxl") {
|
} else if (settings.format === "jxl") {
|
||||||
result = await sharp(result).jxl({ quality: settings.quality }).toBuffer();
|
result = await encodeJxl(result, settings.quality);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { env } from "../../config.js";
|
|||||||
import { resolveConcurrency } from "../../lib/env.js";
|
import { resolveConcurrency } from "../../lib/env.js";
|
||||||
import { formatZodErrors } from "../../lib/errors.js";
|
import { formatZodErrors } from "../../lib/errors.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
|
import { encodeJxl } from "../../lib/format-encoders.js";
|
||||||
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -77,10 +78,12 @@ async function convertSvg(
|
|||||||
buffer = await image.gif().toBuffer();
|
buffer = await image.gif().toBuffer();
|
||||||
ext = "gif";
|
ext = "gif";
|
||||||
break;
|
break;
|
||||||
case "jxl":
|
case "jxl": {
|
||||||
buffer = await image.jxl({ quality: settings.quality }).toBuffer();
|
const pngBuf = await image.png().toBuffer();
|
||||||
|
buffer = await encodeJxl(pngBuf, settings.quality);
|
||||||
ext = "jxl";
|
ext = "jxl";
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
case "heif": {
|
case "heif": {
|
||||||
const pngBuffer = await image.png().toBuffer();
|
const pngBuffer = await image.png().toBuffer();
|
||||||
buffer = await encodeHeic(pngBuffer, settings.quality);
|
buffer = await encodeHeic(pngBuffer, settings.quality);
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
|
|||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../../lib/filename.js";
|
import { sanitizeFilename } from "../../lib/filename.js";
|
||||||
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.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 { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { resolveOutputFormat } from "../../lib/output-format.js";
|
import { resolveOutputFormat } from "../../lib/output-format.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
@@ -186,7 +187,7 @@ export function registerUpscale(app: FastifyInstance) {
|
|||||||
outputBuffer = await encodeHeic(result.buffer, outputQuality);
|
outputBuffer = await encodeHeic(result.buffer, outputQuality);
|
||||||
finalFormat = format;
|
finalFormat = format;
|
||||||
} else if (format === "jxl") {
|
} else if (format === "jxl") {
|
||||||
outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer();
|
outputBuffer = await encodeJxl(result.buffer, outputQuality);
|
||||||
finalFormat = "jxl";
|
finalFormat = "jxl";
|
||||||
} else if (format === "avif") {
|
} else if (format === "avif") {
|
||||||
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
|
outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer();
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { FileImage, Upload } from "lucide-react";
|
import { FileImage, ImageUp, Upload } from "lucide-react";
|
||||||
import { type DragEvent, useCallback, useState } from "react";
|
import { type DragEvent, useCallback, useEffect, useState } from "react";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const IMAGE_EXTENSIONS = new Set([
|
const IMAGE_EXTENSIONS = new Set([
|
||||||
@@ -134,6 +134,35 @@ export function Dropzone({
|
|||||||
input.click();
|
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;
|
const hasMultipleFiles = currentFiles.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -143,31 +172,60 @@ export function Dropzone({
|
|||||||
onDragOver={handleDrag}
|
onDragOver={handleDrag}
|
||||||
onDragLeave={handleDrag}
|
onDragLeave={handleDrag}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
|
onClick={handleClick}
|
||||||
className={cn(
|
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]",
|
compact ? "min-h-0 h-full" : "min-h-[400px]",
|
||||||
isDragging
|
isDragging
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary/10 scale-[1.01]"
|
||||||
: "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50",
|
: "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={cn("flex flex-col items-center", compact ? "gap-2 p-4" : "gap-5 p-8")}>
|
||||||
<div className="text-3xl font-bold text-muted-foreground/30">
|
<div
|
||||||
<span className="text-primary/30">SnapOtter</span>
|
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>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleClick}
|
onClick={(e) => {
|
||||||
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"
|
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 className="h-4 w-4" />
|
||||||
Upload from computer
|
Upload
|
||||||
</button>
|
</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 && (
|
{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">
|
<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" />
|
<FileImage className="h-3.5 w-3.5" />
|
||||||
{currentFiles.length} files selected
|
{currentFiles.length} files selected
|
||||||
|
|||||||
@@ -51,7 +51,12 @@ export function MultiImageViewer() {
|
|||||||
hasProcessed && currentEntry.processedUrl
|
hasProcessed && currentEntry.processedUrl
|
||||||
? canBrowserPreview(currentEntry.processedUrl)
|
? canBrowserPreview(currentEntry.processedUrl)
|
||||||
: false;
|
: 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
|
const processedFilename = currentEntry.processedUrl
|
||||||
? decodeURIComponent(currentEntry.processedUrl.split("/").pop() ?? "processed")
|
? decodeURIComponent(currentEntry.processedUrl.split("/").pop() ?? "processed")
|
||||||
@@ -77,7 +82,10 @@ export function MultiImageViewer() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="w-full h-full min-h-0">
|
<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="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">
|
<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" />
|
<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) {
|
export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) {
|
||||||
if (!active) return null;
|
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 =
|
const icon =
|
||||||
phase === "uploading" ? (
|
phase === "uploading" ? (
|
||||||
<Upload className="h-4 w-4 text-primary" />
|
<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" />
|
<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, `${elapsed}s`].filter(Boolean).join(" · ");
|
||||||
const sublabel = [stage, slowHint, `${elapsed}s`].filter(Boolean).join(" \u00b7 ");
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted/80 border border-border rounded-xl p-3 space-y-2.5">
|
<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>
|
||||||
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
<div className="w-full h-1 bg-muted rounded-full overflow-hidden">
|
||||||
<div
|
<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)}%` }}
|
style={{ width: `${Math.min(100, percent)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
|||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { FileEntry } from "@/stores/file-store";
|
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 {
|
interface ThumbnailStripProps {
|
||||||
entries: FileEntry[];
|
entries: FileEntry[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
@@ -50,7 +62,7 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
src={entry.processedPreviewUrl ?? entry.processedUrl ?? entry.blobUrl}
|
src={thumbnailSrc(entry)}
|
||||||
alt={entry.file.name}
|
alt={entry.file.name}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ProgressCard } from "@/components/common/progress-card";
|
|||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
type ResizeTab = "presets" | "custom" | "scale";
|
type ResizeTab = "presets" | "custom" | "scale" | "content-aware";
|
||||||
type FitMode = "cover" | "contain" | "fill";
|
type FitMode = "cover" | "contain" | "fill";
|
||||||
|
|
||||||
const FIT_LABELS: Record<FitMode, string> = {
|
const FIT_LABELS: Record<FitMode, string> = {
|
||||||
@@ -42,7 +42,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
const [fit, setFit] = useState<FitMode>("cover");
|
const [fit, setFit] = useState<FitMode>("cover");
|
||||||
const [lockAspect, setLockAspect] = useState(true);
|
const [lockAspect, setLockAspect] = useState(true);
|
||||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||||
const [contentAware, setContentAware] = useState(false);
|
const contentAware = tab === "content-aware";
|
||||||
const [protectFaces, setProtectFaces] = useState(false);
|
const [protectFaces, setProtectFaces] = useState(false);
|
||||||
const [blurRadius, setBlurRadius] = useState(4);
|
const [blurRadius, setBlurRadius] = useState(4);
|
||||||
const [sobelThreshold, setSobelThreshold] = useState(2);
|
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.fit != null) setFit(initialSettings.fit as FitMode);
|
||||||
if (initialSettings.withoutEnlargement != null)
|
if (initialSettings.withoutEnlargement != null)
|
||||||
setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement));
|
setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement));
|
||||||
if (initialSettings.contentAware != null)
|
|
||||||
setContentAware(Boolean(initialSettings.contentAware));
|
|
||||||
if (initialSettings.protectFaces != null)
|
if (initialSettings.protectFaces != null)
|
||||||
setProtectFaces(Boolean(initialSettings.protectFaces));
|
setProtectFaces(Boolean(initialSettings.protectFaces));
|
||||||
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
|
if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius));
|
||||||
if (initialSettings.sobelThreshold != null)
|
if (initialSettings.sobelThreshold != null)
|
||||||
setSobelThreshold(Number(initialSettings.sobelThreshold));
|
setSobelThreshold(Number(initialSettings.sobelThreshold));
|
||||||
if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square));
|
if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square));
|
||||||
// Infer tab from settings
|
if (initialSettings.contentAware) setTab("content-aware");
|
||||||
if (initialSettings.percentage != null) setTab("scale");
|
else if (initialSettings.percentage != null) setTab("scale");
|
||||||
else if (initialSettings.contentAware) setTab("custom");
|
|
||||||
}, [initialSettings]);
|
}, [initialSettings]);
|
||||||
|
|
||||||
const onChangeRef = useRef(onChange);
|
const onChangeRef = useRef(onChange);
|
||||||
@@ -104,7 +101,6 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
percentage,
|
percentage,
|
||||||
fit,
|
fit,
|
||||||
withoutEnlargement,
|
withoutEnlargement,
|
||||||
contentAware,
|
|
||||||
protectFaces,
|
protectFaces,
|
||||||
blurRadius,
|
blurRadius,
|
||||||
sobelThreshold,
|
sobelThreshold,
|
||||||
@@ -183,9 +179,6 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Standard resize tabs */}
|
|
||||||
{!contentAware && (
|
|
||||||
<>
|
|
||||||
{/* Tab selector */}
|
{/* Tab selector */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
@@ -195,12 +188,15 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
<button type="button" onClick={() => setTab("scale")} className={tabClass("scale")}>
|
||||||
Scale
|
Scale
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" onClick={() => setTab("presets")} className={tabClass("presets")}>
|
||||||
|
Presets
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTab("presets")}
|
onClick={() => setTab("content-aware")}
|
||||||
className={tabClass("presets")}
|
className={tabClass("content-aware")}
|
||||||
>
|
>
|
||||||
Presets
|
Content-Aware
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -301,36 +297,10 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content-aware section - positioned below standard resize */}
|
{/* Content-aware tab */}
|
||||||
<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>
|
|
||||||
<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"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<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"
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content-aware options (expanded when toggled) */}
|
|
||||||
{contentAware && (
|
{contentAware && (
|
||||||
<div className="mt-3 space-y-3">
|
<div className="space-y-3">
|
||||||
{/* Dimensions */}
|
|
||||||
{dimensionInputs}
|
{dimensionInputs}
|
||||||
|
|
||||||
{/* Square mode */}
|
{/* Square mode */}
|
||||||
@@ -395,7 +365,6 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ const IDLE_PROGRESS: PipelineProgress = {
|
|||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const UPLOAD_WEIGHT = 15;
|
||||||
|
|
||||||
export function usePipelineProcessor() {
|
export function usePipelineProcessor() {
|
||||||
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
||||||
useFileStore();
|
useFileStore();
|
||||||
@@ -35,13 +37,11 @@ export function usePipelineProcessor() {
|
|||||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Clean up on unmount
|
// Clean up on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||||
if (xhrRef.current) xhrRef.current.abort();
|
if (xhrRef.current) xhrRef.current.abort();
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
@@ -50,12 +50,9 @@ export function usePipelineProcessor() {
|
|||||||
|
|
||||||
const processSingle = useCallback(
|
const processSingle = useCallback(
|
||||||
(file: File, steps: PipelineStep[]) => {
|
(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;
|
const capturedIndex = useFileStore.getState().selectedIndex;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
// Mark the target entry as processing and clear any old result
|
|
||||||
useFileStore.getState().updateEntry(capturedIndex, {
|
useFileStore.getState().updateEntry(capturedIndex, {
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
processedPreviewUrl: null,
|
processedPreviewUrl: null,
|
||||||
@@ -66,7 +63,6 @@ export function usePipelineProcessor() {
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||||
|
|
||||||
// Start elapsed timer
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
elapsedRef.current = setInterval(() => {
|
elapsedRef.current = setInterval(() => {
|
||||||
setProgress((prev) => ({
|
setProgress((prev) => ({
|
||||||
@@ -75,7 +71,40 @@ export function usePipelineProcessor() {
|
|||||||
}));
|
}));
|
||||||
}, 1000);
|
}, 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 = {
|
const pipeline = {
|
||||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||||
};
|
};
|
||||||
@@ -83,17 +112,13 @@ export function usePipelineProcessor() {
|
|||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("pipeline", JSON.stringify(pipeline));
|
formData.append("pipeline", JSON.stringify(pipeline));
|
||||||
|
formData.append("clientJobId", clientJobId);
|
||||||
|
|
||||||
// Use XHR for upload progress tracking
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhrRef.current = xhr;
|
xhrRef.current = xhr;
|
||||||
|
|
||||||
// Pipeline runs multiple steps sequentially, allow up to 10 minutes
|
|
||||||
xhr.timeout = 600_000;
|
xhr.timeout = 600_000;
|
||||||
|
|
||||||
// Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95%
|
|
||||||
const UPLOAD_WEIGHT = 40;
|
|
||||||
|
|
||||||
xhr.upload.onprogress = (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (event.lengthComputable) {
|
if (event.lengthComputable) {
|
||||||
const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT;
|
const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT;
|
||||||
@@ -111,23 +136,14 @@ export function usePipelineProcessor() {
|
|||||||
percent: UPLOAD_WEIGHT,
|
percent: UPLOAD_WEIGHT,
|
||||||
stage: "Processing...",
|
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 = () => {
|
xhr.onload = () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
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) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
try {
|
try {
|
||||||
@@ -166,7 +182,10 @@ export function usePipelineProcessor() {
|
|||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
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");
|
setError("Network error - check your connection");
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
setProgress(IDLE_PROGRESS);
|
setProgress(IDLE_PROGRESS);
|
||||||
@@ -174,7 +193,10 @@ export function usePipelineProcessor() {
|
|||||||
|
|
||||||
xhr.ontimeout = () => {
|
xhr.ontimeout = () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
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.");
|
setError("Request timed out - the server may be overloaded. Try again.");
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
setProgress(IDLE_PROGRESS);
|
setProgress(IDLE_PROGRESS);
|
||||||
|
|||||||
@@ -27,13 +27,10 @@ const IDLE_PROGRESS: ToolProgress = {
|
|||||||
elapsed: 0,
|
elapsed: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
// AI tools that go through Python/bridge.ts and can emit SSE progress.
|
// AI tools return 202 and deliver results via SSE (not XHR response).
|
||||||
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
|
||||||
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||||
|
|
||||||
// Tools that take a few seconds (not instant like Sharp, not minutes like AI).
|
const UPLOAD_WEIGHT = 15;
|
||||||
// Uses a smoother progress: upload 0-40%, then a gradual fill during processing.
|
|
||||||
const MEDIUM_TOOLS = new Set(["content-aware-resize", "convert"]);
|
|
||||||
|
|
||||||
export function useToolProcessor(toolId: string) {
|
export function useToolProcessor(toolId: string) {
|
||||||
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
||||||
@@ -47,15 +44,12 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
||||||
const isMediumTool = MEDIUM_TOOLS.has(toolId);
|
|
||||||
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
||||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
||||||
|
|
||||||
// Clean up on unmount
|
// Clean up on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||||
if (xhrRef.current) xhrRef.current.abort();
|
if (xhrRef.current) xhrRef.current.abort();
|
||||||
if (abortRef.current) abortRef.current.abort();
|
if (abortRef.current) abortRef.current.abort();
|
||||||
@@ -69,13 +63,10 @@ export function useToolProcessor(toolId: string) {
|
|||||||
return;
|
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;
|
const capturedIndex = useFileStore.getState().selectedIndex;
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setWarning(null);
|
setWarning(null);
|
||||||
// Mark the target entry as processing and clear any old result
|
|
||||||
useFileStore.getState().updateEntry(capturedIndex, {
|
useFileStore.getState().updateEntry(capturedIndex, {
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
processedPreviewUrl: null,
|
processedPreviewUrl: null,
|
||||||
@@ -86,7 +77,6 @@ export function useToolProcessor(toolId: string) {
|
|||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||||
|
|
||||||
// Start elapsed timer
|
|
||||||
const startTime = Date.now();
|
const startTime = Date.now();
|
||||||
elapsedRef.current = setInterval(() => {
|
elapsedRef.current = setInterval(() => {
|
||||||
setProgress((prev) => ({
|
setProgress((prev) => ({
|
||||||
@@ -95,12 +85,10 @@ export function useToolProcessor(toolId: string) {
|
|||||||
}));
|
}));
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Generate client job ID for SSE correlation
|
|
||||||
const clientJobId = generateId();
|
const clientJobId = generateId();
|
||||||
let asyncMode = false;
|
let asyncMode = false;
|
||||||
|
|
||||||
// For AI tools, open SSE before uploading
|
// Open SSE for real-time progress from the server (all tools)
|
||||||
if (isAiTool) {
|
|
||||||
try {
|
try {
|
||||||
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
|
||||||
eventSourceRef.current = es;
|
eventSourceRef.current = es;
|
||||||
@@ -110,9 +98,9 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type !== "single") return;
|
if (data.type !== "single") return;
|
||||||
|
|
||||||
|
// AI tools deliver results via SSE (they return 202 from the XHR)
|
||||||
if (data.phase === "complete" && data.result) {
|
if (data.phase === "complete" && data.result) {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
es.close();
|
es.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
|
|
||||||
@@ -134,7 +122,6 @@ export function useToolProcessor(toolId: string) {
|
|||||||
|
|
||||||
if (data.phase === "failed" && asyncMode) {
|
if (data.phase === "failed" && asyncMode) {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
es.close();
|
es.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
setError(data.error || "Processing failed");
|
setError(data.error || "Processing failed");
|
||||||
@@ -144,7 +131,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (typeof data.percent === "number") {
|
if (typeof data.percent === "number") {
|
||||||
const scaled = 15 + (data.percent / 100) * 85;
|
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
|
||||||
setProgress((prev) => ({
|
setProgress((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
@@ -166,9 +153,8 @@ export function useToolProcessor(toolId: string) {
|
|||||||
} catch {
|
} catch {
|
||||||
// EventSource creation failed -- proceed without SSE
|
// 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 cleanSettings = { ...settings };
|
||||||
const bgImageFile = cleanSettings._bgImageFile as File | undefined;
|
const bgImageFile = cleanSettings._bgImageFile as File | undefined;
|
||||||
delete cleanSettings._bgImageFile;
|
delete cleanSettings._bgImageFile;
|
||||||
@@ -179,27 +165,17 @@ export function useToolProcessor(toolId: string) {
|
|||||||
if (bgImageFile) {
|
if (bgImageFile) {
|
||||||
formData.append("backgroundImage", 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];
|
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
||||||
if (capturedEntry?.serverFileId) {
|
if (capturedEntry?.serverFileId) {
|
||||||
formData.append("fileId", capturedEntry.serverFileId);
|
formData.append("fileId", capturedEntry.serverFileId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use XHR for upload progress tracking
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhrRef.current = xhr;
|
xhrRef.current = xhr;
|
||||||
|
|
||||||
// Timeout: 2 min for fast tools, 5 min for medium (seam carving), 10 min for AI
|
xhr.timeout = isAiTool ? 600_000 : 120_000;
|
||||||
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.upload.onprogress = (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (event.lengthComputable) {
|
if (event.lengthComputable) {
|
||||||
@@ -216,36 +192,8 @@ export function useToolProcessor(toolId: string) {
|
|||||||
...prev,
|
...prev,
|
||||||
phase: "processing",
|
phase: "processing",
|
||||||
percent: UPLOAD_WEIGHT,
|
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 = () => {
|
xhr.onload = () => {
|
||||||
@@ -255,7 +203,6 @@ export function useToolProcessor(toolId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
@@ -299,7 +246,6 @@ export function useToolProcessor(toolId: string) {
|
|||||||
|
|
||||||
xhr.onerror = () => {
|
xhr.onerror = () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
@@ -311,7 +257,6 @@ export function useToolProcessor(toolId: string) {
|
|||||||
|
|
||||||
xhr.ontimeout = () => {
|
xhr.ontimeout = () => {
|
||||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
|
||||||
if (eventSourceRef.current) {
|
if (eventSourceRef.current) {
|
||||||
eventSourceRef.current.close();
|
eventSourceRef.current.close();
|
||||||
eventSourceRef.current = null;
|
eventSourceRef.current = null;
|
||||||
@@ -327,7 +272,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
});
|
});
|
||||||
xhr.send(formData);
|
xhr.send(formData);
|
||||||
},
|
},
|
||||||
[toolId, isAiTool, isMediumTool, setProcessing, setError, toolName],
|
[toolId, isAiTool, setProcessing, setError, toolName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const processAllFiles = useCallback(
|
const processAllFiles = useCallback(
|
||||||
|
|||||||
@@ -76,25 +76,45 @@ export async function seamCarve(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const targetW = options.width ?? width;
|
let targetW = options.width ?? width;
|
||||||
const targetH = options.height ?? height;
|
let targetH = options.height ?? height;
|
||||||
const wRatio = targetW / width;
|
|
||||||
const hRatio = targetH / height;
|
if (options.square) {
|
||||||
if (wRatio < 0.25 || hRatio < 0.25) {
|
const shortest = Math.min(options.width ?? width, options.height ?? height, width, height);
|
||||||
throw new Error(
|
targetW = shortest;
|
||||||
`Content-aware resize cannot reduce dimensions by more than 75% (requested ${width}→${targetW} width, ${height}→${targetH} height). Use regular resize first to get closer to the target size.`,
|
targetH = shortest;
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const processBuffer = await sharp(inputBuffer).jpeg({ quality: 95 }).toBuffer();
|
const wRatio = targetW / width;
|
||||||
|
const hRatio = targetH / height;
|
||||||
|
|
||||||
|
let currentW = width;
|
||||||
|
let currentH = height;
|
||||||
|
let preResizedBuffer = inputBuffer;
|
||||||
|
|
||||||
|
// Seam carving can only reduce each axis by ~75% per pass. If the target
|
||||||
|
// is further away, do a standard resize first to bring it within range.
|
||||||
|
const MIN_RATIO = 0.25;
|
||||||
|
if (wRatio < MIN_RATIO || hRatio < MIN_RATIO) {
|
||||||
|
const safeW = Math.max(targetW, Math.ceil(width * MIN_RATIO));
|
||||||
|
const safeH = Math.max(targetH, Math.ceil(height * MIN_RATIO));
|
||||||
|
const preResized = sharp(inputBuffer).resize(safeW, safeH, { fit: "inside" });
|
||||||
|
preResizedBuffer = await preResized.toBuffer();
|
||||||
|
const preMeta = await sharp(preResizedBuffer).metadata();
|
||||||
|
currentW = preMeta.width ?? safeW;
|
||||||
|
currentH = preMeta.height ?? safeH;
|
||||||
|
}
|
||||||
|
|
||||||
|
const processBuffer = await sharp(preResizedBuffer).jpeg({ quality: 95 }).toBuffer();
|
||||||
|
|
||||||
await writeFile(inputPath, processBuffer);
|
await writeFile(inputPath, processBuffer);
|
||||||
|
|
||||||
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
||||||
|
|
||||||
if (options.square) {
|
if (options.square) {
|
||||||
const shortest = Math.min(width, height);
|
const shortest = Math.min(currentW, currentH);
|
||||||
args.push("-square", "-width", String(shortest), "-height", String(shortest));
|
const caireTarget = Math.min(shortest, targetW);
|
||||||
|
args.push("-square", "-width", String(caireTarget), "-height", String(caireTarget));
|
||||||
} else {
|
} else {
|
||||||
if (options.width) {
|
if (options.width) {
|
||||||
args.push("-width", String(options.width));
|
args.push("-width", String(options.width));
|
||||||
@@ -108,7 +128,8 @@ export async function seamCarve(
|
|||||||
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
||||||
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
|
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
|
||||||
|
|
||||||
const timeoutMs = Math.max(120_000, megapixels * 10 * 1000);
|
const currentMp = (currentW * currentH) / 1_000_000;
|
||||||
|
const timeoutMs = Math.ceil(Math.max(120_000, currentMp * 10_000));
|
||||||
await execFileAsync(cairePath, args, { timeout: timeoutMs });
|
await execFileAsync(cairePath, args, { timeout: timeoutMs });
|
||||||
|
|
||||||
const buffer = await readFile(outputPath);
|
const buffer = await readFile(outputPath);
|
||||||
|
|||||||
@@ -14,8 +14,27 @@ import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from
|
|||||||
|
|
||||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||||
|
|
||||||
// Output formats accepted by the convert tool
|
// All output formats accepted by the convert tool
|
||||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const;
|
const OUTPUT_FORMATS = [
|
||||||
|
"jpg",
|
||||||
|
"png",
|
||||||
|
"webp",
|
||||||
|
"avif",
|
||||||
|
"tiff",
|
||||||
|
"gif",
|
||||||
|
"heic",
|
||||||
|
"heif",
|
||||||
|
"jxl",
|
||||||
|
"bmp",
|
||||||
|
"ico",
|
||||||
|
"jp2",
|
||||||
|
"qoi",
|
||||||
|
"psd",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
// Formats whose CLI encoder (cjxl, heif-enc, opj_compress, magick) may not
|
||||||
|
// be installed in every dev/CI environment. Allow graceful 422 for these.
|
||||||
|
const CLI_ENCODED_FORMATS = new Set(["heic", "heif", "jxl", "bmp", "ico", "jp2", "qoi", "psd"]);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared state
|
// Shared state
|
||||||
@@ -108,8 +127,10 @@ describe("Format conversion matrix", () => {
|
|||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
// HEIC encode/decode requires libheif which may not be installed (Windows, some Linux)
|
// CLI-encoded formats may not have their encoder installed in every environment
|
||||||
if (res.statusCode === 422 && (inputFmt === "heic" || outputFmt === "heic")) return;
|
if (res.statusCode === 422 && (inputFmt === "heic" || CLI_ENCODED_FORMATS.has(outputFmt))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||||
@@ -146,8 +167,8 @@ describe("SVG via convert tool", () => {
|
|||||||
body: payload,
|
body: payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
// HEIC encode/decode requires libheif which may not be installed (Windows, some Linux)
|
// CLI-encoded formats may not have their encoder installed in every environment
|
||||||
if (res.statusCode === 422 && outputFmt === "heic") return;
|
if (res.statusCode === 422 && CLI_ENCODED_FORMATS.has(outputFmt)) return;
|
||||||
expect(res.statusCode).toBe(200);
|
expect(res.statusCode).toBe(200);
|
||||||
const body = JSON.parse(res.body);
|
const body = JSON.parse(res.body);
|
||||||
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
expect(body.downloadUrl).toContain(`.${outputFmt}`);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ vi.mock("sharp", () => {
|
|||||||
const mockSharp = vi.fn(() => ({
|
const mockSharp = vi.fn(() => ({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||||
}));
|
}));
|
||||||
@@ -44,6 +45,7 @@ beforeEach(() => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -71,6 +73,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -222,6 +225,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
// 4000x3000 = 12MP, should give timeout > 120s
|
// 4000x3000 = 12MP, should give timeout > 120s
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 4000, height: 3000 }),
|
metadata: vi.fn().mockResolvedValue({ width: 4000, height: 3000 }),
|
||||||
@@ -257,6 +261,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -275,6 +280,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
// 6000x5000 = 30 MP, exceeds 25 MP limit
|
// 6000x5000 = 30 MP, exceeds 25 MP limit
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
||||||
@@ -286,40 +292,73 @@ describe("seamCarve", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when dimension reduction exceeds 75%", async () => {
|
it("pre-resizes when width reduction exceeds 75%", async () => {
|
||||||
|
let callCount = 0;
|
||||||
vi.mocked(sharp).mockImplementation(
|
vi.mocked(sharp).mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount <= 1) return Promise.resolve({ width: 800, height: 600 });
|
||||||
|
return Promise.resolve({ width: 200, height: 150 });
|
||||||
|
}),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { seamCarve } = await importFresh();
|
const { seamCarve } = await importFresh();
|
||||||
// Requesting width 100 from 800 is a 87.5% reduction (ratio 0.125 < 0.25)
|
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 100 })).resolves.toBeDefined();
|
||||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 100 })).rejects.toThrow(
|
|
||||||
"cannot reduce dimensions by more than 75%",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws when height reduction exceeds 75%", async () => {
|
it("pre-resizes when height reduction exceeds 75%", async () => {
|
||||||
|
let callCount = 0;
|
||||||
vi.mocked(sharp).mockImplementation(
|
vi.mocked(sharp).mockImplementation(
|
||||||
() =>
|
() =>
|
||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount <= 1) return Promise.resolve({ width: 800, height: 600 });
|
||||||
|
return Promise.resolve({ width: 200, height: 150 });
|
||||||
|
}),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const { seamCarve } = await importFresh();
|
const { seamCarve } = await importFresh();
|
||||||
// Requesting height 100 from 600 is an 83% reduction (ratio 0.167 < 0.25)
|
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { height: 100 })).resolves.toBeDefined();
|
||||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { height: 100 })).rejects.toThrow(
|
});
|
||||||
"cannot reduce dimensions by more than 75%",
|
|
||||||
|
it("pre-resizes large image for square mode with small target", async () => {
|
||||||
|
let callCount = 0;
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
|
metadata: vi.fn().mockImplementation(() => {
|
||||||
|
callCount++;
|
||||||
|
if (callCount <= 1) return Promise.resolve({ width: 3775, height: 5662 });
|
||||||
|
return Promise.resolve({ width: 944, height: 1416 });
|
||||||
|
}),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const { seamCarve } = await importFresh();
|
||||||
|
await expect(
|
||||||
|
seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 500, height: 500, square: true }),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
|
||||||
|
const calls = mockExecFileAsync.mock.calls;
|
||||||
|
const caireCall = calls.find((c: unknown[]) => Array.isArray(c[1]) && c[1].includes("-square"));
|
||||||
|
expect(caireCall).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes only width when height is not specified", async () => {
|
it("passes only width when height is not specified", async () => {
|
||||||
@@ -382,6 +421,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -397,6 +437,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -440,6 +481,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 1200, height: 400 }),
|
metadata: vi.fn().mockResolvedValue({ width: 1200, height: 400 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -461,6 +503,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockRejectedValue(new Error("Corrupt file header")),
|
metadata: vi.fn().mockRejectedValue(new Error("Corrupt file header")),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -476,6 +519,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockRejectedValue(new Error("JPEG encode failed")),
|
toBuffer: vi.fn().mockRejectedValue(new Error("JPEG encode failed")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -520,6 +564,7 @@ describe("seamCarve", () => {
|
|||||||
({
|
({
|
||||||
png: vi.fn().mockReturnThis(),
|
png: vi.fn().mockReturnThis(),
|
||||||
jpeg: vi.fn().mockReturnThis(),
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
|
||||||
}) as unknown as ReturnType<typeof sharp>,
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
@@ -530,6 +575,31 @@ describe("seamCarve", () => {
|
|||||||
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 200 })).resolves.toBeDefined();
|
await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 200 })).resolves.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("timeout is always an integer", async () => {
|
||||||
|
vi.mocked(sharp).mockImplementation(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
png: vi.fn().mockReturnThis(),
|
||||||
|
jpeg: vi.fn().mockReturnThis(),
|
||||||
|
resize: vi.fn().mockReturnThis(),
|
||||||
|
toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")),
|
||||||
|
// 3775x5662 = 21.37 MP -- produces a float if not rounded
|
||||||
|
metadata: vi.fn().mockResolvedValue({ width: 3775, height: 5662 }),
|
||||||
|
}) as unknown as ReturnType<typeof sharp>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { seamCarve } = await importFresh();
|
||||||
|
await seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR);
|
||||||
|
|
||||||
|
const calls = mockExecFileAsync.mock.calls;
|
||||||
|
const caireCall = calls.find(
|
||||||
|
(c: unknown[]) => Array.isArray(c[1]) && c[1].includes("-preview=false"),
|
||||||
|
);
|
||||||
|
expect(caireCall).toBeDefined();
|
||||||
|
const timeout = caireCall?.[2]?.timeout;
|
||||||
|
expect(Number.isInteger(timeout)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("uses unique UUID in temp file names to prevent collisions", async () => {
|
it("uses unique UUID in temp file names to prevent collisions", async () => {
|
||||||
const { seamCarve } = await importFresh();
|
const { seamCarve } = await importFresh();
|
||||||
|
|
||||||
|
|||||||
@@ -1307,11 +1307,10 @@ describe("seamCarve", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects reductions larger than 75%", async () => {
|
it("pre-resizes when reduction exceeds 75% instead of rejecting", async () => {
|
||||||
// 800x600, requesting width: 100 => ratio 0.125 < 0.25
|
// 800x600, requesting width: 100 => ratio 0.125 < 0.25
|
||||||
await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).rejects.toThrow(
|
// Should succeed by pre-resizing to bring within 75% limit
|
||||||
"cannot reduce dimensions by more than 75%",
|
await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).resolves.toBeDefined();
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses original dimensions when width/height not specified", async () => {
|
it("uses original dimensions when width/height not specified", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,682 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Dropzone, isImageFile } from "@/components/common/dropzone";
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function makeFile(name: string, type = "image/png", size = 1024): File {
|
||||||
|
const buf = new ArrayBuffer(size);
|
||||||
|
return new File([buf], name, { type });
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDataTransfer(files: File[]): DataTransfer {
|
||||||
|
return { files } as unknown as DataTransfer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makePasteEvent({
|
||||||
|
items = [],
|
||||||
|
files = [] as File[],
|
||||||
|
}: {
|
||||||
|
items?: Array<{ kind: string; type: string; getAsFile: () => File | null }>;
|
||||||
|
files?: File[];
|
||||||
|
}) {
|
||||||
|
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, "clipboardData", {
|
||||||
|
value: { items, files },
|
||||||
|
});
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate paste via clipboardData.items (e.g. screenshot paste in browser). */
|
||||||
|
function pasteViaItems(files: File[]) {
|
||||||
|
const items = files.map((f) => ({
|
||||||
|
kind: "file" as const,
|
||||||
|
type: f.type,
|
||||||
|
getAsFile: () => f,
|
||||||
|
}));
|
||||||
|
const event = makePasteEvent({ items, files: [] as unknown as File[] });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulate paste via clipboardData.files (e.g. Cmd+C files from Finder on macOS). */
|
||||||
|
function pasteViaFiles(files: File[]) {
|
||||||
|
const event = makePasteEvent({ items: [], files });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
return event;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spy on HTMLInputElement.prototype.click to capture the programmatically
|
||||||
|
* created file input. Returns a getter for the captured input.
|
||||||
|
*/
|
||||||
|
function spyFileInput() {
|
||||||
|
let captured: HTMLInputElement | null = null;
|
||||||
|
vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(function (
|
||||||
|
this: HTMLInputElement,
|
||||||
|
) {
|
||||||
|
if (this.type === "file") captured = this;
|
||||||
|
});
|
||||||
|
return () => captured;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// isImageFile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("isImageFile", () => {
|
||||||
|
it("accepts files with image/* MIME type", () => {
|
||||||
|
expect(isImageFile(makeFile("photo.jpg", "image/jpeg"))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("photo.png", "image/png"))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("photo.webp", "image/webp"))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("icon.svg", "image/svg+xml"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts common image extensions even without MIME type", () => {
|
||||||
|
const formats = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif", "tiff", "ico"];
|
||||||
|
for (const ext of formats) {
|
||||||
|
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts HEIC/HEIF variants", () => {
|
||||||
|
expect(isImageFile(makeFile("photo.heic", ""))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("photo.heif", ""))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("photo.hif", ""))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts RAW camera formats", () => {
|
||||||
|
const raw = ["dng", "cr2", "cr3", "nef", "nrw", "arw", "orf", "rw2", "raf", "pef"];
|
||||||
|
for (const ext of raw) {
|
||||||
|
expect(isImageFile(makeFile(`raw.${ext}`, ""))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts professional/specialized formats", () => {
|
||||||
|
const pro = ["psd", "exr", "hdr", "tga", "eps", "dds", "qoi", "dpx", "cin"];
|
||||||
|
for (const ext of pro) {
|
||||||
|
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts JPEG 2000 variants", () => {
|
||||||
|
const jp2 = ["jp2", "j2k", "j2c", "jpc", "jpf", "jpx"];
|
||||||
|
for (const ext of jp2) {
|
||||||
|
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts netpbm/scientific formats", () => {
|
||||||
|
const pbm = ["pbm", "pgm", "ppm", "pnm", "pam", "pfm", "fits", "fit", "fts"];
|
||||||
|
for (const ext of pbm) {
|
||||||
|
expect(isImageFile(makeFile(`file.${ext}`, ""))).toBe(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is case-insensitive for extensions", () => {
|
||||||
|
expect(isImageFile(makeFile("PHOTO.HEIC", ""))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("file.PSD", ""))).toBe(true);
|
||||||
|
expect(isImageFile(makeFile("scan.Tiff", ""))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-image files", () => {
|
||||||
|
expect(isImageFile(makeFile("doc.pdf", "application/pdf"))).toBe(false);
|
||||||
|
expect(isImageFile(makeFile("data.json", "application/json"))).toBe(false);
|
||||||
|
expect(isImageFile(makeFile("script.js", "text/javascript"))).toBe(false);
|
||||||
|
expect(isImageFile(makeFile("readme.txt", "text/plain"))).toBe(false);
|
||||||
|
expect(isImageFile(makeFile("archive.zip", "application/zip"))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects files with no extension and no image MIME", () => {
|
||||||
|
expect(isImageFile(makeFile("noext", ""))).toBe(false);
|
||||||
|
expect(isImageFile(makeFile("noext", "application/octet-stream"))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Dropzone rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("Dropzone", () => {
|
||||||
|
describe("rendering", () => {
|
||||||
|
it("renders upload button and helper text", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
expect(screen.getByText("Upload")).toBeDefined();
|
||||||
|
expect(screen.getByText("Drop your images here")).toBeDefined();
|
||||||
|
expect(screen.getByText("click anywhere to browse, or paste from clipboard")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows supported formats hint", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
expect(screen.getByText("PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the drop zone section with aria label", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
expect(screen.getByLabelText("File drop zone")).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show file list when no files provided", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
expect(screen.queryByText(/files selected/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show file list for a single file", () => {
|
||||||
|
render(<Dropzone currentFiles={[makeFile("a.png")]} />);
|
||||||
|
expect(screen.queryByText(/files selected/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows file count and list when multiple files are provided", () => {
|
||||||
|
const files = [makeFile("a.png", "image/png", 2048), makeFile("b.jpg", "image/jpeg", 4096)];
|
||||||
|
render(<Dropzone currentFiles={files} />);
|
||||||
|
expect(screen.getByText("2 files selected")).toBeDefined();
|
||||||
|
expect(screen.getByText("a.png")).toBeDefined();
|
||||||
|
expect(screen.getByText("b.jpg")).toBeDefined();
|
||||||
|
expect(screen.getByText("2 KB")).toBeDefined();
|
||||||
|
expect(screen.getByText("4 KB")).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Click to upload
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("click to upload", () => {
|
||||||
|
it("opens file picker when the section is clicked", () => {
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
expect(getInput()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens file picker when the Upload button is clicked", () => {
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("Upload"));
|
||||||
|
expect(getInput()).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets multiple attribute on the file input by default", () => {
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
expect(getInput()!.multiple).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables multiple when multiple=false", () => {
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone multiple={false} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
expect(getInput()!.multiple).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sets accept attribute when accept prop is provided", () => {
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone accept="image/*" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
const input = getInput()!;
|
||||||
|
expect(input.accept).toContain("image/*");
|
||||||
|
expect(input.accept).toContain(".heic");
|
||||||
|
expect(input.accept).toContain(".psd");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onFiles when files are selected via file picker", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
const input = getInput()!;
|
||||||
|
|
||||||
|
const file = makeFile("photo.png");
|
||||||
|
Object.defineProperty(input, "files", { value: [file], configurable: true });
|
||||||
|
input.onchange!({ target: input } as unknown as Event);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls onFiles with multiple files from file picker", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
const input = getInput()!;
|
||||||
|
|
||||||
|
const files = [makeFile("a.png"), makeFile("b.jpg", "image/jpeg")];
|
||||||
|
Object.defineProperty(input, "files", { value: files, configurable: true });
|
||||||
|
input.onchange!({ target: input } as unknown as Event);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onFiles when no files are selected (dialog cancelled)", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
const getInput = spyFileInput();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
const input = getInput()!;
|
||||||
|
|
||||||
|
Object.defineProperty(input, "files", { value: [], configurable: true });
|
||||||
|
input.onchange!({ target: input } as unknown as Event);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Drag and drop
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("drag and drop", () => {
|
||||||
|
it("calls onFiles with image files on drop", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const png = makeFile("a.png", "image/png");
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([png]) });
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out non-image files on drop", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const png = makeFile("a.png", "image/png");
|
||||||
|
const pdf = makeFile("doc.pdf", "application/pdf");
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([png, pdf]) });
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onFiles when all dropped files are non-image", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const pdf = makeFile("doc.pdf", "application/pdf");
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([pdf]) });
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple image files on drop", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
makeFile("a.png", "image/png"),
|
||||||
|
makeFile("b.jpg", "image/jpeg"),
|
||||||
|
makeFile("c.webp", "image/webp"),
|
||||||
|
];
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer(files) });
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts RAW files identified by extension on drop", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const raw = makeFile("photo.cr3", "");
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([raw]) });
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([raw]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts HEIC files on drop", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const heic = makeFile("photo.heic", "");
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([heic]) });
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([heic]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts PSD files on drop", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
const psd = makeFile("design.psd", "");
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([psd]) });
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([psd]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows drag-active styling on dragenter and removes on dragleave", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
fireEvent.dragEnter(zone);
|
||||||
|
expect(zone.className).toContain("border-primary");
|
||||||
|
expect(zone.className).toContain("bg-primary/10");
|
||||||
|
|
||||||
|
fireEvent.dragLeave(zone);
|
||||||
|
expect(zone.className).not.toContain("bg-primary/10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows drag-active styling on dragover", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
fireEvent.dragOver(zone);
|
||||||
|
expect(zone.className).toContain("bg-primary/10");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes drag styling after drop", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
fireEvent.dragEnter(zone);
|
||||||
|
expect(zone.className).toContain("bg-primary/10");
|
||||||
|
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([]) });
|
||||||
|
expect(zone.className).not.toContain("bg-primary/10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clipboard paste
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("clipboard paste", () => {
|
||||||
|
it("calls onFiles when an image is pasted", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const file = makeFile("screenshot.png", "image/png");
|
||||||
|
pasteViaItems([file]);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles multiple pasted images", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const files = [makeFile("a.png", "image/png"), makeFile("b.jpg", "image/jpeg")];
|
||||||
|
pasteViaItems(files);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts pasted HEIC image", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const heic = makeFile("photo.heic", "image/heic");
|
||||||
|
pasteViaItems([heic]);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([heic]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters out non-image files from paste", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const png = makeFile("a.png", "image/png");
|
||||||
|
const txt = makeFile("notes.txt", "text/plain");
|
||||||
|
pasteViaItems([png, txt]);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not call onFiles when pasted content has no image files", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
pasteViaItems([makeFile("doc.pdf", "application/pdf")]);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores paste with no clipboardData items and no files", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const event = new Event("paste", { bubbles: true });
|
||||||
|
Object.defineProperty(event, "clipboardData", { value: { items: [], files: [] } });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores paste with no clipboardData at all", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const event = new Event("paste", { bubbles: true });
|
||||||
|
Object.defineProperty(event, "clipboardData", { value: null });
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores text-only paste (no file items)", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const event = new Event("paste", { bubbles: true });
|
||||||
|
Object.defineProperty(event, "clipboardData", {
|
||||||
|
value: {
|
||||||
|
files: [],
|
||||||
|
items: [{ kind: "string", type: "text/plain", getAsFile: () => null }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents default on paste when image files are found", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const file = makeFile("img.png", "image/png");
|
||||||
|
const event = pasteViaItems([file]);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not prevent default on paste when no image files", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const event = pasteViaItems([makeFile("doc.pdf", "application/pdf")]);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("removes paste listener on unmount", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
const { unmount } = render(<Dropzone onFiles={onFiles} />);
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
pasteViaItems([makeFile("a.png", "image/png")]);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles paste where getAsFile returns null", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const event = new Event("paste", { bubbles: true, cancelable: true });
|
||||||
|
Object.defineProperty(event, "clipboardData", {
|
||||||
|
value: {
|
||||||
|
files: [],
|
||||||
|
items: [{ kind: "file", type: "image/png", getAsFile: () => null }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Clipboard paste via clipboardData.files (macOS Finder Cmd+C)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("clipboard paste via files (Finder)", () => {
|
||||||
|
it("handles multiple files copied from Finder", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
makeFile("photo1.png", "image/png"),
|
||||||
|
makeFile("photo2.jpg", "image/jpeg"),
|
||||||
|
makeFile("photo3.webp", "image/webp"),
|
||||||
|
];
|
||||||
|
pasteViaFiles(files);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles a single file from Finder", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const file = makeFile("photo.png", "image/png");
|
||||||
|
pasteViaFiles([file]);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters non-image files from Finder paste", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const png = makeFile("photo.png", "image/png");
|
||||||
|
const pdf = makeFile("doc.pdf", "application/pdf");
|
||||||
|
pasteViaFiles([png, pdf]);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([png]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores Finder paste with only non-image files", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
pasteViaFiles([makeFile("doc.pdf", "application/pdf")]);
|
||||||
|
|
||||||
|
expect(onFiles).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts RAW and HEIC files from Finder", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const files = [
|
||||||
|
makeFile("photo.heic", ""),
|
||||||
|
makeFile("raw.cr3", ""),
|
||||||
|
makeFile("design.psd", ""),
|
||||||
|
];
|
||||||
|
pasteViaFiles(files);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith(files);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers clipboardData.files over items when both are present", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const fileFromFiles = makeFile("from-files.png", "image/png");
|
||||||
|
const fileFromItems = makeFile("from-items.png", "image/png");
|
||||||
|
|
||||||
|
const event = makePasteEvent({
|
||||||
|
files: [fileFromFiles],
|
||||||
|
items: [{ kind: "file", type: "image/png", getAsFile: () => fileFromItems }],
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([fileFromFiles]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to items when files is empty", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const file = makeFile("screenshot.png", "image/png");
|
||||||
|
const event = makePasteEvent({
|
||||||
|
files: [],
|
||||||
|
items: [{ kind: "file", type: "image/png", getAsFile: () => file }],
|
||||||
|
});
|
||||||
|
document.dispatchEvent(event);
|
||||||
|
|
||||||
|
expect(onFiles).toHaveBeenCalledWith([file]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents default when files are found via clipboardData.files", () => {
|
||||||
|
const onFiles = vi.fn();
|
||||||
|
render(<Dropzone onFiles={onFiles} />);
|
||||||
|
|
||||||
|
const event = pasteViaFiles([makeFile("photo.png", "image/png")]);
|
||||||
|
|
||||||
|
expect(event.defaultPrevented).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Compact mode
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("compact mode", () => {
|
||||||
|
it("renders without min-height in compact mode", () => {
|
||||||
|
render(<Dropzone compact />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
expect(zone.className).toContain("min-h-0");
|
||||||
|
expect(zone.className).not.toContain("min-h-[400px]");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses standard min-height in default mode", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
expect(zone.className).toContain("min-h-[400px]");
|
||||||
|
expect(zone.className).not.toContain("min-h-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// No onFiles callback (graceful no-op)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
describe("without onFiles callback", () => {
|
||||||
|
it("does not throw on drop without onFiles", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
const zone = screen.getByLabelText("File drop zone");
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.drop(zone, { dataTransfer: makeDataTransfer([makeFile("a.png")]) });
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on paste without onFiles", () => {
|
||||||
|
render(<Dropzone />);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
pasteViaItems([makeFile("a.png")]);
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not throw on click without onFiles", () => {
|
||||||
|
spyFileInput();
|
||||||
|
render(<Dropzone />);
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
fireEvent.click(screen.getByLabelText("File drop zone"));
|
||||||
|
}).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user