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;