mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: replace Python seam carving with caire Go binary
Replace the Python seam-carving library with caire (esimov/caire v1.5.0), a Go-based content-aware resize engine that is faster and supports both shrinking and enlarging via seam insertion. - Add Go builder stage in Dockerfile to compile caire from source - Rewrite seam-carving.ts to call caire via execFile (no Python sidecar) - Remove content-aware-resize from PYTHON_SIDECAR_TOOLS (60s timeout) - Add new options: blur radius, edge sensitivity, square mode, face detection - Move content-aware toggle below standard resize in UI (subtler placement) - Rename "Don't enlarge" to "Limit to original size" with hover tooltip - Add smooth progress bar for medium-duration tools - Delete seam_carve.py and remove seam-carving pip dependency - Update integration tests and visual regression screenshots
This commit is contained in:
@@ -7,10 +7,20 @@ import { z } from "zod";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { createWorkspace } from "../../lib/workspace.js";
|
||||
import { updateSingleFileProgress } from "../progress.js";
|
||||
import { registerToolProcessFn } from "../tool-factory.js";
|
||||
|
||||
/** Content-aware resize (seam carving) route. */
|
||||
const settingsSchema = z.object({
|
||||
width: z.number().positive().optional(),
|
||||
height: z.number().positive().optional(),
|
||||
protectFaces: z.boolean().default(false),
|
||||
blurRadius: z.number().min(0).max(20).default(4),
|
||||
sobelThreshold: z.number().min(1).max(20).default(2),
|
||||
square: z.boolean().default(false),
|
||||
});
|
||||
|
||||
type Settings = z.infer<typeof settingsSchema>;
|
||||
|
||||
/** Content-aware resize (seam carving via caire) route. */
|
||||
export function registerContentAwareResize(app: FastifyInstance) {
|
||||
app.post(
|
||||
"/api/v1/tools/content-aware-resize",
|
||||
@@ -18,7 +28,6 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
let fileBuffer: Buffer | null = null;
|
||||
let filename = "image";
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
@@ -32,8 +41,6 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
filename = basename(part.filename ?? "image");
|
||||
} else if (part.fieldname === "settings") {
|
||||
settingsRaw = part.value as string;
|
||||
} else if (part.fieldname === "clientJobId") {
|
||||
clientJobId = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -52,15 +59,37 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||
}
|
||||
|
||||
// Validate settings
|
||||
let settings: Settings;
|
||||
try {
|
||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
const result = settingsSchema.safeParse(parsed);
|
||||
if (!result.success) {
|
||||
return reply.status(400).send({
|
||||
error: "Invalid settings",
|
||||
details: result.error.issues.map((i) => ({
|
||||
path: i.path.join("."),
|
||||
message: i.message,
|
||||
})),
|
||||
});
|
||||
}
|
||||
settings = result.data;
|
||||
} catch {
|
||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||
}
|
||||
|
||||
if (!settings.square && !settings.width && !settings.height) {
|
||||
return reply.status(400).send({
|
||||
error: "Either width, height, or square mode must be specified",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||
request.log.info(
|
||||
{
|
||||
toolId: "content-aware-resize",
|
||||
imageSize: fileBuffer.length,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
protectFaces: settings.protectFaces,
|
||||
...settings,
|
||||
},
|
||||
"Starting content-aware resize",
|
||||
);
|
||||
@@ -75,43 +104,21 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
const inputPath = join(workspacePath, "input", filename);
|
||||
await writeFile(inputPath, fileBuffer);
|
||||
|
||||
// Process
|
||||
const jobIdForProgress = clientJobId;
|
||||
const onProgress = jobIdForProgress
|
||||
? (percent: number, stage: string) => {
|
||||
updateSingleFileProgress({
|
||||
jobId: jobIdForProgress,
|
||||
phase: "processing",
|
||||
stage,
|
||||
percent,
|
||||
});
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const result = await seamCarve(
|
||||
fileBuffer,
|
||||
join(workspacePath, "output"),
|
||||
{
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
protectFaces: settings.protectFaces ?? true,
|
||||
},
|
||||
onProgress,
|
||||
);
|
||||
// Process with caire
|
||||
const result = await seamCarve(fileBuffer, join(workspacePath, "output"), {
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
protectFaces: settings.protectFaces,
|
||||
blurRadius: settings.blurRadius,
|
||||
sobelThreshold: settings.sobelThreshold,
|
||||
square: settings.square,
|
||||
});
|
||||
|
||||
// Save output
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
|
||||
const outputPath = join(workspacePath, "output", outputFilename);
|
||||
await writeFile(outputPath, result.buffer);
|
||||
|
||||
if (clientJobId) {
|
||||
updateSingleFileProgress({
|
||||
jobId: clientJobId,
|
||||
phase: "complete",
|
||||
percent: 100,
|
||||
});
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
@@ -130,24 +137,22 @@ export function registerContentAwareResize(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// Register in the pipeline/batch registry so this tool can be used
|
||||
// as a step in automation pipelines (without progress callbacks).
|
||||
// Register in the pipeline/batch registry
|
||||
registerToolProcessFn({
|
||||
toolId: "content-aware-resize",
|
||||
settingsSchema: z.object({
|
||||
width: z.number().positive().optional(),
|
||||
height: z.number().positive().optional(),
|
||||
protectFaces: z.boolean().default(true),
|
||||
}),
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const s = settings as { width?: number; height?: number; protectFaces?: boolean };
|
||||
const s = settings as Settings;
|
||||
const orientedBuffer = await autoOrient(inputBuffer);
|
||||
const jobId = randomUUID();
|
||||
const workspacePath = await createWorkspace(jobId);
|
||||
const result = await seamCarve(orientedBuffer, join(workspacePath, "output"), {
|
||||
width: s.width,
|
||||
height: s.height,
|
||||
protectFaces: s.protectFaces ?? true,
|
||||
protectFaces: s.protectFaces,
|
||||
blurRadius: s.blurRadius,
|
||||
sobelThreshold: s.sobelThreshold,
|
||||
square: s.square,
|
||||
});
|
||||
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_seam.png`;
|
||||
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SOCIAL_MEDIA_PRESETS } from "@stirling-image/shared";
|
||||
import { Download, Link, Unlink } from "lucide-react";
|
||||
import { Download, Info, Link, Unlink } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
@@ -17,6 +17,17 @@ const FIT_LABELS: Record<FitMode, string> = {
|
||||
// Group presets by platform
|
||||
const platforms = [...new Set(SOCIAL_MEDIA_PRESETS.map((p) => p.platform))];
|
||||
|
||||
function HintIcon({ text }: { text: string }) {
|
||||
return (
|
||||
<span className="relative group">
|
||||
<Info className="h-3 w-3 text-muted-foreground" />
|
||||
<span className="pointer-events-none absolute left-1/2 -translate-x-1/2 bottom-full mb-1.5 w-48 rounded bg-foreground px-2 py-1.5 text-[11px] leading-tight text-background opacity-0 transition-opacity group-hover:opacity-100 z-10">
|
||||
{text}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface ResizeControlsProps {
|
||||
onChange?: (settings: Record<string, unknown>) => void;
|
||||
}
|
||||
@@ -31,7 +42,10 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
const [lockAspect, setLockAspect] = useState(true);
|
||||
const [withoutEnlargement, setWithoutEnlargement] = useState(false);
|
||||
const [contentAware, setContentAware] = useState(false);
|
||||
const [protectFaces, setProtectFaces] = useState(true);
|
||||
const [protectFaces, setProtectFaces] = useState(false);
|
||||
const [blurRadius, setBlurRadius] = useState(4);
|
||||
const [sobelThreshold, setSobelThreshold] = useState(2);
|
||||
const [squareMode, setSquareMode] = useState(false);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
@@ -42,9 +56,14 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
const settings: Record<string, unknown> = {};
|
||||
if (contentAware) {
|
||||
settings.contentAware = true;
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
if (!squareMode) {
|
||||
if (width) settings.width = Number(width);
|
||||
if (height) settings.height = Number(height);
|
||||
}
|
||||
settings.protectFaces = protectFaces;
|
||||
settings.blurRadius = blurRadius;
|
||||
settings.sobelThreshold = sobelThreshold;
|
||||
settings.square = squareMode;
|
||||
} else if (tab === "scale") {
|
||||
settings.percentage = Number(percentage);
|
||||
} else {
|
||||
@@ -54,7 +73,19 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
settings.withoutEnlargement = withoutEnlargement;
|
||||
}
|
||||
onChangeRef.current?.(settings);
|
||||
}, [tab, width, height, percentage, fit, withoutEnlargement, contentAware, protectFaces]);
|
||||
}, [
|
||||
tab,
|
||||
width,
|
||||
height,
|
||||
percentage,
|
||||
fit,
|
||||
withoutEnlargement,
|
||||
contentAware,
|
||||
protectFaces,
|
||||
blurRadius,
|
||||
sobelThreshold,
|
||||
squareMode,
|
||||
]);
|
||||
|
||||
const handlePreset = (preset: (typeof SOCIAL_MEDIA_PRESETS)[number]) => {
|
||||
const key = `${preset.platform}-${preset.name}`;
|
||||
@@ -84,7 +115,8 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
value={width}
|
||||
onChange={(e) => setWidth(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
disabled={squareMode && contentAware}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -105,52 +137,28 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
value={height}
|
||||
onChange={(e) => setHeight(e.target.value)}
|
||||
placeholder="Auto"
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
disabled={squareMode && contentAware}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground disabled:opacity-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const enlargementCheckbox = (
|
||||
<label className="flex items-center gap-1.5 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withoutEnlargement}
|
||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span>Limit to original size</span>
|
||||
<HintIcon text="If your image is already smaller than the target, keep it as-is instead of scaling it up" />
|
||||
</label>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Content-aware toggle */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-foreground">Content-aware</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={contentAware}
|
||||
onClick={() => setContentAware(!contentAware)}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
contentAware ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3.5 w-3.5 rounded-full bg-white shadow-sm transition-transform ${
|
||||
contentAware ? "translate-x-4" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content-aware inputs */}
|
||||
{contentAware && (
|
||||
<div className="space-y-3">
|
||||
{dimensionInputs}
|
||||
|
||||
{/* Protect faces */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={protectFaces}
|
||||
onChange={(e) => setProtectFaces(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Protect faces
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Standard resize tabs */}
|
||||
{!contentAware && (
|
||||
<>
|
||||
@@ -196,7 +204,7 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
>
|
||||
<span>{preset.name}</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{preset.width} × {preset.height}
|
||||
{preset.width} x {preset.height}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
@@ -205,16 +213,7 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Don't enlarge */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withoutEnlargement}
|
||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Don't enlarge
|
||||
</label>
|
||||
{enlargementCheckbox}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -240,16 +239,7 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Don't enlarge */}
|
||||
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={withoutEnlargement}
|
||||
onChange={(e) => setWithoutEnlargement(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Don't enlarge
|
||||
</label>
|
||||
{enlargementCheckbox}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -289,6 +279,98 @@ export function ResizeControls({ onChange }: ResizeControlsProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Content-aware section - positioned below standard resize */}
|
||||
<div className="border-t border-border pt-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs font-medium text-muted-foreground">Content-aware</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={contentAware}
|
||||
onClick={() => setContentAware(!contentAware)}
|
||||
className={`relative inline-flex h-4 w-7 items-center rounded-full transition-colors ${
|
||||
contentAware ? "bg-primary" : "bg-muted"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-3 w-3 rounded-full bg-white shadow-sm transition-transform ${
|
||||
contentAware ? "translate-x-3.5" : "translate-x-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content-aware options (expanded when toggled) */}
|
||||
{contentAware && (
|
||||
<div className="mt-3 space-y-3">
|
||||
{/* Dimensions */}
|
||||
{dimensionInputs}
|
||||
|
||||
{/* Square mode */}
|
||||
<label className="flex items-center gap-2 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={squareMode}
|
||||
onChange={(e) => setSquareMode(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Resize to square
|
||||
</label>
|
||||
|
||||
{/* Face protection */}
|
||||
<label className="flex items-center gap-2 text-xs text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={protectFaces}
|
||||
onChange={(e) => setProtectFaces(e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
Protect faces
|
||||
</label>
|
||||
|
||||
{/* Blur radius */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="blur-radius" className="text-xs text-muted-foreground">
|
||||
Smoothing
|
||||
</label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{blurRadius}</span>
|
||||
</div>
|
||||
<input
|
||||
id="blur-radius"
|
||||
type="range"
|
||||
min={0}
|
||||
max={20}
|
||||
value={blurRadius}
|
||||
onChange={(e) => setBlurRadius(Number(e.target.value))}
|
||||
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sobel threshold */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="sobel-threshold" className="text-xs text-muted-foreground">
|
||||
Edge sensitivity
|
||||
</label>
|
||||
<span className="text-xs tabular-nums text-muted-foreground">{sobelThreshold}</span>
|
||||
</div>
|
||||
<input
|
||||
id="sobel-threshold"
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
value={sobelThreshold}
|
||||
onChange={(e) => setSobelThreshold(Number(e.target.value))}
|
||||
className="w-full mt-1 h-1.5 rounded-full appearance-none bg-muted accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -323,7 +405,7 @@ export function ResizeSettings() {
|
||||
hasFile &&
|
||||
!processing &&
|
||||
(isContentAware
|
||||
? Boolean(settings.width) || Boolean(settings.height)
|
||||
? Boolean(settings.width) || Boolean(settings.height) || Boolean(settings.square)
|
||||
: tab === "scale"
|
||||
? Number(settings.percentage) > 0
|
||||
: Boolean(settings.width) || Boolean(settings.height));
|
||||
|
||||
@@ -29,6 +29,10 @@ const IDLE_PROGRESS: ToolProgress = {
|
||||
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
|
||||
const AI_PYTHON_TOOLS = new Set<string>(PYTHON_SIDECAR_TOOLS);
|
||||
|
||||
// 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.
|
||||
const MEDIUM_TOOLS = new Set(["content-aware-resize"]);
|
||||
|
||||
export function useToolProcessor(toolId: string) {
|
||||
const {
|
||||
processing,
|
||||
@@ -49,11 +53,14 @@ export function useToolProcessor(toolId: string) {
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
||||
const isMediumTool = MEDIUM_TOOLS.has(toolId);
|
||||
const processingTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
// Clean up on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
if (eventSourceRef.current) eventSourceRef.current.close();
|
||||
if (xhrRef.current) xhrRef.current.abort();
|
||||
};
|
||||
@@ -134,12 +141,13 @@ export function useToolProcessor(toolId: string) {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhrRef.current = xhr;
|
||||
|
||||
// Timeout: 60s for fast tools, 5 min for AI tools
|
||||
// Timeout: 60s for fast/medium tools, 5 min for AI tools
|
||||
xhr.timeout = isAiTool ? 300_000 : 60_000;
|
||||
|
||||
// For AI tools: upload = 0-15%, processing = 15-100% (continuous, no reset)
|
||||
// For AI tools: upload = 0-15%, processing = 15-100% (SSE-driven)
|
||||
// For medium tools: upload = 0-40%, processing = 40-95% (gradual fill)
|
||||
// For fast tools: upload = 0-100%, processing = brief 100% hold
|
||||
const UPLOAD_WEIGHT = isAiTool ? 15 : 100;
|
||||
const UPLOAD_WEIGHT = isAiTool ? 15 : isMediumTool ? 40 : 100;
|
||||
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
@@ -158,10 +166,25 @@ export function useToolProcessor(toolId: string) {
|
||||
percent: UPLOAD_WEIGHT,
|
||||
stage: isAiTool ? "Starting..." : "Processing...",
|
||||
}));
|
||||
|
||||
// Medium tools: gradually fill from upload weight to 95% over ~15s
|
||||
if (isMediumTool) {
|
||||
const start = UPLOAD_WEIGHT;
|
||||
const target = 95;
|
||||
const step = (target - start) / 30; // 30 ticks over ~15s
|
||||
processingTimerRef.current = setInterval(() => {
|
||||
setProgress((prev) => {
|
||||
if (prev.phase !== "processing") return prev;
|
||||
const next = Math.min(target, prev.percent + step);
|
||||
return { ...prev, percent: next };
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.onload = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
@@ -201,6 +224,7 @@ export function useToolProcessor(toolId: string) {
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
@@ -212,6 +236,7 @@ export function useToolProcessor(toolId: string) {
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
@@ -227,7 +252,7 @@ export function useToolProcessor(toolId: string) {
|
||||
});
|
||||
xhr.send(formData);
|
||||
},
|
||||
[toolId, isAiTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
||||
[toolId, isAiTool, isMediumTool, setProcessing, setError, setProcessedUrl, setSizes, setJobId],
|
||||
);
|
||||
|
||||
const processAllFiles = useCallback(
|
||||
|
||||
Reference in New Issue
Block a user