diff --git a/apps/api/src/lib/format-encoders.ts b/apps/api/src/lib/format-encoders.ts index 5e5c5e92..bc02b8cd 100644 --- a/apps/api/src/lib/format-encoders.ts +++ b/apps/api/src/lib/format-encoders.ts @@ -103,3 +103,31 @@ export async function encodeQoi(inputBuffer: Buffer): Promise { const encoded = qoiEncode(new Uint8Array(data), info.width, info.height, 4); return Buffer.from(encoded); } + +export async function encodeJxl(inputBuffer: Buffer, quality?: number): Promise { + 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(() => {}); + } +} diff --git a/apps/api/src/routes/pipeline.ts b/apps/api/src/routes/pipeline.ts index 80b9f158..658fd9b9 100644 --- a/apps/api/src/routes/pipeline.ts +++ b/apps/api/src/routes/pipeline.ts @@ -30,7 +30,7 @@ import { isSvgBuffer, sanitizeSvg } from "../lib/svg-sanitize.js"; import { createWorkspace } from "../lib/workspace.js"; import { hasEffectivePermission } from "../permissions.js"; import { requireAuth } from "../plugins/auth.js"; -import { type JobProgress, updateJobProgress } from "./progress.js"; +import { type JobProgress, updateJobProgress, updateSingleFileProgress } from "./progress.js"; import { getRegisteredToolIds, getToolConfig } from "./tool-factory.js"; /** Schema for a single pipeline step. */ @@ -75,6 +75,7 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise = []; + const totalSteps = pipeline.steps.length; + + const reportProgress = (percent: number, stage?: string) => { + if (!clientJobId) return; + updateSingleFileProgress({ + jobId: clientJobId, + phase: "processing", + percent, + stage, + }); + }; try { - for (let i = 0; i < pipeline.steps.length; i++) { + for (let i = 0; i < totalSteps; i++) { const step = pipeline.steps[i]; + const stepPercent = Math.round((i / totalSteps) * 90); + reportProgress(stepPercent, `Step ${i + 1}/${totalSteps}: ${step.toolId}`); // Route content-aware resize to its dedicated tool const resolvedToolId = @@ -250,6 +266,8 @@ export async function registerPipelineRoutes(app: FastifyInstance): Promise { /** Unique tool identifier, used as the URL path segment. */ @@ -112,6 +113,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig let filename = "image"; let settingsRaw: string | null = null; let fileId: string | null = null; + let clientJobId: string | null = null; let fileCount = 0; // Parse multipart parts @@ -143,6 +145,9 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig if (part.fieldname === "fileId") { fileId = part.value as string; } + if (part.fieldname === "clientJobId") { + clientJobId = part.value as string; + } } } } catch (err) { @@ -167,6 +172,18 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // mutates fileBuffer into a larger intermediate PNG. const uploadedSize = fileBuffer.length; + const reportProgress = (percent: number, stage?: string) => { + if (!clientJobId) return; + updateSingleFileProgress({ + jobId: clientJobId, + phase: "processing", + percent, + stage, + }); + }; + + reportProgress(5, "Validating..."); + // Validate the uploaded image const validation = await validateImageBuffer(fileBuffer, filename); if (!validation.valid) { @@ -178,6 +195,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // The decoded buffer is PNG, so update the filename extension to match. const isHeif = validation.format === "heif"; if (isHeif) { + reportProgress(10, "Decoding HEIC..."); try { fileBuffer = await decodeHeic(fileBuffer); const ext = filename.match(/\.[^.]+$/)?.[0]; @@ -195,6 +213,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig // Pass the original file extension so RAW decoder can use the correct // temp file suffix (e.g. .cr3, .nef) for format identification. if (needsCliDecode(validation.format)) { + reportProgress(10, "Decoding..."); try { const fileExt = filename.split(".").pop()?.toLowerCase(); fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt); @@ -225,6 +244,8 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } } + reportProgress(15, "Preparing..."); + // Parse and validate settings let settings: T; try { @@ -259,6 +280,8 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig try { let result: { buffer: Buffer; filename: string; contentType: string }; + reportProgress(20, "Processing..."); + // Offload to worker thread for non-AI tools. // Falls back to main-thread processing on any worker error. // Disabled in test environments where worker_threads can't load .ts files. @@ -299,6 +322,8 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig result = await config.process(processBuffer, settings, filename); } + reportProgress(75, "Saving..."); + // Add a tool-specific suffix to the filename so the download // doesn't silently overwrite the user's original file. // Skip if the tool already changed the filename (e.g. convert, split). @@ -362,6 +387,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig ]); let previewUrl: string | undefined; if (!BROWSER_PREVIEWABLE.has(result.contentType)) { + reportProgress(85, "Generating preview..."); try { let previewInput = result.buffer; // Sharp can't decode HEIC - use system decoder first @@ -372,8 +398,21 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const previewPath = join(workspacePath, "output", "preview.webp"); await writeFile(previewPath, previewBuffer); previewUrl = `/api/v1/download/${jobId}/preview.webp`; - } catch { - // Non-fatal - frontend will show the success card fallback + } catch (previewErr) { + request.log.warn( + { previewErr, contentType: result.contentType, toolId: config.toolId }, + "Failed to generate preview thumbnail, falling back to input buffer", + ); + // Retry with the original input buffer (pre-processing) which + // was already validated and decoded during the intake phase. + try { + const fallbackBuffer = await sharp(fileBuffer).webp({ quality: 80 }).toBuffer(); + const previewPath = join(workspacePath, "output", "preview.webp"); + await writeFile(previewPath, fallbackBuffer); + previewUrl = `/api/v1/download/${jobId}/preview.webp`; + } catch { + // Both attempts failed - frontend will use the upload preview as fallback + } } } @@ -381,6 +420,8 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const inputPath = join(workspacePath, "input", filename); await writeFile(inputPath, fileBuffer); + reportProgress(95, "Finishing..."); + // Auto-save to persistent file store when a fileId is provided let savedFileId: string | undefined; if (fileId) { diff --git a/apps/api/src/routes/tools/collage.ts b/apps/api/src/routes/tools/collage.ts index 00fad6a9..ea24770f 100644 --- a/apps/api/src/routes/tools/collage.ts +++ b/apps/api/src/routes/tools/collage.ts @@ -8,6 +8,7 @@ import { autoOrient } from "../../lib/auto-orient.js"; import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; +import { encodeJxl } from "../../lib/format-encoders.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -643,7 +644,7 @@ export function registerCollage(app: FastifyInstance) { outputExt = "avif"; break; case "jxl": - pipeline = pipeline.jxl({ quality: settings.quality }); + pipeline = pipeline.png(); outputExt = "jxl"; break; default: @@ -653,18 +654,19 @@ export function registerCollage(app: FastifyInstance) { } const result = await pipeline.toBuffer(); + const finalBuffer = outputExt === "jxl" ? await encodeJxl(result, settings.quality) : result; const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); const filename = `collage.${outputExt}`; const outputPath = join(workspacePath, "output", filename); - await writeFile(outputPath, result); + await writeFile(outputPath, finalBuffer); return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${filename}`, originalSize: files.reduce((s, f) => s + f.buffer.length, 0), - processedSize: result.length, + processedSize: finalBuffer.length, }); } catch (err) { return reply.status(422).send({ diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts index 093b4723..a3f278fc 100644 --- a/apps/api/src/routes/tools/convert.ts +++ b/apps/api/src/routes/tools/convert.ts @@ -8,7 +8,13 @@ import { convert } from "@snapotter/image-engine"; import type { FastifyInstance } from "fastify"; import sharp from "sharp"; import { z } from "zod"; -import { encodeBmp, encodeIco, encodeJp2, encodeQoi } from "../../lib/format-encoders.js"; +import { + encodeBmp, + encodeIco, + encodeJp2, + encodeJxl, + encodeQoi, +} from "../../lib/format-encoders.js"; import { encodeHeic } from "../../lib/heic-converter.js"; import { isSvgBuffer } from "../../lib/svg-sanitize.js"; import { createToolRoute } from "../tool-factory.js"; @@ -56,6 +62,7 @@ const CLI_ENCODERS: Record Promise; async function processImage(inputBuffer: Buffer, settings: Settings, filename: string) { + const isJxl = settings.format === "jxl"; + const engineSettings = isJxl ? { ...settings, format: "png" as const } : settings; + const image = sharp(inputBuffer); - const result = await optimizeForWeb(image, settings); - const buffer = await result.toBuffer(); + const result = await optimizeForWeb(image, engineSettings); + let buffer = await result.toBuffer(); + + if (isJxl) { + buffer = await encodeJxl(buffer, settings.quality); + } const ext = extname(filename); const baseName = ext ? filename.slice(0, -ext.length) : filename; diff --git a/apps/api/src/routes/tools/pdf-to-image.ts b/apps/api/src/routes/tools/pdf-to-image.ts index 8e6fcd9d..7e156bcf 100644 --- a/apps/api/src/routes/tools/pdf-to-image.ts +++ b/apps/api/src/routes/tools/pdf-to-image.ts @@ -9,6 +9,7 @@ import sharp from "sharp"; import { z } from "zod"; import { env } from "../../config.js"; import { formatZodErrors } from "../../lib/errors.js"; +import { encodeJxl } from "../../lib/format-encoders.js"; import { encodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -117,8 +118,10 @@ async function convertWithSharp( return s.tiff().toBuffer(); case "gif": return s.gif().toBuffer(); - case "jxl": - return s.jxl({ quality }).toBuffer(); + case "jxl": { + const pngBuf = await s.png().toBuffer(); + return encodeJxl(pngBuf, quality); + } case "heic": case "heif": { const pngBuf = await s.png().toBuffer(); diff --git a/apps/api/src/routes/tools/split.ts b/apps/api/src/routes/tools/split.ts index 6a062790..0c4cfdd5 100644 --- a/apps/api/src/routes/tools/split.ts +++ b/apps/api/src/routes/tools/split.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { formatZodErrors } from "../../lib/errors.js"; import { sanitizeFilename } from "../../lib/filename.js"; +import { encodeJxl } from "../../lib/format-encoders.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { registerToolProcessFn } from "../tool-factory.js"; @@ -31,7 +32,7 @@ function resolveOutputFormat( jpg: { sharpFormat: "jpeg", ext: ".jpg" }, webp: { sharpFormat: "webp", ext: ".webp" }, avif: { sharpFormat: "avif", ext: ".avif" }, - jxl: { sharpFormat: "jxl", ext: ".jxl" }, + jxl: { sharpFormat: "png", ext: ".jxl" }, }; return map[outputFormat] ?? { sharpFormat: null, ext: originalExt }; } @@ -150,7 +151,11 @@ export function registerSplit(app: FastifyInstance) { } const partBuffer = await pipeline.toBuffer(); - archive.append(partBuffer, { + const finalBuffer = + settings.outputFormat === "jxl" + ? await encodeJxl(partBuffer, settings.quality) + : partBuffer; + archive.append(finalBuffer, { name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`, }); } @@ -236,7 +241,11 @@ export function registerSplit(app: FastifyInstance) { } const partBuffer = await pipeline.toBuffer(); - archive.append(partBuffer, { + const finalBuffer = + settings.outputFormat === "jxl" + ? await encodeJxl(partBuffer, settings.quality) + : partBuffer; + archive.append(finalBuffer, { name: `${baseName}_r${row + 1}_c${col + 1}${outputExt}`, }); } diff --git a/apps/api/src/routes/tools/stitch.ts b/apps/api/src/routes/tools/stitch.ts index fb7d189c..dbed5d96 100644 --- a/apps/api/src/routes/tools/stitch.ts +++ b/apps/api/src/routes/tools/stitch.ts @@ -9,6 +9,7 @@ import { autoOrient } from "../../lib/auto-orient.js"; import { formatZodErrors } from "../../lib/errors.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; +import { encodeJxl } from "../../lib/format-encoders.js"; import { ensureSharpCompat } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -204,13 +205,17 @@ export function registerStitch(app: FastifyInstance) { } else if (settings.format === "avif") { pipeline = pipeline.avif({ quality: settings.quality, effort: 4 }); } else if (settings.format === "jxl") { - pipeline = pipeline.jxl({ quality: settings.quality }); + pipeline = pipeline.png(); } else { pipeline = pipeline.png(); } let result = await pipeline.toBuffer(); + if (settings.format === "jxl") { + result = await encodeJxl(result, settings.quality); + } + if (settings.cornerRadius > 0) { const meta = await sharp(result).metadata(); if (!meta.width || !meta.height) throw new Error("Cannot read image dimensions"); @@ -238,7 +243,7 @@ export function registerStitch(app: FastifyInstance) { } else if (settings.format === "avif") { result = await sharp(result).avif({ quality: settings.quality, effort: 4 }).toBuffer(); } else if (settings.format === "jxl") { - result = await sharp(result).jxl({ quality: settings.quality }).toBuffer(); + result = await encodeJxl(result, settings.quality); } } diff --git a/apps/api/src/routes/tools/svg-to-raster.ts b/apps/api/src/routes/tools/svg-to-raster.ts index 9e340671..f57b7b84 100644 --- a/apps/api/src/routes/tools/svg-to-raster.ts +++ b/apps/api/src/routes/tools/svg-to-raster.ts @@ -10,6 +10,7 @@ import { env } from "../../config.js"; import { resolveConcurrency } from "../../lib/env.js"; import { formatZodErrors } from "../../lib/errors.js"; import { sanitizeFilename } from "../../lib/filename.js"; +import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; import { isSvgBuffer, sanitizeSvg } from "../../lib/svg-sanitize.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -77,10 +78,12 @@ async function convertSvg( buffer = await image.gif().toBuffer(); ext = "gif"; break; - case "jxl": - buffer = await image.jxl({ quality: settings.quality }).toBuffer(); + case "jxl": { + const pngBuf = await image.png().toBuffer(); + buffer = await encodeJxl(pngBuf, settings.quality); ext = "jxl"; break; + } case "heif": { const pngBuffer = await image.png().toBuffer(); buffer = await encodeHeic(pngBuffer, settings.quality); diff --git a/apps/api/src/routes/tools/upscale.ts b/apps/api/src/routes/tools/upscale.ts index a82798b9..e3d19194 100644 --- a/apps/api/src/routes/tools/upscale.ts +++ b/apps/api/src/routes/tools/upscale.ts @@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; import { sanitizeFilename } from "../../lib/filename.js"; import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js"; +import { encodeJxl } from "../../lib/format-encoders.js"; import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js"; import { resolveOutputFormat } from "../../lib/output-format.js"; import { createWorkspace } from "../../lib/workspace.js"; @@ -186,7 +187,7 @@ export function registerUpscale(app: FastifyInstance) { outputBuffer = await encodeHeic(result.buffer, outputQuality); finalFormat = format; } else if (format === "jxl") { - outputBuffer = await sharp(result.buffer).jxl({ quality: outputQuality }).toBuffer(); + outputBuffer = await encodeJxl(result.buffer, outputQuality); finalFormat = "jxl"; } else if (format === "avif") { outputBuffer = await sharp(result.buffer).avif({ quality: outputQuality }).toBuffer(); diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index b4f614ab..f99acc6e 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -1,5 +1,5 @@ -import { FileImage, Upload } from "lucide-react"; -import { type DragEvent, useCallback, useState } from "react"; +import { FileImage, ImageUp, Upload } from "lucide-react"; +import { type DragEvent, useCallback, useEffect, useState } from "react"; import { cn } from "@/lib/utils"; const IMAGE_EXTENSIONS = new Set([ @@ -134,6 +134,35 @@ export function Dropzone({ input.click(); }; + useEffect(() => { + const handlePaste = (e: ClipboardEvent) => { + const clip = e.clipboardData; + if (!clip) return; + + const files: File[] = []; + + if (clip.files.length > 0) { + for (const file of clip.files) { + if (isImageFile(file)) files.push(file); + } + } else if (clip.items) { + for (const item of clip.items) { + if (item.kind === "file") { + const file = item.getAsFile(); + if (file && isImageFile(file)) files.push(file); + } + } + } + + if (files.length > 0) { + e.preventDefault(); + onFiles?.(files); + } + }; + document.addEventListener("paste", handlePaste); + return () => document.removeEventListener("paste", handlePaste); + }, [onFiles]); + const hasMultipleFiles = currentFiles.length > 1; return ( @@ -143,31 +172,60 @@ export function Dropzone({ onDragOver={handleDrag} onDragLeave={handleDrag} onDrop={handleDrop} + onClick={handleClick} className={cn( - "flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-colors mx-auto max-w-2xl w-full", + "group flex flex-col items-center justify-center rounded-2xl border-2 border-dashed transition-all duration-200 mx-auto max-w-2xl w-full cursor-pointer", compact ? "min-h-0 h-full" : "min-h-[400px]", isDragging - ? "border-primary bg-primary/5" - : "border-border bg-muted/30 hover:border-primary/50 hover:bg-muted/50", + ? "border-primary bg-primary/10 scale-[1.01]" + : "border-border/60 bg-muted/20 hover:border-primary/40 hover:bg-muted/40", )} > -
-
- SnapOtter +
+
+ +
+
+

+ Drop your images here +

+

+ click anywhere to browse, or paste from clipboard +

-

Drop files here or click the upload button

+

+ PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats +

- {/* Show file count badge and list when multiple files are dropped */} {hasMultipleFiles && ( -
+
{currentFiles.length} files selected diff --git a/apps/web/src/components/common/multi-image-viewer.tsx b/apps/web/src/components/common/multi-image-viewer.tsx index 45aacef0..ab78595d 100644 --- a/apps/web/src/components/common/multi-image-viewer.tsx +++ b/apps/web/src/components/common/multi-image-viewer.tsx @@ -51,7 +51,12 @@ export function MultiImageViewer() { hasProcessed && currentEntry.processedUrl ? canBrowserPreview(currentEntry.processedUrl) : false; - const displayUrl = currentEntry.processedPreviewUrl ?? currentEntry.processedUrl; + const processedRenderable = + currentEntry.processedUrl && canBrowserPreview(currentEntry.processedUrl) + ? currentEntry.processedUrl + : null; + const displayUrl = + currentEntry.processedPreviewUrl ?? processedRenderable ?? currentEntry.blobUrl; const processedFilename = currentEntry.processedUrl ? decodeURIComponent(currentEntry.processedUrl.split("/").pop() ?? "processed") @@ -77,7 +82,10 @@ export function MultiImageViewer() { )}
- {hasProcessed && !isPreviewable && !currentEntry.processedPreviewUrl ? ( + {hasProcessed && + !isPreviewable && + !currentEntry.processedPreviewUrl && + !currentEntry.blobUrl ? (
diff --git a/apps/web/src/components/common/progress-card.tsx b/apps/web/src/components/common/progress-card.tsx index a31ad2e9..a8ad84be 100644 --- a/apps/web/src/components/common/progress-card.tsx +++ b/apps/web/src/components/common/progress-card.tsx @@ -12,9 +12,6 @@ interface ProgressCardProps { export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ProgressCardProps) { if (!active) return null; - // No real-time server progress: non-AI tools sit at 100% while the server works - const isIndeterminate = phase === "processing" && percent >= 100; - const icon = phase === "uploading" ? ( @@ -22,8 +19,7 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }: ); - const slowHint = phase === "processing" && elapsed >= 10 ? "This may take a moment" : undefined; - const sublabel = [stage, slowHint, `${elapsed}s`].filter(Boolean).join(" \u00b7 "); + const sublabel = [stage, `${elapsed}s`].filter(Boolean).join(" · "); return (
@@ -41,7 +37,7 @@ export function ProgressCard({ active, phase, label, stage, percent, elapsed }:
diff --git a/apps/web/src/components/common/thumbnail-strip.tsx b/apps/web/src/components/common/thumbnail-strip.tsx index 4d43e847..87e89d13 100644 --- a/apps/web/src/components/common/thumbnail-strip.tsx +++ b/apps/web/src/components/common/thumbnail-strip.tsx @@ -2,6 +2,18 @@ import { CheckCircle2, Loader2, XCircle } from "lucide-react"; import { useEffect, useRef } from "react"; import type { FileEntry } from "@/stores/file-store"; +const BROWSER_IMG_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "avif"]); + +function thumbnailSrc(entry: FileEntry): string { + if (entry.processedPreviewUrl) return entry.processedPreviewUrl; + if (entry.processedUrl) { + if (entry.processedUrl.startsWith("blob:")) return entry.processedUrl; + const ext = decodeURIComponent(entry.processedUrl).split(".").pop()?.toLowerCase() ?? ""; + if (BROWSER_IMG_EXTS.has(ext)) return entry.processedUrl; + } + return entry.blobUrl; +} + interface ThumbnailStripProps { entries: FileEntry[]; selectedIndex: number; @@ -50,7 +62,7 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
) : ( {entry.file.name} = { @@ -42,7 +42,7 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo const [fit, setFit] = useState("cover"); const [lockAspect, setLockAspect] = useState(true); const [withoutEnlargement, setWithoutEnlargement] = useState(false); - const [contentAware, setContentAware] = useState(false); + const contentAware = tab === "content-aware"; const [protectFaces, setProtectFaces] = useState(false); const [blurRadius, setBlurRadius] = useState(4); const [sobelThreshold, setSobelThreshold] = useState(2); @@ -58,17 +58,14 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo if (initialSettings.fit != null) setFit(initialSettings.fit as FitMode); if (initialSettings.withoutEnlargement != null) setWithoutEnlargement(Boolean(initialSettings.withoutEnlargement)); - if (initialSettings.contentAware != null) - setContentAware(Boolean(initialSettings.contentAware)); if (initialSettings.protectFaces != null) setProtectFaces(Boolean(initialSettings.protectFaces)); if (initialSettings.blurRadius != null) setBlurRadius(Number(initialSettings.blurRadius)); if (initialSettings.sobelThreshold != null) setSobelThreshold(Number(initialSettings.sobelThreshold)); if (initialSettings.square != null) setSquareMode(Boolean(initialSettings.square)); - // Infer tab from settings - if (initialSettings.percentage != null) setTab("scale"); - else if (initialSettings.contentAware) setTab("custom"); + if (initialSettings.contentAware) setTab("content-aware"); + else if (initialSettings.percentage != null) setTab("scale"); }, [initialSettings]); const onChangeRef = useRef(onChange); @@ -104,7 +101,6 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo percentage, fit, withoutEnlargement, - contentAware, protectFaces, blurRadius, sobelThreshold, @@ -183,218 +179,191 @@ export function ResizeControls({ settings: initialSettings, onChange }: ResizeCo return (
- {/* Standard resize tabs */} - {!contentAware && ( - <> - {/* Tab selector */} -
-
- - - -
-
- - {/* Presets tab */} - {tab === "presets" && ( -
- {platforms.map((platform) => ( -
-

{platform}

-
- {SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { - const key = `${preset.platform}-${preset.name}`; - const isSelected = selectedPreset === key; - return ( - - ); - })} -
-
- ))} - - {enlargementCheckbox} -
- )} - - {/* Custom Size tab */} - {tab === "custom" && ( -
- {dimensionInputs} - - {/* Fit mode */} -
-

Fit Mode

-
- {(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( - - ))} -
-
- - {enlargementCheckbox} -
- )} - - {/* Scale tab */} - {tab === "scale" && ( -
-
- - setPercentage(e.target.value)} - min={1} - className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" - /> -
-
- {[25, 50, 75].map((pct) => ( - - ))} -
-
- )} - - )} - - {/* Content-aware section - positioned below standard resize */} -
-
-
- Content-aware -
+ {/* Tab selector */} +
+
+ + +
+
- {/* Content-aware options (expanded when toggled) */} - {contentAware && ( -
- {/* Dimensions */} - {dimensionInputs} - - {/* Square mode */} - - - {/* Face protection */} - - - {/* Blur radius */} -
-
- - {blurRadius} + {/* Presets tab */} + {tab === "presets" && ( +
+ {platforms.map((platform) => ( +
+

{platform}

+
+ {SOCIAL_MEDIA_PRESETS.filter((p) => p.platform === platform).map((preset) => { + const key = `${preset.platform}-${preset.name}`; + const isSelected = selectedPreset === key; + return ( + + ); + })}
- setBlurRadius(Number(e.target.value))} - className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary" - />
+ ))} - {/* Sobel threshold */} -
-
- - {sobelThreshold} -
- setSobelThreshold(Number(e.target.value))} - className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary" - /> + {enlargementCheckbox} +
+ )} + + {/* Custom Size tab */} + {tab === "custom" && ( +
+ {dimensionInputs} + + {/* Fit mode */} +
+

Fit Mode

+
+ {(Object.keys(FIT_LABELS) as FitMode[]).map((f) => ( + + ))}
- )} -
+ + {enlargementCheckbox} +
+ )} + + {/* Scale tab */} + {tab === "scale" && ( +
+
+ + setPercentage(e.target.value)} + min={1} + className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground" + /> +
+
+ {[25, 50, 75].map((pct) => ( + + ))} +
+
+ )} + + {/* Content-aware tab */} + {contentAware && ( +
+ {dimensionInputs} + + {/* Square mode */} + + + {/* Face protection */} + + + {/* Blur radius */} +
+
+ + {blurRadius} +
+ setBlurRadius(Number(e.target.value))} + className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary" + /> +
+ + {/* Sobel threshold */} +
+
+ + {sobelThreshold} +
+ setSobelThreshold(Number(e.target.value))} + className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary" + /> +
+
+ )}
); } diff --git a/apps/web/src/hooks/use-pipeline-processor.ts b/apps/web/src/hooks/use-pipeline-processor.ts index 806a5fe9..d39f40d6 100644 --- a/apps/web/src/hooks/use-pipeline-processor.ts +++ b/apps/web/src/hooks/use-pipeline-processor.ts @@ -26,6 +26,8 @@ const IDLE_PROGRESS: PipelineProgress = { elapsed: 0, }; +const UPLOAD_WEIGHT = 15; + export function usePipelineProcessor() { const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } = useFileStore(); @@ -35,13 +37,11 @@ export function usePipelineProcessor() { const xhrRef = useRef(null); const eventSourceRef = useRef(null); const abortRef = useRef(null); - const processingTimerRef = useRef | null>(null); // Clean up on unmount useEffect(() => { return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (eventSourceRef.current) eventSourceRef.current.close(); if (xhrRef.current) xhrRef.current.abort(); if (abortRef.current) abortRef.current.abort(); @@ -50,12 +50,9 @@ export function usePipelineProcessor() { const processSingle = useCallback( (file: File, steps: PipelineStep[]) => { - // Capture the file index at request time so results are written - // to the correct entry even if the user navigates away. const capturedIndex = useFileStore.getState().selectedIndex; setError(null); - // Mark the target entry as processing and clear any old result useFileStore.getState().updateEntry(capturedIndex, { processedUrl: null, processedPreviewUrl: null, @@ -66,7 +63,6 @@ export function usePipelineProcessor() { setProcessing(true); setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); - // Start elapsed timer const startTime = Date.now(); elapsedRef.current = setInterval(() => { setProgress((prev) => ({ @@ -75,7 +71,40 @@ export function usePipelineProcessor() { })); }, 1000); - // Build pipeline payload + const clientJobId = generateId(); + + // Open SSE for real-time progress from the server + try { + const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); + eventSourceRef.current = es; + + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type !== "single") return; + + if (typeof data.percent === "number") { + const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT); + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: Math.max(prev.percent, scaled), + stage: data.stage, + })); + } + } catch { + // Ignore malformed SSE + } + }; + + es.onerror = () => { + es.close(); + eventSourceRef.current = null; + }; + } catch { + // EventSource creation failed -- proceed without SSE + } + const pipeline = { steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })), }; @@ -83,17 +112,13 @@ export function usePipelineProcessor() { const formData = new FormData(); formData.append("file", file); formData.append("pipeline", JSON.stringify(pipeline)); + formData.append("clientJobId", clientJobId); - // Use XHR for upload progress tracking const xhr = new XMLHttpRequest(); xhrRef.current = xhr; - // Pipeline runs multiple steps sequentially, allow up to 10 minutes xhr.timeout = 600_000; - // Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95% - const UPLOAD_WEIGHT = 40; - xhr.upload.onprogress = (event) => { if (event.lengthComputable) { const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT; @@ -111,23 +136,14 @@ export function usePipelineProcessor() { percent: UPLOAD_WEIGHT, stage: "Processing...", })); - - // Gradually fill from upload weight to 95% over ~45s - const start = UPLOAD_WEIGHT; - const target = 95; - const step = (target - start) / 90; // 90 ticks over ~45s - processingTimerRef.current = setInterval(() => { - setProgress((prev) => { - if (prev.phase !== "processing") return prev; - const next = Math.min(target, prev.percent + step); - return { ...prev, percent: next }; - }); - }, 500); }; xhr.onload = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } if (xhr.status >= 200 && xhr.status < 300) { try { @@ -166,7 +182,10 @@ export function usePipelineProcessor() { xhr.onerror = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } setError("Network error - check your connection"); setProcessing(false); setProgress(IDLE_PROGRESS); @@ -174,7 +193,10 @@ export function usePipelineProcessor() { xhr.ontimeout = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); + if (eventSourceRef.current) { + eventSourceRef.current.close(); + eventSourceRef.current = null; + } setError("Request timed out - the server may be overloaded. Try again."); setProcessing(false); setProgress(IDLE_PROGRESS); diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index 1bc59a69..fef13d95 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -27,13 +27,10 @@ const IDLE_PROGRESS: ToolProgress = { elapsed: 0, }; -// AI tools that go through Python/bridge.ts and can emit SSE progress. -// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded. +// AI tools return 202 and deliver results via SSE (not XHR response). const AI_PYTHON_TOOLS = new Set(PYTHON_SIDECAR_TOOLS); -// Tools that take a few seconds (not instant like Sharp, not minutes like AI). -// Uses a smoother progress: upload 0-40%, then a gradual fill during processing. -const MEDIUM_TOOLS = new Set(["content-aware-resize", "convert"]); +const UPLOAD_WEIGHT = 15; export function useToolProcessor(toolId: string) { const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } = @@ -47,15 +44,12 @@ export function useToolProcessor(toolId: string) { const abortRef = useRef(null); const isAiTool = AI_PYTHON_TOOLS.has(toolId); - const isMediumTool = MEDIUM_TOOLS.has(toolId); const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId; - const processingTimerRef = useRef | null>(null); // Clean up on unmount useEffect(() => { return () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (eventSourceRef.current) eventSourceRef.current.close(); if (xhrRef.current) xhrRef.current.abort(); if (abortRef.current) abortRef.current.abort(); @@ -69,13 +63,10 @@ export function useToolProcessor(toolId: string) { return; } - // Capture the file index at request time so results are written - // to the correct entry even if the user navigates away. const capturedIndex = useFileStore.getState().selectedIndex; setError(null); setWarning(null); - // Mark the target entry as processing and clear any old result useFileStore.getState().updateEntry(capturedIndex, { processedUrl: null, processedPreviewUrl: null, @@ -86,7 +77,6 @@ export function useToolProcessor(toolId: string) { setProcessing(true); setProgress({ phase: "uploading", percent: 0, elapsed: 0 }); - // Start elapsed timer const startTime = Date.now(); elapsedRef.current = setInterval(() => { setProgress((prev) => ({ @@ -95,80 +85,76 @@ export function useToolProcessor(toolId: string) { })); }, 1000); - // Generate client job ID for SSE correlation const clientJobId = generateId(); let asyncMode = false; - // For AI tools, open SSE before uploading - if (isAiTool) { - try { - const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); - eventSourceRef.current = es; + // Open SSE for real-time progress from the server (all tools) + try { + const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`); + eventSourceRef.current = es; - es.onmessage = (event) => { - try { - const data = JSON.parse(event.data); - if (data.type !== "single") return; + es.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type !== "single") return; - if (data.phase === "complete" && data.result) { - if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); - es.close(); - eventSourceRef.current = null; - - const result = data.result as ProcessResult; - setWarning(result.warning ?? null); - useFileStore.getState().updateEntry(capturedIndex, { - processedUrl: result.downloadUrl, - processedPreviewUrl: result.previewUrl ?? null, - processedFilename: null, - status: "completed", - originalSize: result.originalSize, - processedSize: result.processedSize, - ...(result.savedFileId ? { serverFileId: result.savedFileId } : {}), - }); - setProcessing(false); - setProgress(IDLE_PROGRESS); - return; - } - - if (data.phase === "failed" && asyncMode) { - if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); - es.close(); - eventSourceRef.current = null; - setError(data.error || "Processing failed"); - setProcessing(false); - setProgress(IDLE_PROGRESS); - return; - } - - if (typeof data.percent === "number") { - const scaled = 15 + (data.percent / 100) * 85; - setProgress((prev) => ({ - ...prev, - phase: "processing", - percent: Math.max(prev.percent, scaled), - stage: data.stage, - })); - } - } catch { - // Ignore malformed SSE - } - }; - - es.onerror = () => { - if (!asyncMode) { + // AI tools deliver results via SSE (they return 202 from the XHR) + if (data.phase === "complete" && data.result) { + if (elapsedRef.current) clearInterval(elapsedRef.current); es.close(); eventSourceRef.current = null; + + const result = data.result as ProcessResult; + setWarning(result.warning ?? null); + useFileStore.getState().updateEntry(capturedIndex, { + processedUrl: result.downloadUrl, + processedPreviewUrl: result.previewUrl ?? null, + processedFilename: null, + status: "completed", + originalSize: result.originalSize, + processedSize: result.processedSize, + ...(result.savedFileId ? { serverFileId: result.savedFileId } : {}), + }); + setProcessing(false); + setProgress(IDLE_PROGRESS); + return; } - }; - } catch { - // EventSource creation failed -- proceed without SSE - } + + if (data.phase === "failed" && asyncMode) { + if (elapsedRef.current) clearInterval(elapsedRef.current); + es.close(); + eventSourceRef.current = null; + setError(data.error || "Processing failed"); + setProcessing(false); + setProgress(IDLE_PROGRESS); + return; + } + + if (typeof data.percent === "number") { + const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT); + setProgress((prev) => ({ + ...prev, + phase: "processing", + percent: Math.max(prev.percent, scaled), + stage: data.stage, + })); + } + } catch { + // Ignore malformed SSE + } + }; + + es.onerror = () => { + if (!asyncMode) { + es.close(); + eventSourceRef.current = null; + } + }; + } catch { + // EventSource creation failed -- proceed without SSE } - // Build form data - extract any File objects from settings before JSON serialization + // Build form data const cleanSettings = { ...settings }; const bgImageFile = cleanSettings._bgImageFile as File | undefined; delete cleanSettings._bgImageFile; @@ -179,27 +165,17 @@ export function useToolProcessor(toolId: string) { if (bgImageFile) { formData.append("backgroundImage", bgImageFile); } - if (isAiTool) { - formData.append("clientJobId", clientJobId); - } + formData.append("clientJobId", clientJobId); - // If this file came from the Files page, include its ID for version tracking const capturedEntry = useFileStore.getState().entries[capturedIndex]; if (capturedEntry?.serverFileId) { formData.append("fileId", capturedEntry.serverFileId); } - // Use XHR for upload progress tracking const xhr = new XMLHttpRequest(); xhrRef.current = xhr; - // Timeout: 2 min for fast tools, 5 min for medium (seam carving), 10 min for AI - xhr.timeout = isAiTool ? 600_000 : isMediumTool ? 300_000 : 120_000; - - // For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven) - // For medium tools: upload = 0-40%, processing = 40-95% (gradual fill) - // For fast tools: upload = 0-100%, processing = brief 100% hold - const UPLOAD_WEIGHT = isAiTool ? 15 : isMediumTool ? 40 : 100; + xhr.timeout = isAiTool ? 600_000 : 120_000; xhr.upload.onprogress = (event) => { if (event.lengthComputable) { @@ -216,36 +192,8 @@ export function useToolProcessor(toolId: string) { ...prev, phase: "processing", percent: UPLOAD_WEIGHT, - stage: isAiTool ? "Starting..." : "Processing...", + stage: "Processing...", })); - - // Medium tools: gradually fill from upload weight to 95% over ~45s - if (isMediumTool) { - const start = UPLOAD_WEIGHT; - const target = 95; - const step = (target - start) / 90; // 90 ticks over ~45s - processingTimerRef.current = setInterval(() => { - setProgress((prev) => { - if (prev.phase !== "processing") return prev; - const next = Math.min(target, prev.percent + step); - return { ...prev, percent: next }; - }); - }, 500); - } - - // AI tools: asymptotic fill during long processing gaps. - // Slowly creeps toward 88% so the bar never stalls visually. - // Real SSE events always win via Math.max in the handler. - if (isAiTool) { - processingTimerRef.current = setInterval(() => { - setProgress((prev) => { - if (prev.phase !== "processing") return prev; - const remaining = 88 - prev.percent; - if (remaining <= 0.5) return prev; - return { ...prev, percent: prev.percent + remaining * 0.015 }; - }); - }, 1000); - } }; xhr.onload = () => { @@ -255,7 +203,6 @@ export function useToolProcessor(toolId: string) { } if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; @@ -299,7 +246,6 @@ export function useToolProcessor(toolId: string) { xhr.onerror = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; @@ -311,7 +257,6 @@ export function useToolProcessor(toolId: string) { xhr.ontimeout = () => { if (elapsedRef.current) clearInterval(elapsedRef.current); - if (processingTimerRef.current) clearInterval(processingTimerRef.current); if (eventSourceRef.current) { eventSourceRef.current.close(); eventSourceRef.current = null; @@ -327,7 +272,7 @@ export function useToolProcessor(toolId: string) { }); xhr.send(formData); }, - [toolId, isAiTool, isMediumTool, setProcessing, setError, toolName], + [toolId, isAiTool, setProcessing, setError, toolName], ); const processAllFiles = useCallback( diff --git a/packages/ai/src/seam-carving.ts b/packages/ai/src/seam-carving.ts index 8e433a11..3bd992bb 100644 --- a/packages/ai/src/seam-carving.ts +++ b/packages/ai/src/seam-carving.ts @@ -76,25 +76,45 @@ export async function seamCarve( ); } - const targetW = options.width ?? width; - const targetH = options.height ?? height; - const wRatio = targetW / width; - const hRatio = targetH / height; - if (wRatio < 0.25 || hRatio < 0.25) { - throw new Error( - `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.`, - ); + let targetW = options.width ?? width; + let targetH = options.height ?? height; + + if (options.square) { + const shortest = Math.min(options.width ?? width, options.height ?? height, width, height); + targetW = shortest; + 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); const args = ["-in", inputPath, "-out", outputPath, "-preview=false"]; if (options.square) { - const shortest = Math.min(width, height); - args.push("-square", "-width", String(shortest), "-height", String(shortest)); + const shortest = Math.min(currentW, currentH); + const caireTarget = Math.min(shortest, targetW); + args.push("-square", "-width", String(caireTarget), "-height", String(caireTarget)); } else { if (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.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 }); const buffer = await readFile(outputPath); diff --git a/tests/integration/convert-formats.test.ts b/tests/integration/convert-formats.test.ts index 7a4b3199..91017e0c 100644 --- a/tests/integration/convert-formats.test.ts +++ b/tests/integration/convert-formats.test.ts @@ -14,8 +14,27 @@ import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from const FIXTURES = join(__dirname, "..", "fixtures"); -// Output formats accepted by the convert tool -const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const; +// All output formats accepted by the convert tool +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 @@ -108,8 +127,10 @@ describe("Format conversion matrix", () => { body: payload, }); - // HEIC encode/decode requires libheif which may not be installed (Windows, some Linux) - if (res.statusCode === 422 && (inputFmt === "heic" || outputFmt === "heic")) return; + // CLI-encoded formats may not have their encoder installed in every environment + if (res.statusCode === 422 && (inputFmt === "heic" || CLI_ENCODED_FORMATS.has(outputFmt))) { + return; + } expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.downloadUrl).toContain(`.${outputFmt}`); @@ -146,8 +167,8 @@ describe("SVG via convert tool", () => { body: payload, }); - // HEIC encode/decode requires libheif which may not be installed (Windows, some Linux) - if (res.statusCode === 422 && outputFmt === "heic") return; + // CLI-encoded formats may not have their encoder installed in every environment + if (res.statusCode === 422 && CLI_ENCODED_FORMATS.has(outputFmt)) return; expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.downloadUrl).toContain(`.${outputFmt}`); diff --git a/tests/unit/ai/seam-carving.test.ts b/tests/unit/ai/seam-carving.test.ts index 9ec929d4..0764eeb9 100644 --- a/tests/unit/ai/seam-carving.test.ts +++ b/tests/unit/ai/seam-carving.test.ts @@ -5,6 +5,7 @@ vi.mock("sharp", () => { const mockSharp = vi.fn(() => ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), })); @@ -44,6 +45,7 @@ beforeEach(() => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), }) as unknown as ReturnType, @@ -71,6 +73,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), }) as unknown as ReturnType, @@ -222,6 +225,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), // 4000x3000 = 12MP, should give timeout > 120s metadata: vi.fn().mockResolvedValue({ width: 4000, height: 3000 }), @@ -257,6 +261,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: undefined, height: undefined }), }) as unknown as ReturnType, @@ -275,6 +280,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), // 6000x5000 = 30 MP, exceeds 25 MP limit 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( () => ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), 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, ); 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 })).rejects.toThrow( - "cannot reduce dimensions by more than 75%", - ); + await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { width: 100 })).resolves.toBeDefined(); }); - it("throws when height reduction exceeds 75%", async () => { + it("pre-resizes when height reduction exceeds 75%", 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().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, ); 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 })).rejects.toThrow( - "cannot reduce dimensions by more than 75%", + await expect(seamCarve(FAKE_INPUT, FAKE_OUTPUT_DIR, { height: 100 })).resolves.toBeDefined(); + }); + + 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, ); + + 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 () => { @@ -382,6 +421,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }), }) as unknown as ReturnType, @@ -397,6 +437,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 6000, height: 5000 }), }) as unknown as ReturnType, @@ -440,6 +481,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 1200, height: 400 }), }) as unknown as ReturnType, @@ -461,6 +503,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockRejectedValue(new Error("Corrupt file header")), }) as unknown as ReturnType, @@ -476,6 +519,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockRejectedValue(new Error("JPEG encode failed")), metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), }) as unknown as ReturnType, @@ -520,6 +564,7 @@ describe("seamCarve", () => { ({ png: vi.fn().mockReturnThis(), jpeg: vi.fn().mockReturnThis(), + resize: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(Buffer.from("mock-jpeg-data")), metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), }) as unknown as ReturnType, @@ -530,6 +575,31 @@ describe("seamCarve", () => { 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, + ); + + 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 () => { const { seamCarve } = await importFresh(); diff --git a/tests/unit/api/ai-tools.test.ts b/tests/unit/api/ai-tools.test.ts index 7747e3a5..b427071d 100644 --- a/tests/unit/api/ai-tools.test.ts +++ b/tests/unit/api/ai-tools.test.ts @@ -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 - await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).rejects.toThrow( - "cannot reduce dimensions by more than 75%", - ); + // Should succeed by pre-resizing to bring within 75% limit + await expect(seamCarve(INPUT_BUFFER, OUTPUT_DIR, { width: 100 })).resolves.toBeDefined(); }); it("uses original dimensions when width/height not specified", async () => { diff --git a/tests/unit/web/dropzone.test.tsx b/tests/unit/web/dropzone.test.tsx new file mode 100644 index 00000000..ea51df06 --- /dev/null +++ b/tests/unit/web/dropzone.test.tsx @@ -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(); + 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(); + expect(screen.getByText("PNG, JPG, WebP, HEIC, RAW, PSD, and 65+ formats")).toBeDefined(); + }); + + it("renders the drop zone section with aria label", () => { + render(); + expect(screen.getByLabelText("File drop zone")).toBeDefined(); + }); + + it("does not show file list when no files provided", () => { + render(); + expect(screen.queryByText(/files selected/)).toBeNull(); + }); + + it("does not show file list for a single file", () => { + render(); + 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(); + 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(); + + 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(); + + fireEvent.click(screen.getByText("Upload")); + expect(getInput()).not.toBeNull(); + }); + + it("sets multiple attribute on the file input by default", () => { + const getInput = spyFileInput(); + render(); + + fireEvent.click(screen.getByLabelText("File drop zone")); + expect(getInput()!.multiple).toBe(true); + }); + + it("disables multiple when multiple=false", () => { + const getInput = spyFileInput(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + const zone = screen.getByLabelText("File drop zone"); + + fireEvent.dragOver(zone); + expect(zone.className).toContain("bg-primary/10"); + }); + + it("removes drag styling after drop", () => { + render(); + 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(); + + const file = makeFile("screenshot.png", "image/png"); + pasteViaItems([file]); + + expect(onFiles).toHaveBeenCalledWith([file]); + }); + + it("handles multiple pasted images", () => { + const onFiles = vi.fn(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + unmount(); + + pasteViaItems([makeFile("a.png", "image/png")]); + + expect(onFiles).not.toHaveBeenCalled(); + }); + + it("handles paste where getAsFile returns null", () => { + const onFiles = vi.fn(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + + pasteViaFiles([makeFile("doc.pdf", "application/pdf")]); + + expect(onFiles).not.toHaveBeenCalled(); + }); + + it("accepts RAW and HEIC files from Finder", () => { + const onFiles = vi.fn(); + render(); + + 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(); + + 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(); + + 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(); + + 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(); + 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(); + 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(); + 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(); + + expect(() => { + pasteViaItems([makeFile("a.png")]); + }).not.toThrow(); + }); + + it("does not throw on click without onFiles", () => { + spyFileInput(); + render(); + + expect(() => { + fireEvent.click(screen.getByLabelText("File drop zone")); + }).not.toThrow(); + }); + }); +});