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
+9 -1
View File
@@ -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);
@@ -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 (
<section
@@ -49,13 +73,28 @@ export function MultiImageViewer() {
</button>
)}
<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
beforeSrc={currentEntry.blobUrl}
afterSrc={currentEntry.processedUrl ?? ""}
afterSrc={displayUrl ?? ""}
beforeSize={currentEntry.originalSize}
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
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 type { FileEntry } from "@/stores/file-store";
@@ -44,12 +44,18 @@ export function ThumbnailStrip({ entries, selectedIndex, onSelect }: ThumbnailSt
style={{ width: 52, height: 38 }}
title={entry.file.name}
>
<img
src={entry.processedUrl ?? entry.blobUrl}
alt={entry.file.name}
className="w-full h-full object-cover"
draggable={false}
/>
{entry.previewLoading ? (
<div className="w-full h-full flex items-center justify-center bg-muted">
<Loader2 className="h-3.5 w-3.5 text-muted-foreground animate-spin" />
</div>
) : (
<img
src={entry.processedPreviewUrl ?? entry.processedUrl ?? entry.blobUrl}
alt={entry.file.name}
className="w-full h-full object-cover"
draggable={false}
/>
)}
{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">
<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 { 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<string, unknown>) => void;
@@ -132,10 +132,6 @@ export function ConvertSettings() {
<div className="text-xs text-muted-foreground space-y-0.5">
<p>Original: {(originalSize / 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>
)}
@@ -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);
@@ -87,20 +87,45 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
return (
<div className="space-y-4">
{/* Quick rotate */}
{/* Quick rotate presets */}
<div>
<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
type="button"
data-testid="rotate-left"
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"
>
<RotateCcw className="h-4 w-4" />
Left
<RotateCcw className="h-3.5 w-3.5" />
-90°
</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
type="button"
onClick={() => setRotation((r) => r - 1)}
@@ -122,7 +147,7 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
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">
°
@@ -136,16 +161,6 @@ export function RotateControls({ onChange, onPreviewTransform, resetSignal }: Ro
>
<Plus className="h-3.5 w-3.5" />
</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>
@@ -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"
}`}
>
<FlipHorizontal className="h-4 w-4" />
<FlipHorizontal className="h-3.5 w-3.5" />
Horizontal
</button>
<button
type="button"
data-testid="rotate-flip-v"
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
? "bg-primary text-primary-foreground"
: "bg-muted text-muted-foreground hover:bg-primary/10"
}`}
>
<FlipVertical className="h-4 w-4" />
<FlipVertical className="h-3.5 w-3.5" />
Vertical
</button>
</div>