diff --git a/apps/api/src/lib/file-validation.ts b/apps/api/src/lib/file-validation.ts index e86ca02e..18f94718 100644 --- a/apps/api/src/lib/file-validation.ts +++ b/apps/api/src/lib/file-validation.ts @@ -154,11 +154,13 @@ function detectMagicBytes(buffer: Buffer): string | null { const brand = buffer.slice(8, 12).toString("ascii"); if (brand !== "avif" && brand !== "avis") continue; } - // For ftyp, verify HEIF/HEIC brand at bytes 8-11 + // For ftyp, verify HEIF/HEIC brand at bytes 8-11. + // Covers HEVC still (heic/heix), HEVC sequence (hevc/hevx), + // generic HEIF still/sequence (mif1/msf1), and multi-layer profiles. if (entry.format === "heif") { if (buffer.length < 12) continue; const brand = buffer.slice(8, 12).toString("ascii"); - if (brand !== "heic" && brand !== "heix" && brand !== "mif1") continue; + if (!["heic", "heix", "mif1", "msf1", "hevc", "hevx"].includes(brand)) continue; } return entry.format; } diff --git a/apps/api/src/lib/heic-converter.ts b/apps/api/src/lib/heic-converter.ts index b7f0d11d..7306dc88 100644 --- a/apps/api/src/lib/heic-converter.ts +++ b/apps/api/src/lib/heic-converter.ts @@ -8,9 +8,8 @@ import { promisify } from "node:util"; const execFileAsync = promisify(execFile); /** - * Find the HEIF decode command. macOS (Homebrew) provides `heif-dec`, - * while Linux packages provide `heif-convert`. Both accept the same - * ` ` argument syntax. + * Find the HEIF decode command. Both heif-convert and heif-dec accept + * ` ` positional arguments. */ let cachedDecodeCmd: string | null = null; @@ -32,20 +31,32 @@ async function findDecodeCmd(): Promise { * Decode a HEIC/HEIF buffer to PNG using the system HEIF decoder CLI. * This is needed because Sharp's bundled libheif does not include the * HEVC decoder required for true HEIC files (iPhone photos). + * + * Multi-image HEIF files (common from iPhones) cause heif-convert/heif-dec + * to add numeric suffixes (-1, -2, ...) to the output filename. We try the + * exact path first, then fall back to the -1 suffixed path. */ export async function decodeHeic(buffer: Buffer): Promise { const cmd = await findDecodeCmd(); const id = randomUUID(); const inputPath = join(tmpdir(), `heic-in-${id}.heic`); const outputPath = join(tmpdir(), `heic-out-${id}.png`); + const suffixedPath = outputPath.replace(/\.png$/, "-1.png"); try { await writeFile(inputPath, buffer); await execFileAsync(cmd, [inputPath, outputPath], { timeout: 30_000 }); - return await readFile(outputPath); + + // Single-image HEIF: exact filename. Multi-image: -1 suffix on first image. + try { + return await readFile(outputPath); + } catch { + return await readFile(suffixedPath); + } } finally { await rm(inputPath, { force: true }).catch(() => {}); await rm(outputPath, { force: true }).catch(() => {}); + await rm(suffixedPath, { force: true }).catch(() => {}); } } diff --git a/apps/api/src/routes/files.ts b/apps/api/src/routes/files.ts index 13c81916..cfd9645e 100644 --- a/apps/api/src/routes/files.ts +++ b/apps/api/src/routes/files.ts @@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto"; import { readFile, stat, writeFile } from "node:fs/promises"; import { extname, join } from "node:path"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; +import sharp from "sharp"; import { validateImageBuffer } from "../lib/file-validation.js"; import { sanitizeFilename } from "../lib/filename.js"; +import { decodeHeic } from "../lib/heic-converter.js"; import { createWorkspace, getWorkspacePath } from "../lib/workspace.js"; /** @@ -120,6 +122,36 @@ export async function fileRoutes(app: FastifyInstance): Promise { .send(buffer); }, ); + + // ── POST /api/v1/preview ────────────────────────────────────── + // Returns a WebP preview for formats browsers can't display (HEIC/HEIF). + app.post("/api/v1/preview", async (request: FastifyRequest, reply: FastifyReply) => { + const data = await request.file(); + if (!data) { + return reply.status(400).send({ error: "No file provided" }); + } + let buffer = await data.toBuffer(); + + const validation = await validateImageBuffer(buffer); + if (!validation.valid) { + return reply.status(400).send({ error: validation.reason }); + } + + // Decode HEIC/HEIF via system decoder + if (validation.format === "heif") { + try { + buffer = await decodeHeic(buffer); + } catch { + return reply.status(422).send({ error: "Failed to decode HEIC/HEIF file" }); + } + } + + const webp = await sharp(buffer) + .resize(1200, 1200, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(); + return reply.header("Content-Type", "image/webp").send(webp); + }); } function getContentType(ext: string): string { diff --git a/apps/api/src/routes/tool-factory.ts b/apps/api/src/routes/tool-factory.ts index f17e3e32..af7e9750 100644 --- a/apps/api/src/routes/tool-factory.ts +++ b/apps/api/src/routes/tool-factory.ts @@ -149,11 +149,14 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig } // Decode HEIC/HEIF input via system heif-dec (Sharp's bundled libheif - // lacks the HEVC decoder needed for iPhone photos) + // lacks the HEVC decoder needed for iPhone photos). + // The decoded buffer is PNG, so update the filename extension to match. const isHeif = validation.format === "heif"; if (isHeif) { try { fileBuffer = await decodeHeic(fileBuffer); + const ext = filename.match(/\.[^.]+$/)?.[0]; + if (ext) filename = filename.slice(0, -ext.length) + ".png"; } catch (err) { return reply.status(422).send({ error: "Failed to decode HEIC file. Ensure libheif-examples is installed.", @@ -240,6 +243,34 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig const outputPath = join(workspacePath, "output", result.filename); await writeFile(outputPath, result.buffer); + // Generate a browser-previewable WebP thumbnail for formats that + // browsers cannot render in tags (HEIC, TIFF, etc.) + const BROWSER_PREVIEWABLE = new Set([ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "image/svg+xml", + "image/bmp", + "image/avif", + ]); + let previewUrl: string | undefined; + if (!BROWSER_PREVIEWABLE.has(result.contentType)) { + try { + let previewInput = result.buffer; + // Sharp can't decode HEIC - use system decoder first + if (result.contentType === "image/heic" || result.contentType === "image/heif") { + previewInput = await decodeHeic(result.buffer); + } + const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer(); + 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 + } + } + // Also save the original input for reference/download const inputPath = join(workspacePath, "input", filename); await writeFile(inputPath, fileBuffer); @@ -296,6 +327,7 @@ export function createToolRoute(app: FastifyInstance, config: ToolRouteConfig return reply.send({ jobId, downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`, + previewUrl, originalSize: fileBuffer.length, processedSize: result.buffer.length, savedFileId, diff --git a/apps/api/src/routes/tools/content-aware-resize.ts b/apps/api/src/routes/tools/content-aware-resize.ts index 4fd240ad..bd7fd2b7 100644 --- a/apps/api/src/routes/tools/content-aware-resize.ts +++ b/apps/api/src/routes/tools/content-aware-resize.ts @@ -6,6 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { z } from "zod"; import { autoOrient } from "../../lib/auto-orient.js"; import { validateImageBuffer } from "../../lib/file-validation.js"; +import { decodeHeic } from "../../lib/heic-converter.js"; import { createWorkspace } from "../../lib/workspace.js"; import { registerToolProcessFn } from "../tool-factory.js"; @@ -59,6 +60,20 @@ export function registerContentAwareResize(app: FastifyInstance) { return reply.status(400).send({ error: `Invalid image: ${validation.reason}` }); } + // Decode HEIC/HEIF input (caire can't read HEIF containers) + if (validation.format === "heif") { + try { + fileBuffer = await decodeHeic(fileBuffer); + const ext = filename.match(/\.[^.]+$/)?.[0]; + if (ext) filename = filename.slice(0, -ext.length) + ".png"; + } catch (err) { + return reply.status(422).send({ + error: "Failed to decode HEIC/HEIF file", + details: err instanceof Error ? err.message : String(err), + }); + } + } + // Validate settings let settings: Settings; try { @@ -143,7 +158,13 @@ export function registerContentAwareResize(app: FastifyInstance) { settingsSchema, process: async (inputBuffer, settings, filename) => { const s = settings as Settings; - const orientedBuffer = await autoOrient(inputBuffer); + // Decode HEIC/HEIF for pipeline/batch mode + const ext = filename.split(".").pop()?.toLowerCase() ?? ""; + let buf = inputBuffer; + if (["heic", "heif", "hif"].includes(ext)) { + buf = await decodeHeic(buf); + } + const orientedBuffer = await autoOrient(buf); const jobId = randomUUID(); const workspacePath = await createWorkspace(jobId); const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), { diff --git a/apps/api/src/routes/tools/convert.ts b/apps/api/src/routes/tools/convert.ts index 98825ad5..38af3839 100644 --- a/apps/api/src/routes/tools/convert.ts +++ b/apps/api/src/routes/tools/convert.ts @@ -15,10 +15,11 @@ const FORMAT_CONTENT_TYPES: Record = { tiff: "image/tiff", gif: "image/gif", heic: "image/heic", + heif: "image/heif", }; const settingsSchema = z.object({ - format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic"]), + format: z.enum(["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"]), quality: z.number().min(1).max(100).optional(), }); @@ -31,7 +32,7 @@ export function registerConvert(app: FastifyInstance) { const image = sharp(inputBuffer, sharpOpts); let buffer: Buffer; - if (settings.format === "heic") { + if (settings.format === "heic" || settings.format === "heif") { // Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc. const pngBuffer = await image.png().toBuffer(); buffer = await encodeHeic(pngBuffer, settings.quality); diff --git a/apps/web/src/components/common/dropzone.tsx b/apps/web/src/components/common/dropzone.tsx index f04d7217..250b16dd 100644 --- a/apps/web/src/components/common/dropzone.tsx +++ b/apps/web/src/components/common/dropzone.tsx @@ -10,7 +10,15 @@ interface DropzoneProps { currentFiles?: File[]; } +// Browsers may not map .heic/.heif to image/* in file pickers. +// Append explicit extensions so they are selectable. +function expandAccept(accept?: string): string | undefined { + if (!accept?.includes("image/*")) return accept; + return `${accept},.heic,.heif,.hif`; +} + export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) { + const resolvedAccept = expandAccept(accept); const [isDragging, setIsDragging] = useState(false); const handleDrag = useCallback((e: DragEvent) => { @@ -35,7 +43,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] } const input = document.createElement("input"); input.type = "file"; input.multiple = multiple; - if (accept) input.accept = accept; + if (resolvedAccept) input.accept = resolvedAccept; input.onchange = (e) => { const files = Array.from((e.target as HTMLInputElement).files || []); if (files.length > 0) onFiles?.(files); diff --git a/apps/web/src/components/common/multi-image-viewer.tsx b/apps/web/src/components/common/multi-image-viewer.tsx index fd59753e..30fdbcc7 100644 --- a/apps/web/src/components/common/multi-image-viewer.tsx +++ b/apps/web/src/components/common/multi-image-viewer.tsx @@ -1,10 +1,27 @@ -import { ChevronLeft, ChevronRight } from "lucide-react"; +import { CheckCircle2, ChevronLeft, ChevronRight, Loader2 } from "lucide-react"; import { useCallback } from "react"; import { BeforeAfterSlider } from "@/components/common/before-after-slider"; import { ImageViewer } from "@/components/common/image-viewer"; import { ThumbnailStrip } from "@/components/common/thumbnail-strip"; import { useFileStore } from "@/stores/file-store"; +const BROWSER_PREVIEWABLE_EXTS = new Set([ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "svg", + "bmp", + "ico", + "avif", +]); + +function canBrowserPreview(url: string): boolean { + const ext = decodeURIComponent(url).split(".").pop()?.toLowerCase() ?? ""; + return BROWSER_PREVIEWABLE_EXTS.has(ext); +} + export function MultiImageViewer() { const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore(); @@ -29,6 +46,13 @@ export function MultiImageViewer() { const hasNext = selectedIndex < entries.length - 1; const hasProcessed = !!currentEntry.processedUrl; + const isPreviewable = hasProcessed && canBrowserPreview(currentEntry.processedUrl!); + const displayUrl = currentEntry.processedPreviewUrl ?? currentEntry.processedUrl; + + const processedFilename = currentEntry.processedUrl + ? decodeURIComponent(currentEntry.processedUrl.split("/").pop() ?? "processed") + : "processed"; + const processedExt = processedFilename.split(".").pop()?.toUpperCase() || "FILE"; return (
)}
- {hasProcessed ? ( + {hasProcessed && !isPreviewable && !currentEntry.processedPreviewUrl ? ( +
+
+ +
+

{processedFilename}

+

+ {processedExt} files cannot be previewed in the browser. +

+
+ ) : hasProcessed ? ( + ) : currentEntry.previewLoading ? ( +
+ +

Generating preview...

+
) : ( - {entry.file.name} + {entry.previewLoading ? ( +
+ +
+ ) : ( + {entry.file.name} + )} {isCompleted && (
diff --git a/apps/web/src/components/tools/convert-settings.tsx b/apps/web/src/components/tools/convert-settings.tsx index 191fd9c1..16b0b09b 100644 --- a/apps/web/src/components/tools/convert-settings.tsx +++ b/apps/web/src/components/tools/convert-settings.tsx @@ -4,8 +4,8 @@ import { ProgressCard } from "@/components/common/progress-card"; import { useToolProcessor } from "@/hooks/use-tool-processor"; import { useFileStore } from "@/stores/file-store"; -const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const; -const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic"]; +const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"] as const; +const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"]; export interface ConvertControlsProps { onChange?: (settings: Record) => void; @@ -132,10 +132,6 @@ export function ConvertSettings() {

Original: {(originalSize / 1024).toFixed(1)} KB

Processed: {(processedSize / 1024).toFixed(1)} KB

-

- Savings:{" "} - {originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}% -

)} diff --git a/apps/web/src/components/tools/pipeline-builder.tsx b/apps/web/src/components/tools/pipeline-builder.tsx index e782d44c..7a99e86c 100644 --- a/apps/web/src/components/tools/pipeline-builder.tsx +++ b/apps/web/src/components/tools/pipeline-builder.tsx @@ -146,7 +146,7 @@ export function PipelineBuilder({ const handleFileSelect = useCallback(() => { const input = document.createElement("input"); input.type = "file"; - input.accept = "image/*"; + input.accept = "image/*,.heic,.heif,.hif"; input.onchange = (e) => { const f = (e.target as HTMLInputElement).files?.[0]; if (f) setFile(f); diff --git a/apps/web/src/components/tools/rotate-settings.tsx b/apps/web/src/components/tools/rotate-settings.tsx index 6ceb0f87..84e0f23f 100644 --- a/apps/web/src/components/tools/rotate-settings.tsx +++ b/apps/web/src/components/tools/rotate-settings.tsx @@ -87,20 +87,45 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro return (
- {/* Quick rotate */} + {/* Quick rotate presets */}

Rotate

-
+
+ + +
+
+ + {/* Custom angle */} +
+

Angle

+
-
@@ -185,26 +200,26 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro type="button" data-testid="rotate-flip-h" onClick={() => setFlipH(!flipH)} - className={`flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg text-sm font-medium transition-colors ${ + className={`flex-1 flex items-center justify-center gap-1.5 py-2 rounded-lg text-xs font-medium transition-colors ${ flipH ? "bg-primary text-primary-foreground" : "bg-muted text-muted-foreground hover:bg-primary/10" }`} > - + Horizontal
diff --git a/apps/web/src/hooks/use-tool-processor.ts b/apps/web/src/hooks/use-tool-processor.ts index ebf1535d..b98bfc1b 100644 --- a/apps/web/src/hooks/use-tool-processor.ts +++ b/apps/web/src/hooks/use-tool-processor.ts @@ -7,6 +7,7 @@ import { useFileStore } from "@/stores/file-store"; interface ProcessResult { jobId: string; downloadUrl: string; + previewUrl?: string; originalSize: number; processedSize: number; savedFileId?: string; @@ -31,7 +32,7 @@ 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"]); +const MEDIUM_TOOLS = new Set(["content-aware-resize", "convert"]); export function useToolProcessor(toolId: string) { const { @@ -141,8 +142,8 @@ export function useToolProcessor(toolId: string) { const xhr = new XMLHttpRequest(); xhrRef.current = xhr; - // Timeout: 60s for fast/medium tools, 5 min for AI tools - xhr.timeout = isAiTool ? 300_000 : 60_000; + // Timeout: 60s for fast tools, 3 min for medium (seam carving), 5 min for AI + xhr.timeout = isAiTool ? 300_000 : isMediumTool ? 180_000 : 60_000; // For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven) // For medium tools: upload = 0-40%, processing = 40-95% (gradual fill) @@ -167,11 +168,11 @@ export function useToolProcessor(toolId: string) { stage: isAiTool ? "Starting..." : "Processing...", })); - // Medium tools: gradually fill from upload weight to 95% over ~15s + // Medium tools: gradually fill from upload weight to 95% over ~45s if (isMediumTool) { const start = UPLOAD_WEIGHT; const target = 95; - const step = (target - start) / 30; // 30 ticks over ~15s + const step = (target - start) / 90; // 90 ticks over ~45s processingTimerRef.current = setInterval(() => { setProgress((prev) => { if (prev.phase !== "processing") return prev; @@ -194,7 +195,7 @@ export function useToolProcessor(toolId: string) { try { const result: ProcessResult = JSON.parse(xhr.responseText); setJobId(result.jobId); - setProcessedUrl(result.downloadUrl); + setProcessedUrl(result.downloadUrl, result.previewUrl); setSizes(result.originalSize, result.processedSize); // Update serverFileId if a new version was saved if (result.savedFileId) { diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 71421b05..79658471 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,5 +1,6 @@ import { CATEGORIES, TOOLS } from "@stirling-image/shared"; import * as icons from "lucide-react"; +import { Loader2 } from "lucide-react"; import { useCallback, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { ImageViewer } from "@/components/common/image-viewer"; @@ -12,8 +13,15 @@ import { useSettingsStore } from "@/stores/settings-store"; const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"]; export function HomePage() { - const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } = - useFileStore(); + const { + setFiles, + files, + reset, + originalBlobUrl, + selectedFileName, + selectedFileSize, + currentEntry, + } = useFileStore(); const navigate = useNavigate(); const { fetch: fetchSettings } = useSettingsStore(); @@ -141,6 +149,12 @@ export function HomePage() {
{files.length > 1 ? ( + ) : currentEntry?.previewLoading ? ( +
+ +

Generating preview...

+

{selectedFileName}

+
) : originalBlobUrl ? ( tags. */ +const BROWSER_PREVIEWABLE_EXTS = new Set([ + "jpg", + "jpeg", + "png", + "gif", + "webp", + "svg", + "bmp", + "ico", + "avif", +]); + +function canBrowserPreview(url: string): boolean { + const ext = decodeURIComponent(url).split(".").pop()?.toLowerCase() ?? ""; + return BROWSER_PREVIEWABLE_EXTS.has(ext); +} + /** File selection indicator shown in left panel */ function FileSelectionInfo({ files, @@ -84,6 +102,7 @@ export function ToolPage() { addFiles, reset, processedUrl, + processedPreviewUrl, originalBlobUrl, originalSize, processedSize, @@ -170,7 +189,7 @@ export function ToolPage() { const input = document.createElement("input"); input.type = "file"; input.multiple = true; - input.accept = "image/*"; + input.accept = "image/*,.heic,.heif,.hif"; input.onchange = (e) => { const newFiles = Array.from((e.target as HTMLInputElement).files || []); if (newFiles.length > 0) addFiles(newFiles); @@ -208,11 +227,15 @@ export function ToolPage() { const isNoDropzone = displayMode === "no-dropzone"; const isLivePreview = registryEntry.livePreview ?? false; - // Derive processed file info from context - const processedFileName = selectedFileName ? `processed-${selectedFileName}` : "processed-image"; - const processedFileType = selectedFileName - ? selectedFileName.split(".").pop()?.toUpperCase() || "IMAGE" - : "IMAGE"; + // Derive processed file info from the actual download URL (has correct extension) + const processedFileName = processedUrl + ? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image") + : "processed-image"; + const processedFileType = processedFileName.split(".").pop()?.toUpperCase() || "IMAGE"; + const isProcessedPreviewable = processedUrl ? canBrowserPreview(processedUrl) : false; + // Use server-generated preview for non-previewable formats (HEIC, TIFF). + // Always a string when hasProcessed is true (processedUrl is non-null). + const displayUrl = (processedPreviewUrl ?? processedUrl) as string; // Build settings props const settingsProps = { @@ -287,6 +310,30 @@ export function ToolPage() { ); } + // Non-previewable format with no server-generated preview - show success card + if (hasProcessed && !isProcessedPreviewable && !processedPreviewUrl) { + return ( +
+
+ +
+
+

Conversion complete

+

{processedFileName}

+ {processedSize != null && ( +

+ {formatFileSize(processedSize)} · {processedFileType} +

+ )} +
+

+ {processedFileType} files cannot be previewed in the browser. Use the download button to + save your file. +

+
+ ); + } + if ( hasProcessed && originalBlobUrl && @@ -295,7 +342,7 @@ export function ToolPage() { return ( @@ -308,11 +355,7 @@ export function ToolPage() { (displayMode === "live-preview" || displayMode === "no-comparison") ) { return ( - + ); } @@ -320,13 +363,23 @@ export function ToolPage() { return ( ); } + if (hasFile && currentEntry?.previewLoading) { + return ( +
+ +

Generating preview...

+

{selectedFileName}

+
+ ); + } + if (hasFile && originalBlobUrl) { return ( diff --git a/apps/web/src/stores/file-store.ts b/apps/web/src/stores/file-store.ts index b438ac52..207b51eb 100644 --- a/apps/web/src/stores/file-store.ts +++ b/apps/web/src/stores/file-store.ts @@ -1,9 +1,12 @@ import { create } from "zustand"; +import { formatHeaders } from "@/lib/api"; export interface FileEntry { file: File; blobUrl: string; + previewLoading: boolean; processedUrl: string | null; + processedPreviewUrl: string | null; processedSize: number | null; originalSize: number; status: "pending" | "processing" | "completed" | "failed"; @@ -19,7 +22,9 @@ function createEntry(file: File): FileEntry { return { file, blobUrl: URL.createObjectURL(file), + previewLoading: needsServerPreview(file), processedUrl: null, + processedPreviewUrl: null, processedSize: null, originalSize: file.size, status: "pending", @@ -51,6 +56,7 @@ function deriveSelected(entries: FileEntry[], selectedIndex: number) { selectedFileSize: entry ? entry.file.size : null, originalBlobUrl: entry ? entry.blobUrl : null, processedUrl: entry ? entry.processedUrl : null, + processedPreviewUrl: entry ? entry.processedPreviewUrl : null, originalSize: entry ? entry.originalSize : null, processedSize: entry ? entry.processedSize : null, }; @@ -69,6 +75,34 @@ function deriveFiles(entries: FileEntry[]): File[] { return prevFiles; } +// --------------------------------------------------------------------------- +// HEIC/HEIF preview helpers +// --------------------------------------------------------------------------- + +const HEIF_EXTENSIONS = new Set(["heic", "heif", "hif"]); + +function needsServerPreview(file: File): boolean { + const ext = file.name.split(".").pop()?.toLowerCase() ?? ""; + return HEIF_EXTENSIONS.has(ext); +} + +async function fetchDecodedPreview(file: File): Promise { + try { + const formData = new FormData(); + formData.append("file", file); + const res = await fetch("/api/v1/preview", { + method: "POST", + headers: formatHeaders(), + body: formData, + }); + if (!res.ok) return null; + const blob = await res.blob(); + return URL.createObjectURL(blob); + } catch { + return null; + } +} + // --------------------------------------------------------------------------- // Store // --------------------------------------------------------------------------- @@ -88,6 +122,7 @@ interface FileState { readonly selectedFileSize: number | null; readonly originalBlobUrl: string | null; readonly processedUrl: string | null; + readonly processedPreviewUrl: string | null; readonly originalSize: number | null; readonly processedSize: number | null; @@ -103,7 +138,7 @@ interface FileState { setProcessing: (v: boolean) => void; setError: (e: string | null) => void; setJobId: (id: string) => void; - setProcessedUrl: (url: string | null) => void; + setProcessedUrl: (url: string | null, previewUrl?: string | null) => void; setSizes: (original: number, processed: number) => void; undoProcessing: () => void; reset: () => void; @@ -133,12 +168,41 @@ export const useFileStore = create((set, get) => ({ files: deriveFiles(entries), ...deriveSelected(entries, 0), }); + // Async: decode HEIC/HEIF files for browser preview + for (let i = 0; i < entries.length; i++) { + if (needsServerPreview(entries[i].file)) { + const file = entries[i].file; + fetchDecodedPreview(file).then((url) => { + const state = get(); + if (state.entries[i]?.file !== file) return; + const updated = [...state.entries]; + updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) }; + set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) }); + }); + } + } }, addFiles: (files) => { - const entries = [...get().entries, ...files.map(createEntry)]; + const oldLen = get().entries.length; + const newEntries = files.map(createEntry); + const entries = [...get().entries, ...newEntries]; const idx = get().selectedIndex; set({ entries, files: deriveFiles(entries), ...deriveSelected(entries, idx) }); + // Async: decode HEIC/HEIF files for browser preview + for (let j = 0; j < newEntries.length; j++) { + const i = oldLen + j; + if (needsServerPreview(newEntries[j].file)) { + const file = newEntries[j].file; + fetchDecodedPreview(file).then((url) => { + const state = get(); + if (state.entries[i]?.file !== file) return; + const updated = [...state.entries]; + updated[i] = { ...updated[i], previewLoading: false, ...(url ? { blobUrl: url } : {}) }; + set({ entries: updated, ...deriveSelected(updated, state.selectedIndex) }); + }); + } + } }, removeFile: (index) => { @@ -207,7 +271,7 @@ export const useFileStore = create((set, get) => ({ // no-op for backward compat }, - setProcessedUrl: (url) => { + setProcessedUrl: (url, previewUrl) => { const { entries, selectedIndex } = get(); if (!entries[selectedIndex]) return; const updated = [...entries]; @@ -215,12 +279,14 @@ export const useFileStore = create((set, get) => ({ updated[selectedIndex] = { ...updated[selectedIndex], processedUrl: url, + processedPreviewUrl: previewUrl ?? null, status: "completed", }; } else { updated[selectedIndex] = { ...updated[selectedIndex], processedUrl: null, + processedPreviewUrl: null, status: "pending", }; } @@ -247,6 +313,7 @@ export const useFileStore = create((set, get) => ({ const resetEntries = entries.map((e) => ({ ...e, processedUrl: null, + processedPreviewUrl: null, processedSize: null, status: "pending" as const, error: null, diff --git a/packages/ai/src/seam-carving.ts b/packages/ai/src/seam-carving.ts index 69ad6dd8..1b1b070c 100644 --- a/packages/ai/src/seam-carving.ts +++ b/packages/ai/src/seam-carving.ts @@ -47,9 +47,17 @@ async function findCaire(): Promise { ); } +/** Max pixels on the longest edge before downscaling for caire. */ +const MAX_CAIRE_DIMENSION = 1200; + /** * Content-aware resize using caire (Go seam carving engine). * Supports both shrinking and enlarging via seam removal/insertion. + * + * Large images (>1200px longest edge) are downscaled first because + * seam carving is O(width * height * seams) and becomes impractical + * on high-resolution inputs. JPEG intermediate is used because Go's + * JPEG decoder is significantly faster than PNG for large images. */ export async function seamCarve( inputBuffer: Buffer, @@ -58,38 +66,71 @@ export async function seamCarve( ): Promise { const cairePath = await findCaire(); const id = randomUUID(); - const inputPath = join(outputDir, `caire-in-${id}.png`); + // Use JPEG for input (fast decode in Go) and PNG for output (lossless) + const inputPath = join(outputDir, `caire-in-${id}.jpg`); const outputPath = join(outputDir, `caire-out-${id}.png`); try { - await writeFile(inputPath, inputBuffer); + // Downscale large images and convert to JPEG for fast caire processing + const meta = await sharp(inputBuffer).metadata(); + const origWidth = meta.width ?? 0; + const origHeight = meta.height ?? 0; + const longest = Math.max(origWidth, origHeight); + + let width = origWidth; + let height = origHeight; + + if (longest > MAX_CAIRE_DIMENSION) { + const scale = MAX_CAIRE_DIMENSION / longest; + width = Math.round(origWidth * scale); + height = Math.round(origHeight * scale); + } + + // Always output JPEG for caire input (Go decodes JPEG 3-5x faster than PNG) + const processBuffer = await sharp(inputBuffer) + .resize(width, height, { fit: "fill" }) + .jpeg({ quality: 95 }) + .toBuffer(); + + await writeFile(inputPath, processBuffer); // Build caire arguments const args = ["-in", inputPath, "-out", outputPath, "-preview=false"]; if (options.square) { - // Caire -square requires -width and -height set to the shortest edge - const meta = await sharp(inputBuffer).metadata(); - const shortest = Math.min(meta.width ?? 0, meta.height ?? 0); + const shortest = Math.min(width, height); args.push("-square", "-width", String(shortest), "-height", String(shortest)); } else { - if (options.width) args.push("-width", String(options.width)); - if (options.height) args.push("-height", String(options.height)); + if (options.width) { + // Scale user-specified dimensions proportionally if image was downscaled + const targetW = + longest > MAX_CAIRE_DIMENSION + ? Math.round(options.width * (MAX_CAIRE_DIMENSION / longest)) + : options.width; + args.push("-width", String(targetW)); + } + if (options.height) { + const targetH = + longest > MAX_CAIRE_DIMENSION + ? Math.round(options.height * (MAX_CAIRE_DIMENSION / longest)) + : options.height; + args.push("-height", String(targetH)); + } } if (options.protectFaces) args.push("-face"); if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius)); if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold)); - await execFileAsync(cairePath, args, { timeout: 60_000 }); + await execFileAsync(cairePath, args, { timeout: 120_000 }); const buffer = await readFile(outputPath); - const meta = await sharp(buffer).metadata(); + const outMeta = await sharp(buffer).metadata(); return { buffer, - width: meta.width ?? 0, - height: meta.height ?? 0, + width: outMeta.width ?? 0, + height: outMeta.height ?? 0, }; } finally { await rm(inputPath, { force: true }).catch(() => {}); diff --git a/packages/image-engine/src/types.ts b/packages/image-engine/src/types.ts index 416ec1d5..8f660748 100644 --- a/packages/image-engine/src/types.ts +++ b/packages/image-engine/src/types.ts @@ -17,7 +17,7 @@ export interface OperationResult { info: ImageInfo; } -export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif" | "heic"; +export type OutputFormat = "jpg" | "png" | "webp" | "avif" | "tiff" | "gif" | "heic" | "heif"; export interface ResizeOptions { width?: number;