mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix: clean up crop mode toggle, extend timeouts for long-running tools
- Remove content-aware crop mode toggle from tool-page (now handled internally) - Add content-aware-resize/crop to LONG_RUNNING_TOOLS for extended XHR timeout - Remove unused onModeChange prop from CropProps - Bump HEIC converter test timeouts to 60s to fix flaky failures
This commit is contained in:
@@ -5,15 +5,6 @@ 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 EXTEND_PRESETS = [
|
|
||||||
{ label: "16:9", aspect: 16 / 9 },
|
|
||||||
{ label: "1:1", aspect: 1 },
|
|
||||||
{ label: "4:3", aspect: 4 / 3 },
|
|
||||||
{ label: "3:2", aspect: 3 / 2 },
|
|
||||||
{ label: "9:16", aspect: 9 / 16 },
|
|
||||||
{ label: "4:5", aspect: 4 / 5 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const ASPECT_PRESETS = [
|
const ASPECT_PRESETS = [
|
||||||
{ label: "Free", value: undefined as number | undefined },
|
{ label: "Free", value: undefined as number | undefined },
|
||||||
{ label: "1:1", value: 1 },
|
{ label: "1:1", value: 1 },
|
||||||
@@ -35,7 +26,6 @@ export interface CropSettingsProps {
|
|||||||
onCropChange: (crop: Crop) => void;
|
onCropChange: (crop: Crop) => void;
|
||||||
onAspectChange: (aspect: number | undefined) => void;
|
onAspectChange: (aspect: number | undefined) => void;
|
||||||
onGridToggle: (show: boolean) => void;
|
onGridToggle: (show: boolean) => void;
|
||||||
onModeChange?: (mode: "standard" | "content-aware") => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CropSettings({
|
export function CropSettings({
|
||||||
@@ -43,14 +33,10 @@ export function CropSettings({
|
|||||||
onCropChange,
|
onCropChange,
|
||||||
onAspectChange,
|
onAspectChange,
|
||||||
onGridToggle,
|
onGridToggle,
|
||||||
onModeChange,
|
|
||||||
}: CropSettingsProps) {
|
}: CropSettingsProps) {
|
||||||
const { files } = useFileStore();
|
const { files } = useFileStore();
|
||||||
const standardCrop = useToolProcessor("crop");
|
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } =
|
||||||
const contentAwareCrop = useToolProcessor("content-aware-crop");
|
useToolProcessor("crop");
|
||||||
const [mode, setMode] = useState<"standard" | "content-aware">("standard");
|
|
||||||
const active = mode === "content-aware" ? contentAwareCrop : standardCrop;
|
|
||||||
const { processFiles, processAllFiles, processing, error, downloadUrl, progress } = active;
|
|
||||||
|
|
||||||
const { crop, aspect, showGrid, imgDimensions } = cropState;
|
const { crop, aspect, showGrid, imgDimensions } = cropState;
|
||||||
|
|
||||||
@@ -58,17 +44,6 @@ export function CropSettings({
|
|||||||
const [customW, setCustomW] = useState("3");
|
const [customW, setCustomW] = useState("3");
|
||||||
const [customH, setCustomH] = useState("2");
|
const [customH, setCustomH] = useState("2");
|
||||||
|
|
||||||
// Content-aware extension state
|
|
||||||
const [extendTop, setExtendTop] = useState(0);
|
|
||||||
const [extendRight, setExtendRight] = useState(0);
|
|
||||||
const [extendBottom, setExtendBottom] = useState(0);
|
|
||||||
const [extendLeft, setExtendLeft] = useState(0);
|
|
||||||
|
|
||||||
// Notify parent when mode changes
|
|
||||||
useEffect(() => {
|
|
||||||
onModeChange?.(mode);
|
|
||||||
}, [mode, onModeChange]);
|
|
||||||
|
|
||||||
// Convert percentage crop to pixel values
|
// Convert percentage crop to pixel values
|
||||||
const toPixels = useCallback(
|
const toPixels = useCallback(
|
||||||
(c: Crop) => {
|
(c: Crop) => {
|
||||||
@@ -205,7 +180,7 @@ export function CropSettings({
|
|||||||
height: Math.max(0.1, crop.height),
|
height: Math.max(0.1, crop.height),
|
||||||
unit: "percent" as const,
|
unit: "percent" as const,
|
||||||
};
|
};
|
||||||
standardCrop.processAllFiles(files, settings);
|
processAllFiles(files, settings);
|
||||||
} else {
|
} else {
|
||||||
const settings = {
|
const settings = {
|
||||||
left: pixels.left,
|
left: pixels.left,
|
||||||
@@ -213,60 +188,16 @@ export function CropSettings({
|
|||||||
width: Math.max(1, pixels.width),
|
width: Math.max(1, pixels.width),
|
||||||
height: Math.max(1, pixels.height),
|
height: Math.max(1, pixels.height),
|
||||||
};
|
};
|
||||||
standardCrop.processFiles(files, settings);
|
processFiles(files, settings);
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleExtendPreset = useCallback(
|
|
||||||
(targetAspect: number) => {
|
|
||||||
if (!imgDimensions) return;
|
|
||||||
const { width: w, height: h } = imgDimensions;
|
|
||||||
const currentAspect = w / h;
|
|
||||||
let top = 0;
|
|
||||||
let right = 0;
|
|
||||||
let bottom = 0;
|
|
||||||
let left = 0;
|
|
||||||
if (targetAspect > currentAspect) {
|
|
||||||
// Need wider -- extend left and right
|
|
||||||
const newWidth = Math.round(h * targetAspect);
|
|
||||||
const extra = newWidth - w;
|
|
||||||
left = Math.round(extra / 2);
|
|
||||||
right = extra - left;
|
|
||||||
} else {
|
|
||||||
// Need taller -- extend top and bottom
|
|
||||||
const newHeight = Math.round(w / targetAspect);
|
|
||||||
const extra = newHeight - h;
|
|
||||||
top = Math.round(extra / 2);
|
|
||||||
bottom = extra - top;
|
|
||||||
}
|
|
||||||
setExtendTop(top);
|
|
||||||
setExtendRight(right);
|
|
||||||
setExtendBottom(bottom);
|
|
||||||
setExtendLeft(left);
|
|
||||||
},
|
|
||||||
[imgDimensions],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleContentAwareProcess = () => {
|
|
||||||
const settings = { extendTop, extendRight, extendBottom, extendLeft };
|
|
||||||
if (files.length > 1) {
|
|
||||||
contentAwareCrop.processAllFiles(files, settings);
|
|
||||||
} else {
|
|
||||||
contentAwareCrop.processFiles(files, settings);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
const hasSize = pixels.width > 0 && pixels.height > 0;
|
const hasSize = pixels.width > 0 && pixels.height > 0;
|
||||||
const hasExtension = extendTop > 0 || extendRight > 0 || extendBottom > 0 || extendLeft > 0;
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (mode === "content-aware") {
|
if (hasFile && hasSize && !processing) handleProcess();
|
||||||
if (hasFile && hasExtension && !processing) handleContentAwareProcess();
|
|
||||||
} else {
|
|
||||||
if (hasFile && hasSize && !processing) handleProcess();
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Find which preset label matches the current aspect
|
// Find which preset label matches the current aspect
|
||||||
@@ -278,280 +209,157 @@ export function CropSettings({
|
|||||||
return false;
|
return false;
|
||||||
})?.label;
|
})?.label;
|
||||||
|
|
||||||
const canSubmit =
|
const canSubmit = hasFile && hasSize && !processing;
|
||||||
mode === "content-aware"
|
|
||||||
? hasFile && hasExtension && !processing
|
|
||||||
: hasFile && hasSize && !processing;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4" data-crop-form>
|
<form onSubmit={handleSubmit} className="space-y-4" data-crop-form>
|
||||||
{/* Mode tabs */}
|
{/* Aspect Ratio */}
|
||||||
<div className="flex gap-1 mb-4">
|
<div>
|
||||||
<button
|
<div className="flex items-center justify-between mb-1">
|
||||||
type="button"
|
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
|
||||||
onClick={() => setMode("standard")}
|
{aspect !== undefined && (
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${
|
<button
|
||||||
mode === "standard"
|
type="button"
|
||||||
? "bg-primary text-primary-foreground"
|
onClick={handleSwapAspect}
|
||||||
: "bg-muted text-muted-foreground"
|
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
title="Swap width/height"
|
||||||
>
|
>
|
||||||
Standard
|
<ArrowLeftRight className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setMode("content-aware")}
|
|
||||||
className={`flex-1 text-xs py-1.5 rounded ${
|
|
||||||
mode === "content-aware"
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Content-Aware
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Standard tab */}
|
|
||||||
{mode === "standard" && (
|
|
||||||
<>
|
|
||||||
{/* Aspect Ratio */}
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between mb-1">
|
|
||||||
<p className="text-xs text-muted-foreground">Aspect Ratio</p>
|
|
||||||
{aspect !== undefined && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSwapAspect}
|
|
||||||
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
|
|
||||||
title="Swap width/height"
|
|
||||||
>
|
|
||||||
<ArrowLeftRight className="h-3.5 w-3.5" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{ASPECT_PRESETS.map(({ label, value }) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
key={label}
|
|
||||||
onClick={() => handleAspectSelect(value)}
|
|
||||||
className={`px-2 py-1.5 rounded text-xs transition-colors ${
|
|
||||||
!customMode && activePresetLabel === label
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCustomSelect}
|
|
||||||
className={`px-2 py-1.5 rounded text-xs transition-colors ${
|
|
||||||
customMode
|
|
||||||
? "bg-primary text-primary-foreground"
|
|
||||||
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Custom
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{customMode && (
|
|
||||||
<div className="flex items-center gap-1.5 mt-2">
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={customW}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomW(e.target.value);
|
|
||||||
const w = Number(e.target.value);
|
|
||||||
const h = Number(customH);
|
|
||||||
if (w > 0 && h > 0) applyCustomAspect(w, h);
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center"
|
|
||||||
/>
|
|
||||||
<span className="text-xs text-muted-foreground">:</span>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
value={customH}
|
|
||||||
onChange={(e) => {
|
|
||||||
setCustomH(e.target.value);
|
|
||||||
const w = Number(customW);
|
|
||||||
const h = Number(e.target.value);
|
|
||||||
if (w > 0 && h > 0) applyCustomAspect(w, h);
|
|
||||||
}}
|
|
||||||
min={1}
|
|
||||||
className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Position & Size */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground">Position & Size</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2 mt-1">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
|
|
||||||
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="crop-x"
|
|
||||||
type="number"
|
|
||||||
value={pixels.left}
|
|
||||||
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
|
|
||||||
min={0}
|
|
||||||
max={imgDimensions ? imgDimensions.width - 1 : undefined}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
|
|
||||||
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="crop-y"
|
|
||||||
type="number"
|
|
||||||
value={pixels.top}
|
|
||||||
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
|
|
||||||
min={0}
|
|
||||||
max={imgDimensions ? imgDimensions.height - 1 : undefined}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
|
|
||||||
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="crop-width"
|
|
||||||
type="number"
|
|
||||||
value={pixels.width}
|
|
||||||
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
|
|
||||||
min={1}
|
|
||||||
max={imgDimensions ? imgDimensions.width : undefined}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
|
|
||||||
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="crop-height"
|
|
||||||
type="number"
|
|
||||||
value={pixels.height}
|
|
||||||
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
|
|
||||||
min={1}
|
|
||||||
max={imgDimensions ? imgDimensions.height : undefined}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Grid overlay toggle */}
|
|
||||||
<label className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={showGrid}
|
|
||||||
onChange={(e) => onGridToggle(e.target.checked)}
|
|
||||||
className="accent-primary h-3.5 w-3.5"
|
|
||||||
/>
|
|
||||||
<Grid3x3 className="h-3.5 w-3.5" />
|
|
||||||
Rule of Thirds
|
|
||||||
</label>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Content-Aware tab */}
|
|
||||||
{mode === "content-aware" && (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Aspect ratio presets */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">Extend to aspect ratio</p>
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{EXTEND_PRESETS.map(({ label, aspect: a }) => (
|
|
||||||
<button
|
|
||||||
key={label}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleExtendPreset(a)}
|
|
||||||
className="px-2 py-1.5 rounded text-xs bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground transition-colors"
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-side extension */}
|
|
||||||
<div>
|
|
||||||
<p className="text-xs text-muted-foreground mb-1">Extend by (pixels)</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
<div>
|
|
||||||
<label htmlFor="extend-top" className="text-[10px] text-muted-foreground">
|
|
||||||
Top
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="extend-top"
|
|
||||||
type="number"
|
|
||||||
value={extendTop}
|
|
||||||
onChange={(e) => setExtendTop(Math.max(0, Number(e.target.value)))}
|
|
||||||
min={0}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="extend-right" className="text-[10px] text-muted-foreground">
|
|
||||||
Right
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="extend-right"
|
|
||||||
type="number"
|
|
||||||
value={extendRight}
|
|
||||||
onChange={(e) => setExtendRight(Math.max(0, Number(e.target.value)))}
|
|
||||||
min={0}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="extend-bottom" className="text-[10px] text-muted-foreground">
|
|
||||||
Bottom
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="extend-bottom"
|
|
||||||
type="number"
|
|
||||||
value={extendBottom}
|
|
||||||
onChange={(e) => setExtendBottom(Math.max(0, Number(e.target.value)))}
|
|
||||||
min={0}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label htmlFor="extend-left" className="text-[10px] text-muted-foreground">
|
|
||||||
Left
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
id="extend-left"
|
|
||||||
type="number"
|
|
||||||
value={extendLeft}
|
|
||||||
onChange={(e) => setExtendLeft(Math.max(0, Number(e.target.value)))}
|
|
||||||
min={0}
|
|
||||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output size display */}
|
|
||||||
{imgDimensions && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
New size: {imgDimensions.width + extendLeft + extendRight} x{" "}
|
|
||||||
{imgDimensions.height + extendTop + extendBottom}
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{ASPECT_PRESETS.map(({ label, value }) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
key={label}
|
||||||
|
onClick={() => handleAspectSelect(value)}
|
||||||
|
className={`px-2 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
!customMode && activePresetLabel === label
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCustomSelect}
|
||||||
|
className={`px-2 py-1.5 rounded text-xs transition-colors ${
|
||||||
|
customMode
|
||||||
|
? "bg-primary text-primary-foreground"
|
||||||
|
: "bg-muted text-muted-foreground hover:bg-primary/20 hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Custom
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{customMode && (
|
||||||
|
<div className="flex items-center gap-1.5 mt-2">
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={customW}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomW(e.target.value);
|
||||||
|
const w = Number(e.target.value);
|
||||||
|
const h = Number(customH);
|
||||||
|
if (w > 0 && h > 0) applyCustomAspect(w, h);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center"
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-muted-foreground">:</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={customH}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCustomH(e.target.value);
|
||||||
|
const w = Number(customW);
|
||||||
|
const h = Number(e.target.value);
|
||||||
|
if (w > 0 && h > 0) applyCustomAspect(w, h);
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
className="w-16 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums text-center"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Position & Size */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground">Position & Size</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-1">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="crop-x" className="text-[10px] text-muted-foreground">
|
||||||
|
X{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="crop-x"
|
||||||
|
type="number"
|
||||||
|
value={pixels.left}
|
||||||
|
onChange={(e) => handlePixelChange("left", Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
max={imgDimensions ? imgDimensions.width - 1 : undefined}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="crop-y" className="text-[10px] text-muted-foreground">
|
||||||
|
Y{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="crop-y"
|
||||||
|
type="number"
|
||||||
|
value={pixels.top}
|
||||||
|
onChange={(e) => handlePixelChange("top", Number(e.target.value))}
|
||||||
|
min={0}
|
||||||
|
max={imgDimensions ? imgDimensions.height - 1 : undefined}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="crop-width" className="text-[10px] text-muted-foreground">
|
||||||
|
Width{imgDimensions ? ` (of ${imgDimensions.width})` : ""}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="crop-width"
|
||||||
|
type="number"
|
||||||
|
value={pixels.width}
|
||||||
|
onChange={(e) => handlePixelChange("width", Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={imgDimensions ? imgDimensions.width : undefined}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="crop-height" className="text-[10px] text-muted-foreground">
|
||||||
|
Height{imgDimensions ? ` (of ${imgDimensions.height})` : ""}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="crop-height"
|
||||||
|
type="number"
|
||||||
|
value={pixels.height}
|
||||||
|
onChange={(e) => handlePixelChange("height", Number(e.target.value))}
|
||||||
|
min={1}
|
||||||
|
max={imgDimensions ? imgDimensions.height : undefined}
|
||||||
|
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground tabular-nums"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grid overlay toggle */}
|
||||||
|
<label className="flex items-center gap-2 w-full px-2 py-1.5 rounded text-xs cursor-pointer select-none text-muted-foreground hover:text-foreground transition-colors">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showGrid}
|
||||||
|
onChange={(e) => onGridToggle(e.target.checked)}
|
||||||
|
className="accent-primary h-3.5 w-3.5"
|
||||||
|
/>
|
||||||
|
<Grid3x3 className="h-3.5 w-3.5" />
|
||||||
|
Rule of Thirds
|
||||||
|
</label>
|
||||||
|
|
||||||
{/* Error */}
|
{/* Error */}
|
||||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||||
@@ -561,7 +369,7 @@ export function CropSettings({
|
|||||||
<ProgressCard
|
<ProgressCard
|
||||||
active={processing}
|
active={processing}
|
||||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||||
label={mode === "content-aware" ? "Extending" : "Cropping"}
|
label="Cropping"
|
||||||
stage={progress.stage}
|
stage={progress.stage}
|
||||||
percent={progress.percent}
|
percent={progress.percent}
|
||||||
elapsed={progress.elapsed}
|
elapsed={progress.elapsed}
|
||||||
@@ -573,13 +381,7 @@ export function CropSettings({
|
|||||||
disabled={!canSubmit}
|
disabled={!canSubmit}
|
||||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||||
>
|
>
|
||||||
{mode === "content-aware"
|
{files.length > 1 ? `Crop (${files.length} files)` : "Crop"}
|
||||||
? files.length > 1
|
|
||||||
? `Extend (${files.length} files)`
|
|
||||||
: "Extend"
|
|
||||||
: files.length > 1
|
|
||||||
? `Crop (${files.length} files)`
|
|
||||||
: "Crop"}
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const IDLE_PROGRESS: ToolProgress = {
|
|||||||
// AI tools return 202 and deliver results via SSE (not XHR response).
|
// AI tools return 202 and deliver results via SSE (not XHR response).
|
||||||
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||||
|
|
||||||
|
// Tools that are not Python sidecar but still need an extended XHR timeout.
|
||||||
|
const LONG_RUNNING_TOOLS = new Set<string>(["content-aware-resize", "content-aware-crop"]);
|
||||||
|
|
||||||
const UPLOAD_WEIGHT = 15;
|
const UPLOAD_WEIGHT = 15;
|
||||||
|
|
||||||
export function useToolProcessor(toolId: string) {
|
export function useToolProcessor(toolId: string) {
|
||||||
@@ -175,7 +178,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhrRef.current = xhr;
|
xhrRef.current = xhr;
|
||||||
|
|
||||||
xhr.timeout = isAiTool ? 600_000 : 120_000;
|
xhr.timeout = isAiTool || LONG_RUNNING_TOOLS.has(toolId) ? 600_000 : 120_000;
|
||||||
|
|
||||||
xhr.upload.onprogress = (event) => {
|
xhr.upload.onprogress = (event) => {
|
||||||
if (event.lengthComputable) {
|
if (event.lengthComputable) {
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ export interface CropProps {
|
|||||||
onCropChange: (crop: Crop) => void;
|
onCropChange: (crop: Crop) => void;
|
||||||
onAspectChange: (aspect: number | undefined) => void;
|
onAspectChange: (aspect: number | undefined) => void;
|
||||||
onGridToggle: (show: boolean) => void;
|
onGridToggle: (show: boolean) => void;
|
||||||
onModeChange?: (mode: "standard" | "content-aware") => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EraserProps {
|
export interface EraserProps {
|
||||||
|
|||||||
@@ -229,7 +229,6 @@ export function ToolPage() {
|
|||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [cropMode, setCropMode] = useState<"standard" | "content-aware">("standard");
|
|
||||||
|
|
||||||
const cropState = useMemo(
|
const cropState = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -267,7 +266,6 @@ export function ToolPage() {
|
|||||||
setCropAspect(undefined);
|
setCropAspect(undefined);
|
||||||
setCropShowGrid(true);
|
setCropShowGrid(true);
|
||||||
setCropImgDimensions(null);
|
setCropImgDimensions(null);
|
||||||
setCropMode("standard");
|
|
||||||
setEraserHasStrokes(false);
|
setEraserHasStrokes(false);
|
||||||
setEraserBrushSize(30);
|
setEraserBrushSize(30);
|
||||||
setEraserSliderInitPos(null);
|
setEraserSliderInitPos(null);
|
||||||
@@ -391,7 +389,6 @@ export function ToolPage() {
|
|||||||
onCropChange: setCropCrop,
|
onCropChange: setCropCrop,
|
||||||
onAspectChange: setCropAspect,
|
onAspectChange: setCropAspect,
|
||||||
onGridToggle: setCropShowGrid,
|
onGridToggle: setCropShowGrid,
|
||||||
onModeChange: setCropMode,
|
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
eraserProps:
|
eraserProps:
|
||||||
@@ -459,11 +456,6 @@ export function ToolPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) {
|
if (displayMode === "interactive-crop" && hasFile && !hasProcessed && originalBlobUrl) {
|
||||||
if (cropMode === "content-aware") {
|
|
||||||
const fname = selectedFileName ?? files[0].name;
|
|
||||||
const fsize = selectedFileSize ?? files[0].size;
|
|
||||||
return <ImageViewer src={originalBlobUrl} filename={fname} fileSize={fsize} />;
|
|
||||||
}
|
|
||||||
return (
|
return (
|
||||||
<CropCanvas
|
<CropCanvas
|
||||||
imageSrc={originalBlobUrl}
|
imageSrc={originalBlobUrl}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ describe("decodeHeic", () => {
|
|||||||
expect(meta.height).toBeGreaterThan(0);
|
expect(meta.height).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("decodes sample.heif to a valid PNG buffer", async () => {
|
it("decodes sample.heif to a valid PNG buffer", { timeout: 60_000 }, async () => {
|
||||||
const heifBuf = await readFile(join(FIXTURES, "formats/sample.heif"));
|
const heifBuf = await readFile(join(FIXTURES, "formats/sample.heif"));
|
||||||
const result = await decodeHeic(heifBuf);
|
const result = await decodeHeic(heifBuf);
|
||||||
expect(isPng(result)).toBe(true);
|
expect(isPng(result)).toBe(true);
|
||||||
@@ -55,7 +55,7 @@ describe("decodeHeic", () => {
|
|||||||
await expect(decodeHeic(Buffer.alloc(0))).rejects.toThrow();
|
await expect(decodeHeic(Buffer.alloc(0))).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("cleans up temp files after success", async () => {
|
it("cleans up temp files after success", { timeout: 60_000 }, async () => {
|
||||||
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
|
const heicBuf = await readFile(join(FIXTURES, "formats/sample.heic"));
|
||||||
const { tmpdir } = await import("node:os");
|
const { tmpdir } = await import("node:os");
|
||||||
const { readdirSync } = await import("node:fs");
|
const { readdirSync } = await import("node:fs");
|
||||||
|
|||||||
Reference in New Issue
Block a user