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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user