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:
Siddharth Kumar Sah
2026-04-11 23:27:44 +08:00
parent e0869477d4
commit 6f5283019b
18 changed files with 425 additions and 86 deletions
+70 -3
View File
@@ -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<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
// ---------------------------------------------------------------------------
@@ -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<FileState>((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<FileState>((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<FileState>((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<FileState>((set, get) => ({
const resetEntries = entries.map((e) => ({
...e,
processedUrl: null,
processedPreviewUrl: null,
processedSize: null,
status: "pending" as const,
error: null,