mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: full HEIF/HEIC support, content-aware resize performance fix, UI improvements
- Add bidirectional HEIF support: decode (input) and encode (output) via system heif-convert/heif-enc - Add server-side WebP preview generation for non-browser-previewable formats (HEIC, TIFF) - Fix content-aware resize failing on HEIF input (decode before passing to caire) - Fix content-aware resize timeout on large images by downscaling to max 1200px and using JPEG intermediate - Add HEIF as target format in convert tool - Add loading spinner for HEIF preview decode in file store - Fix file picker not accepting HEIF files (explicit .heic,.heif,.hif extensions) - Extend frontend timeout for medium tools to 180s with 45s progress animation - Redesign rotate controls with preset buttons and compact flip section - Remove misleading savings percentage from convert tool
This commit is contained in:
@@ -154,11 +154,13 @@ function detectMagicBytes(buffer: Buffer): string | null {
|
|||||||
const brand = buffer.slice(8, 12).toString("ascii");
|
const brand = buffer.slice(8, 12).toString("ascii");
|
||||||
if (brand !== "avif" && brand !== "avis") continue;
|
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 (entry.format === "heif") {
|
||||||
if (buffer.length < 12) continue;
|
if (buffer.length < 12) continue;
|
||||||
const brand = buffer.slice(8, 12).toString("ascii");
|
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;
|
return entry.format;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,8 @@ import { promisify } from "node:util";
|
|||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find the HEIF decode command. macOS (Homebrew) provides `heif-dec`,
|
* Find the HEIF decode command. Both heif-convert and heif-dec accept
|
||||||
* while Linux packages provide `heif-convert`. Both accept the same
|
* `<input> <output>` positional arguments.
|
||||||
* `<input> <output>` argument syntax.
|
|
||||||
*/
|
*/
|
||||||
let cachedDecodeCmd: string | null = null;
|
let cachedDecodeCmd: string | null = null;
|
||||||
|
|
||||||
@@ -32,20 +31,32 @@ async function findDecodeCmd(): Promise<string> {
|
|||||||
* Decode a HEIC/HEIF buffer to PNG using the system HEIF decoder CLI.
|
* 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
|
* This is needed because Sharp's bundled libheif does not include the
|
||||||
* HEVC decoder required for true HEIC files (iPhone photos).
|
* 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<Buffer> {
|
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
|
||||||
const cmd = await findDecodeCmd();
|
const cmd = await findDecodeCmd();
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
|
const inputPath = join(tmpdir(), `heic-in-${id}.heic`);
|
||||||
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
|
const outputPath = join(tmpdir(), `heic-out-${id}.png`);
|
||||||
|
const suffixedPath = outputPath.replace(/\.png$/, "-1.png");
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await writeFile(inputPath, buffer);
|
await writeFile(inputPath, buffer);
|
||||||
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 30_000 });
|
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 {
|
} finally {
|
||||||
await rm(inputPath, { force: true }).catch(() => {});
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
await rm(outputPath, { force: true }).catch(() => {});
|
await rm(outputPath, { force: true }).catch(() => {});
|
||||||
|
await rm(suffixedPath, { force: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { readFile, stat, writeFile } from "node:fs/promises";
|
import { readFile, stat, writeFile } from "node:fs/promises";
|
||||||
import { extname, join } from "node:path";
|
import { extname, join } from "node:path";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import sharp from "sharp";
|
||||||
import { validateImageBuffer } from "../lib/file-validation.js";
|
import { validateImageBuffer } from "../lib/file-validation.js";
|
||||||
import { sanitizeFilename } from "../lib/filename.js";
|
import { sanitizeFilename } from "../lib/filename.js";
|
||||||
|
import { decodeHeic } from "../lib/heic-converter.js";
|
||||||
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
|
import { createWorkspace, getWorkspacePath } from "../lib/workspace.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,6 +122,36 @@ export async function fileRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
.send(buffer);
|
.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 {
|
function getContentType(ext: string): string {
|
||||||
|
|||||||
@@ -149,11 +149,14 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Decode HEIC/HEIF input via system heif-dec (Sharp's bundled libheif
|
// 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";
|
const isHeif = validation.format === "heif";
|
||||||
if (isHeif) {
|
if (isHeif) {
|
||||||
try {
|
try {
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
|
const ext = filename.match(/\.[^.]+$/)?.[0];
|
||||||
|
if (ext) filename = filename.slice(0, -ext.length) + ".png";
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.status(422).send({
|
return reply.status(422).send({
|
||||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||||
@@ -240,6 +243,34 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
const outputPath = join(workspacePath, "output", result.filename);
|
const outputPath = join(workspacePath, "output", result.filename);
|
||||||
await writeFile(outputPath, result.buffer);
|
await writeFile(outputPath, result.buffer);
|
||||||
|
|
||||||
|
// Generate a browser-previewable WebP thumbnail for formats that
|
||||||
|
// browsers cannot render in <img> 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
|
// Also save the original input for reference/download
|
||||||
const inputPath = join(workspacePath, "input", filename);
|
const inputPath = join(workspacePath, "input", filename);
|
||||||
await writeFile(inputPath, fileBuffer);
|
await writeFile(inputPath, fileBuffer);
|
||||||
@@ -296,6 +327,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
|||||||
return reply.send({
|
return reply.send({
|
||||||
jobId,
|
jobId,
|
||||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(result.filename)}`,
|
||||||
|
previewUrl,
|
||||||
originalSize: fileBuffer.length,
|
originalSize: fileBuffer.length,
|
||||||
processedSize: result.buffer.length,
|
processedSize: result.buffer.length,
|
||||||
savedFileId,
|
savedFileId,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||||
|
import { decodeHeic } from "../../lib/heic-converter.js";
|
||||||
import { createWorkspace } from "../../lib/workspace.js";
|
import { createWorkspace } from "../../lib/workspace.js";
|
||||||
import { registerToolProcessFn } from "../tool-factory.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}` });
|
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
|
// Validate settings
|
||||||
let settings: Settings;
|
let settings: Settings;
|
||||||
try {
|
try {
|
||||||
@@ -143,7 +158,13 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
|||||||
settingsSchema,
|
settingsSchema,
|
||||||
process: async (inputBuffer, settings, filename) => {
|
process: async (inputBuffer, settings, filename) => {
|
||||||
const s = settings as Settings;
|
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 jobId = randomUUID();
|
||||||
const workspacePath = await createWorkspace(jobId);
|
const workspacePath = await createWorkspace(jobId);
|
||||||
const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), {
|
const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), {
|
||||||
|
|||||||
@@ -15,10 +15,11 @@ const FORMAT_CONTENT_TYPES: Record<string, string> = {
|
|||||||
tiff: "image/tiff",
|
tiff: "image/tiff",
|
||||||
gif: "image/gif",
|
gif: "image/gif",
|
||||||
heic: "image/heic",
|
heic: "image/heic",
|
||||||
|
heif: "image/heif",
|
||||||
};
|
};
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
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(),
|
quality: z.number().min(1).max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ export function registerConvert(app: FastifyInstance) {
|
|||||||
const image = sharp(inputBuffer, sharpOpts);
|
const image = sharp(inputBuffer, sharpOpts);
|
||||||
|
|
||||||
let buffer: Buffer;
|
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.
|
// Sharp cannot encode HEVC. Convert to PNG first, then use heif-enc.
|
||||||
const pngBuffer = await image.png().toBuffer();
|
const pngBuffer = await image.png().toBuffer();
|
||||||
buffer = await encodeHeic(pngBuffer, settings.quality);
|
buffer = await encodeHeic(pngBuffer, settings.quality);
|
||||||
|
|||||||
@@ -10,7 +10,15 @@ interface DropzoneProps {
|
|||||||
currentFiles?: File[];
|
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) {
|
export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }: DropzoneProps) {
|
||||||
|
const resolvedAccept = expandAccept(accept);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDrag = useCallback((e: DragEvent) => {
|
const handleDrag = useCallback((e: DragEvent) => {
|
||||||
@@ -35,7 +43,7 @@ export function Dropzone({ onFiles, accept, multiple = true, currentFiles = [] }
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.multiple = multiple;
|
input.multiple = multiple;
|
||||||
if (accept) input.accept = accept;
|
if (resolvedAccept) input.accept = resolvedAccept;
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const files = Array.from((e.target as HTMLInputElement).files || []);
|
const files = Array.from((e.target as HTMLInputElement).files || []);
|
||||||
if (files.length > 0) onFiles?.(files);
|
if (files.length > 0) onFiles?.(files);
|
||||||
|
|||||||
@@ -1,10 +1,27 @@
|
|||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
import { CheckCircle2, ChevronLeft, ChevronRight, Loader2 } from "lucide-react";
|
||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||||
import { ImageViewer } from "@/components/common/image-viewer";
|
import { ImageViewer } from "@/components/common/image-viewer";
|
||||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
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() {
|
export function MultiImageViewer() {
|
||||||
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
|
const { entries, selectedIndex, setSelectedIndex, navigateNext, navigatePrev } = useFileStore();
|
||||||
|
|
||||||
@@ -29,6 +46,13 @@ export function MultiImageViewer() {
|
|||||||
const hasNext = selectedIndex < entries.length - 1;
|
const hasNext = selectedIndex < entries.length - 1;
|
||||||
|
|
||||||
const hasProcessed = !!currentEntry.processedUrl;
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -49,13 +73,28 @@ export function MultiImageViewer() {
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="w-full h-full min-h-0">
|
<div className="w-full h-full min-h-0">
|
||||||
{hasProcessed ? (
|
{hasProcessed && !isPreviewable && !currentEntry.processedPreviewUrl ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center p-8">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-medium">{processedFilename}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{processedExt} files cannot be previewed in the browser.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : hasProcessed ? (
|
||||||
<BeforeAfterSlider
|
<BeforeAfterSlider
|
||||||
beforeSrc={currentEntry.blobUrl}
|
beforeSrc={currentEntry.blobUrl}
|
||||||
afterSrc={currentEntry.processedUrl ?? ""}
|
afterSrc={displayUrl ?? ""}
|
||||||
beforeSize={currentEntry.originalSize}
|
beforeSize={currentEntry.originalSize}
|
||||||
afterSize={currentEntry.processedSize ?? undefined}
|
afterSize={currentEntry.processedSize ?? undefined}
|
||||||
/>
|
/>
|
||||||
|
) : currentEntry.previewLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||||
|
<p className="text-sm text-muted-foreground">Generating preview...</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={currentEntry.blobUrl}
|
src={currentEntry.blobUrl}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CheckCircle2, XCircle } from "lucide-react";
|
import { CheckCircle2, Loader2, XCircle } from "lucide-react";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import type { FileEntry } from "@/stores/file-store";
|
import type { FileEntry } from "@/stores/file-store";
|
||||||
|
|
||||||
@@ -44,12 +44,18 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
|
|||||||
style={{ width: 52, height: 38 }}
|
style={{ width: 52, height: 38 }}
|
||||||
title={entry.file.name}
|
title={entry.file.name}
|
||||||
>
|
>
|
||||||
<img
|
{entry.previewLoading ? (
|
||||||
src={entry.processedUrl ?? entry.blobUrl}
|
<div className="w-full h-full flex items-center justify-center bg-muted">
|
||||||
alt={entry.file.name}
|
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
|
||||||
className="w-full h-full object-cover"
|
</div>
|
||||||
draggable={false}
|
) : (
|
||||||
/>
|
<img
|
||||||
|
src={entry.processedPreviewUrl ?? entry.processedUrl ?? entry.blobUrl}
|
||||||
|
alt={entry.file.name}
|
||||||
|
className="w-full h-full object-cover"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{isCompleted && (
|
{isCompleted && (
|
||||||
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
|
<div className="absolute -top-0.5 -right-0.5 w-3.5 h-3.5 bg-green-500 rounded-full flex items-center justify-center">
|
||||||
<CheckCircle2 className="h-2.5 w-2.5 text-white" />
|
<CheckCircle2 className="h-2.5 w-2.5 text-white" />
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import { ProgressCard } from "@/components/common/progress-card";
|
|||||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic"] as const;
|
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif", "heic", "heif"] as const;
|
||||||
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic"];
|
const LOSSY_FORMATS = ["jpg", "jpeg", "webp", "avif", "heic", "heif"];
|
||||||
|
|
||||||
export interface ConvertControlsProps {
|
export interface ConvertControlsProps {
|
||||||
onChange?: (settings: Record<string, unknown>) => void;
|
onChange?: (settings: Record<string, unknown>) => void;
|
||||||
@@ -132,10 +132,6 @@ export function ConvertSettings() {
|
|||||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||||
<p>
|
|
||||||
Savings:{" "}
|
|
||||||
{originalSize > 0 ? ((1 - processedSize / originalSize) * 100).toFixed(1) : "0"}%
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export function PipelineBuilder({
|
|||||||
const handleFileSelect = useCallback(() => {
|
const handleFileSelect = useCallback(() => {
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "image/*";
|
input.accept = "image/*,.heic,.heif,.hif";
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const f = (e.target as HTMLInputElement).files?.[0];
|
const f = (e.target as HTMLInputElement).files?.[0];
|
||||||
if (f) setFile(f);
|
if (f) setFile(f);
|
||||||
|
|||||||
@@ -87,20 +87,45 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{/* Quick rotate */}
|
{/* Quick rotate presets */}
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground">Rotate</p>
|
<p className="text-xs text-muted-foreground">Rotate</p>
|
||||||
<div className="flex items-center gap-2 mt-1">
|
<div className="flex gap-1.5 mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="rotate-left"
|
data-testid="rotate-left"
|
||||||
onClick={rotateLeft}
|
onClick={rotateLeft}
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
className="flex-1 flex items-center justify-center gap-1 py-2 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-xs font-medium"
|
||||||
title="Rotate 90° counter-clockwise"
|
title="Rotate 90° counter-clockwise"
|
||||||
>
|
>
|
||||||
<RotateCcw className="h-4 w-4" />
|
<RotateCcw className="h-3.5 w-3.5" />
|
||||||
Left
|
-90°
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRotation((r) => r + 180)}
|
||||||
|
className="flex-1 flex items-center justify-center py-2 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-xs font-medium"
|
||||||
|
title="Rotate 180°"
|
||||||
|
>
|
||||||
|
180°
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-testid="rotate-right"
|
||||||
|
onClick={rotateRight}
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 py-2 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-xs font-medium"
|
||||||
|
title="Rotate 90° clockwise"
|
||||||
|
>
|
||||||
|
+90°
|
||||||
|
<RotateCw className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom angle */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Angle</p>
|
||||||
|
<div className="flex items-center justify-center gap-1.5 mt-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setRotation((r) => r - 1)}
|
onClick={() => setRotation((r) => r - 1)}
|
||||||
@@ -122,7 +147,7 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
|
|||||||
commitAngleInput();
|
commitAngleInput();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-14 text-center text-sm font-mono font-medium tabular-nums py-1.5 rounded-md bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 pr-4"
|
className="w-16 text-center text-sm font-mono font-medium tabular-nums py-1.5 rounded-md bg-background border border-border focus:outline-none focus:ring-2 focus:ring-primary/50 pr-4"
|
||||||
/>
|
/>
|
||||||
<span className="absolute right-2 text-sm font-mono text-muted-foreground pointer-events-none">
|
<span className="absolute right-2 text-sm font-mono text-muted-foreground pointer-events-none">
|
||||||
°
|
°
|
||||||
@@ -136,16 +161,6 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
|
|||||||
>
|
>
|
||||||
<Plus className="h-3.5 w-3.5" />
|
<Plus className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="rotate-right"
|
|
||||||
onClick={rotateRight}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1.5 py-2.5 rounded-lg bg-muted text-muted-foreground hover:bg-primary hover:text-primary-foreground transition-colors text-sm font-medium"
|
|
||||||
title="Rotate 90° clockwise"
|
|
||||||
>
|
|
||||||
Right
|
|
||||||
<RotateCw className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -185,26 +200,26 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
|
|||||||
type="button"
|
type="button"
|
||||||
data-testid="rotate-flip-h"
|
data-testid="rotate-flip-h"
|
||||||
onClick={() => setFlipH(!flipH)}
|
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
|
flipH
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FlipHorizontal className="h-4 w-4" />
|
<FlipHorizontal className="h-3.5 w-3.5" />
|
||||||
Horizontal
|
Horizontal
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="rotate-flip-v"
|
data-testid="rotate-flip-v"
|
||||||
onClick={() => setFlipV(!flipV)}
|
onClick={() => setFlipV(!flipV)}
|
||||||
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 ${
|
||||||
flipV
|
flipV
|
||||||
? "bg-primary text-primary-foreground"
|
? "bg-primary text-primary-foreground"
|
||||||
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
: "bg-muted text-muted-foreground hover:bg-primary/10"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<FlipVertical className="h-4 w-4" />
|
<FlipVertical className="h-3.5 w-3.5" />
|
||||||
Vertical
|
Vertical
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { useFileStore } from "@/stores/file-store";
|
|||||||
interface ProcessResult {
|
interface ProcessResult {
|
||||||
jobId: string;
|
jobId: string;
|
||||||
downloadUrl: string;
|
downloadUrl: string;
|
||||||
|
previewUrl?: string;
|
||||||
originalSize: number;
|
originalSize: number;
|
||||||
processedSize: number;
|
processedSize: number;
|
||||||
savedFileId?: string;
|
savedFileId?: string;
|
||||||
@@ -31,7 +32,7 @@ const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
|||||||
|
|
||||||
// Tools that take a few seconds (not instant like Sharp, not minutes like AI).
|
// 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.
|
// 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) {
|
export function useToolProcessor(toolId: string) {
|
||||||
const {
|
const {
|
||||||
@@ -141,8 +142,8 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhrRef.current = xhr;
|
xhrRef.current = xhr;
|
||||||
|
|
||||||
// Timeout: 60s for fast/medium tools, 5 min for AI tools
|
// Timeout: 60s for fast tools, 3 min for medium (seam carving), 5 min for AI
|
||||||
xhr.timeout = isAiTool ? 300_000 : 60_000;
|
xhr.timeout = isAiTool ? 300_000 : isMediumTool ? 180_000 : 60_000;
|
||||||
|
|
||||||
// For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven)
|
// For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven)
|
||||||
// For medium tools: upload = 0-40%, processing = 40-95% (gradual fill)
|
// For medium tools: upload = 0-40%, processing = 40-95% (gradual fill)
|
||||||
@@ -167,11 +168,11 @@ export function useToolProcessor(toolId: string) {
|
|||||||
stage: isAiTool ? "Starting..." : "Processing...",
|
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) {
|
if (isMediumTool) {
|
||||||
const start = UPLOAD_WEIGHT;
|
const start = UPLOAD_WEIGHT;
|
||||||
const target = 95;
|
const target = 95;
|
||||||
const step = (target - start) / 30; // 30 ticks over ~15s
|
const step = (target - start) / 90; // 90 ticks over ~45s
|
||||||
processingTimerRef.current = setInterval(() => {
|
processingTimerRef.current = setInterval(() => {
|
||||||
setProgress((prev) => {
|
setProgress((prev) => {
|
||||||
if (prev.phase !== "processing") return prev;
|
if (prev.phase !== "processing") return prev;
|
||||||
@@ -194,7 +195,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
try {
|
try {
|
||||||
const result: ProcessResult = JSON.parse(xhr.responseText);
|
const result: ProcessResult = JSON.parse(xhr.responseText);
|
||||||
setJobId(result.jobId);
|
setJobId(result.jobId);
|
||||||
setProcessedUrl(result.downloadUrl);
|
setProcessedUrl(result.downloadUrl, result.previewUrl);
|
||||||
setSizes(result.originalSize, result.processedSize);
|
setSizes(result.originalSize, result.processedSize);
|
||||||
// Update serverFileId if a new version was saved
|
// Update serverFileId if a new version was saved
|
||||||
if (result.savedFileId) {
|
if (result.savedFileId) {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
import { CATEGORIES, TOOLS } from "@stirling-image/shared";
|
||||||
import * as icons from "lucide-react";
|
import * as icons from "lucide-react";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
import { useCallback, useEffect } from "react";
|
import { useCallback, useEffect } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ImageViewer } from "@/components/common/image-viewer";
|
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"];
|
const QUICK_ACTION_IDS = ["resize", "compress", "convert", "remove-background"];
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { setFiles, files, reset, originalBlobUrl, selectedFileName, selectedFileSize } =
|
const {
|
||||||
useFileStore();
|
setFiles,
|
||||||
|
files,
|
||||||
|
reset,
|
||||||
|
originalBlobUrl,
|
||||||
|
selectedFileName,
|
||||||
|
selectedFileSize,
|
||||||
|
currentEntry,
|
||||||
|
} = useFileStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { fetch: fetchSettings } = useSettingsStore();
|
const { fetch: fetchSettings } = useSettingsStore();
|
||||||
|
|
||||||
@@ -141,6 +149,12 @@ export function HomePage() {
|
|||||||
<div className="flex-1 flex items-center justify-center p-6 min-h-0">
|
<div className="flex-1 flex items-center justify-center p-6 min-h-0">
|
||||||
{files.length > 1 ? (
|
{files.length > 1 ? (
|
||||||
<MultiImageViewer />
|
<MultiImageViewer />
|
||||||
|
) : currentEntry?.previewLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||||
|
<p className="text-sm text-muted-foreground">Generating preview...</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||||
|
</div>
|
||||||
) : originalBlobUrl ? (
|
) : originalBlobUrl ? (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={originalBlobUrl}
|
src={originalBlobUrl}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { TOOLS } from "@stirling-image/shared";
|
import { TOOLS } from "@stirling-image/shared";
|
||||||
import * as icons from "lucide-react";
|
import * as icons from "lucide-react";
|
||||||
import { CheckCircle2, ChevronLeft, ChevronRight, Download } from "lucide-react";
|
import { CheckCircle2, ChevronLeft, ChevronRight, Download, Loader2 } from "lucide-react";
|
||||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { Crop } from "react-image-crop";
|
import type { Crop } from "react-image-crop";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
@@ -20,6 +20,24 @@ import { formatFileSize } from "@/lib/download";
|
|||||||
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
import { getToolRegistryEntry } from "@/lib/tool-registry";
|
||||||
import { useFileStore } from "@/stores/file-store";
|
import { useFileStore } from "@/stores/file-store";
|
||||||
|
|
||||||
|
/** Formats that browsers can render in <img> 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 */
|
/** File selection indicator shown in left panel */
|
||||||
function FileSelectionInfo({
|
function FileSelectionInfo({
|
||||||
files,
|
files,
|
||||||
@@ -84,6 +102,7 @@ export function ToolPage() {
|
|||||||
addFiles,
|
addFiles,
|
||||||
reset,
|
reset,
|
||||||
processedUrl,
|
processedUrl,
|
||||||
|
processedPreviewUrl,
|
||||||
originalBlobUrl,
|
originalBlobUrl,
|
||||||
originalSize,
|
originalSize,
|
||||||
processedSize,
|
processedSize,
|
||||||
@@ -170,7 +189,7 @@ export function ToolPage() {
|
|||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.multiple = true;
|
input.multiple = true;
|
||||||
input.accept = "image/*";
|
input.accept = "image/*,.heic,.heif,.hif";
|
||||||
input.onchange = (e) => {
|
input.onchange = (e) => {
|
||||||
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
const newFiles = Array.from((e.target as HTMLInputElement).files || []);
|
||||||
if (newFiles.length > 0) addFiles(newFiles);
|
if (newFiles.length > 0) addFiles(newFiles);
|
||||||
@@ -208,11 +227,15 @@ export function ToolPage() {
|
|||||||
const isNoDropzone = displayMode === "no-dropzone";
|
const isNoDropzone = displayMode === "no-dropzone";
|
||||||
const isLivePreview = registryEntry.livePreview ?? false;
|
const isLivePreview = registryEntry.livePreview ?? false;
|
||||||
|
|
||||||
// Derive processed file info from context
|
// Derive processed file info from the actual download URL (has correct extension)
|
||||||
const processedFileName = selectedFileName ? `processed-${selectedFileName}` : "processed-image";
|
const processedFileName = processedUrl
|
||||||
const processedFileType = selectedFileName
|
? decodeURIComponent(processedUrl.split("/").pop() ?? "processed-image")
|
||||||
? selectedFileName.split(".").pop()?.toUpperCase() || "IMAGE"
|
: "processed-image";
|
||||||
: "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
|
// Build settings props
|
||||||
const settingsProps = {
|
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 (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-4 text-center p-8">
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-100 dark:bg-green-900/30 flex items-center justify-center">
|
||||||
|
<CheckCircle2 className="h-8 w-8 text-green-600 dark:text-green-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">Conversion complete</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">{processedFileName}</p>
|
||||||
|
{processedSize != null && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatFileSize(processedSize)} · {processedFileType}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground max-w-xs">
|
||||||
|
{processedFileType} files cannot be previewed in the browser. Use the download button to
|
||||||
|
save your file.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
hasProcessed &&
|
hasProcessed &&
|
||||||
originalBlobUrl &&
|
originalBlobUrl &&
|
||||||
@@ -295,7 +342,7 @@ export function ToolPage() {
|
|||||||
return (
|
return (
|
||||||
<SideBySideComparison
|
<SideBySideComparison
|
||||||
beforeSrc={originalBlobUrl}
|
beforeSrc={originalBlobUrl}
|
||||||
afterSrc={processedUrl}
|
afterSrc={displayUrl}
|
||||||
beforeSize={originalSize ?? undefined}
|
beforeSize={originalSize ?? undefined}
|
||||||
afterSize={processedSize ?? undefined}
|
afterSize={processedSize ?? undefined}
|
||||||
/>
|
/>
|
||||||
@@ -308,11 +355,7 @@ export function ToolPage() {
|
|||||||
(displayMode === "live-preview" || displayMode === "no-comparison")
|
(displayMode === "live-preview" || displayMode === "no-comparison")
|
||||||
) {
|
) {
|
||||||
return (
|
return (
|
||||||
<ImageViewer
|
<ImageViewer src={displayUrl} filename={processedFileName} fileSize={processedSize ?? 0} />
|
||||||
src={processedUrl}
|
|
||||||
filename={processedFileName}
|
|
||||||
fileSize={processedSize ?? 0}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,13 +363,23 @@ export function ToolPage() {
|
|||||||
return (
|
return (
|
||||||
<BeforeAfterSlider
|
<BeforeAfterSlider
|
||||||
beforeSrc={originalBlobUrl}
|
beforeSrc={originalBlobUrl}
|
||||||
afterSrc={processedUrl}
|
afterSrc={displayUrl}
|
||||||
beforeSize={originalSize ?? undefined}
|
beforeSize={originalSize ?? undefined}
|
||||||
afterSize={processedSize ?? undefined}
|
afterSize={processedSize ?? undefined}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasFile && currentEntry?.previewLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full gap-3 text-center">
|
||||||
|
<Loader2 className="h-8 w-8 text-muted-foreground animate-spin" />
|
||||||
|
<p className="text-sm text-muted-foreground">Generating preview...</p>
|
||||||
|
<p className="text-xs text-muted-foreground/60">{selectedFileName}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (hasFile && originalBlobUrl) {
|
if (hasFile && originalBlobUrl) {
|
||||||
return (
|
return (
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
@@ -413,7 +466,7 @@ export function ToolPage() {
|
|||||||
fileSize={processedSize}
|
fileSize={processedSize}
|
||||||
fileType={processedFileType}
|
fileType={processedFileType}
|
||||||
downloadUrl={processedUrl}
|
downloadUrl={processedUrl}
|
||||||
previewUrl={processedUrl}
|
previewUrl={isProcessedPreviewable ? processedUrl : (processedPreviewUrl ?? undefined)}
|
||||||
onUndo={handleUndo}
|
onUndo={handleUndo}
|
||||||
currentToolId={tool?.id ?? ""}
|
currentToolId={tool?.id ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
|
import { formatHeaders } from "@/lib/api";
|
||||||
|
|
||||||
export interface FileEntry {
|
export interface FileEntry {
|
||||||
file: File;
|
file: File;
|
||||||
blobUrl: string;
|
blobUrl: string;
|
||||||
|
previewLoading: boolean;
|
||||||
processedUrl: string | null;
|
processedUrl: string | null;
|
||||||
|
processedPreviewUrl: string | null;
|
||||||
processedSize: number | null;
|
processedSize: number | null;
|
||||||
originalSize: number;
|
originalSize: number;
|
||||||
status: "pending" | "processing" | "completed" | "failed";
|
status: "pending" | "processing" | "completed" | "failed";
|
||||||
@@ -19,7 +22,9 @@ function createEntry(file: File): FileEntry {
|
|||||||
return {
|
return {
|
||||||
file,
|
file,
|
||||||
blobUrl: URL.createObjectURL(file),
|
blobUrl: URL.createObjectURL(file),
|
||||||
|
previewLoading: needsServerPreview(file),
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
|
processedPreviewUrl: null,
|
||||||
processedSize: null,
|
processedSize: null,
|
||||||
originalSize: file.size,
|
originalSize: file.size,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
@@ -51,6 +56,7 @@ function deriveSelected(entries: FileEntry[], selectedIndex: number) {
|
|||||||
selectedFileSize: entry ? entry.file.size : null,
|
selectedFileSize: entry ? entry.file.size : null,
|
||||||
originalBlobUrl: entry ? entry.blobUrl : null,
|
originalBlobUrl: entry ? entry.blobUrl : null,
|
||||||
processedUrl: entry ? entry.processedUrl : null,
|
processedUrl: entry ? entry.processedUrl : null,
|
||||||
|
processedPreviewUrl: entry ? entry.processedPreviewUrl : null,
|
||||||
originalSize: entry ? entry.originalSize : null,
|
originalSize: entry ? entry.originalSize : null,
|
||||||
processedSize: entry ? entry.processedSize : null,
|
processedSize: entry ? entry.processedSize : null,
|
||||||
};
|
};
|
||||||
@@ -69,6 +75,34 @@ function deriveFiles(entries: FileEntry[]): File[] {
|
|||||||
return prevFiles;
|
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<string | null> {
|
||||||
|
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
|
// Store
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -88,6 +122,7 @@ interface FileState {
|
|||||||
readonly selectedFileSize: number | null;
|
readonly selectedFileSize: number | null;
|
||||||
readonly originalBlobUrl: string | null;
|
readonly originalBlobUrl: string | null;
|
||||||
readonly processedUrl: string | null;
|
readonly processedUrl: string | null;
|
||||||
|
readonly processedPreviewUrl: string | null;
|
||||||
readonly originalSize: number | null;
|
readonly originalSize: number | null;
|
||||||
readonly processedSize: number | null;
|
readonly processedSize: number | null;
|
||||||
|
|
||||||
@@ -103,7 +138,7 @@ interface FileState {
|
|||||||
setProcessing: (v: boolean) => void;
|
setProcessing: (v: boolean) => void;
|
||||||
setError: (e: string | null) => void;
|
setError: (e: string | null) => void;
|
||||||
setJobId: (id: string) => void;
|
setJobId: (id: string) => void;
|
||||||
setProcessedUrl: (url: string | null) => void;
|
setProcessedUrl: (url: string | null, previewUrl?: string | null) => void;
|
||||||
setSizes: (original: number, processed: number) => void;
|
setSizes: (original: number, processed: number) => void;
|
||||||
undoProcessing: () => void;
|
undoProcessing: () => void;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
@@ -133,12 +168,41 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
files: deriveFiles(entries),
|
files: deriveFiles(entries),
|
||||||
...deriveSelected(entries, 0),
|
...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) => {
|
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;
|
const idx = get().selectedIndex;
|
||||||
set({ entries, files: deriveFiles(entries), ...deriveSelected(entries, idx) });
|
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) => {
|
removeFile: (index) => {
|
||||||
@@ -207,7 +271,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
// no-op for backward compat
|
// no-op for backward compat
|
||||||
},
|
},
|
||||||
|
|
||||||
setProcessedUrl: (url) => {
|
setProcessedUrl: (url, previewUrl) => {
|
||||||
const { entries, selectedIndex } = get();
|
const { entries, selectedIndex } = get();
|
||||||
if (!entries[selectedIndex]) return;
|
if (!entries[selectedIndex]) return;
|
||||||
const updated = [...entries];
|
const updated = [...entries];
|
||||||
@@ -215,12 +279,14 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
updated[selectedIndex] = {
|
updated[selectedIndex] = {
|
||||||
...updated[selectedIndex],
|
...updated[selectedIndex],
|
||||||
processedUrl: url,
|
processedUrl: url,
|
||||||
|
processedPreviewUrl: previewUrl ?? null,
|
||||||
status: "completed",
|
status: "completed",
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
updated[selectedIndex] = {
|
updated[selectedIndex] = {
|
||||||
...updated[selectedIndex],
|
...updated[selectedIndex],
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
|
processedPreviewUrl: null,
|
||||||
status: "pending",
|
status: "pending",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -247,6 +313,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
|||||||
const resetEntries = entries.map((e) => ({
|
const resetEntries = entries.map((e) => ({
|
||||||
...e,
|
...e,
|
||||||
processedUrl: null,
|
processedUrl: null,
|
||||||
|
processedPreviewUrl: null,
|
||||||
processedSize: null,
|
processedSize: null,
|
||||||
status: "pending" as const,
|
status: "pending" as const,
|
||||||
error: null,
|
error: null,
|
||||||
|
|||||||
@@ -47,9 +47,17 @@ async function findCaire(): Promise<string> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Max pixels on the longest edge before downscaling for caire. */
|
||||||
|
const MAX_CAIRE_DIMENSION = 1200;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content-aware resize using caire (Go seam carving engine).
|
* Content-aware resize using caire (Go seam carving engine).
|
||||||
* Supports both shrinking and enlarging via seam removal/insertion.
|
* 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(
|
export async function seamCarve(
|
||||||
inputBuffer: Buffer,
|
inputBuffer: Buffer,
|
||||||
@@ -58,38 +66,71 @@ export async function seamCarve(
|
|||||||
): Promise<SeamCarveResult> {
|
): Promise<SeamCarveResult> {
|
||||||
const cairePath = await findCaire();
|
const cairePath = await findCaire();
|
||||||
const id = randomUUID();
|
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`);
|
const outputPath = join(outputDir, `caire-out-${id}.png`);
|
||||||
|
|
||||||
try {
|
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
|
// Build caire arguments
|
||||||
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
const args = ["-in", inputPath, "-out", outputPath, "-preview=false"];
|
||||||
|
|
||||||
if (options.square) {
|
if (options.square) {
|
||||||
// Caire -square requires -width and -height set to the shortest edge
|
const shortest = Math.min(width, height);
|
||||||
const meta = await sharp(inputBuffer).metadata();
|
|
||||||
const shortest = Math.min(meta.width ?? 0, meta.height ?? 0);
|
|
||||||
args.push("-square", "-width", String(shortest), "-height", String(shortest));
|
args.push("-square", "-width", String(shortest), "-height", String(shortest));
|
||||||
} else {
|
} else {
|
||||||
if (options.width) args.push("-width", String(options.width));
|
if (options.width) {
|
||||||
if (options.height) args.push("-height", String(options.height));
|
// 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.protectFaces) args.push("-face");
|
||||||
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
if (options.blurRadius !== undefined) args.push("-blur", String(options.blurRadius));
|
||||||
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
|
if (options.sobelThreshold !== undefined) args.push("-sobel", String(options.sobelThreshold));
|
||||||
|
|
||||||
await execFileAsync(cairePath, args, { timeout: 60_000 });
|
await execFileAsync(cairePath, args, { timeout: 120_000 });
|
||||||
|
|
||||||
const buffer = await readFile(outputPath);
|
const buffer = await readFile(outputPath);
|
||||||
const meta = await sharp(buffer).metadata();
|
const outMeta = await sharp(buffer).metadata();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buffer,
|
buffer,
|
||||||
width: meta.width ?? 0,
|
width: outMeta.width ?? 0,
|
||||||
height: meta.height ?? 0,
|
height: outMeta.height ?? 0,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
await rm(inputPath, { force: true }).catch(() => {});
|
await rm(inputPath, { force: true }).catch(() => {});
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface OperationResult {
|
|||||||
info: ImageInfo;
|
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 {
|
export interface ResizeOptions {
|
||||||
width?: number;
|
width?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user