refactor: improve tool processing, dropzone, seam carving, and format encoding

- Refactor use-tool-processor and use-pipeline-processor hooks
- Enhance dropzone component with improved UX
- Improve seam carving with better error handling and tests
- Add JXL format encoding support to format-encoders
- Update tool routes for consistent format handling
- Add dropzone unit tests
This commit is contained in:
SnapOtter
2026-05-11 21:57:40 +08:00
parent 656a8d3d44
commit b06906025c
25 changed files with 1368 additions and 436 deletions
+49 -27
View File
@@ -26,6 +26,8 @@ const IDLE_PROGRESS: PipelineProgress = {
elapsed: 0,
};
const UPLOAD_WEIGHT = 15;
export function usePipelineProcessor() {
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
useFileStore();
@@ -35,13 +37,11 @@ export function usePipelineProcessor() {
const xhrRef = useRef<XMLHttpRequest | null>(null);
const eventSourceRef = useRef<EventSource | null>(null);
const abortRef = useRef<AbortController | null>(null);
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();
if (abortRef.current) abortRef.current.abort();
@@ -50,12 +50,9 @@ export function usePipelineProcessor() {
const processSingle = useCallback(
(file: File, steps: PipelineStep[]) => {
// Capture the file index at request time so results are written
// to the correct entry even if the user navigates away.
const capturedIndex = useFileStore.getState().selectedIndex;
setError(null);
// Mark the target entry as processing and clear any old result
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: null,
processedPreviewUrl: null,
@@ -66,7 +63,6 @@ export function usePipelineProcessor() {
setProcessing(true);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
// Start elapsed timer
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({
@@ -75,7 +71,40 @@ export function usePipelineProcessor() {
}));
}, 1000);
// Build pipeline payload
const clientJobId = generateId();
// Open SSE for real-time progress from the server
try {
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
if (typeof data.percent === "number") {
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
setProgress((prev) => ({
...prev,
phase: "processing",
percent: Math.max(prev.percent, scaled),
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
es.close();
eventSourceRef.current = null;
};
} catch {
// EventSource creation failed -- proceed without SSE
}
const pipeline = {
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
};
@@ -83,17 +112,13 @@ export function usePipelineProcessor() {
const formData = new FormData();
formData.append("file", file);
formData.append("pipeline", JSON.stringify(pipeline));
formData.append("clientJobId", clientJobId);
// Use XHR for upload progress tracking
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Pipeline runs multiple steps sequentially, allow up to 10 minutes
xhr.timeout = 600_000;
// Pipeline is always "medium" speed: upload = 0-40%, processing = 40-95%
const UPLOAD_WEIGHT = 40;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const uploadPercent = (event.loaded / event.total) * UPLOAD_WEIGHT;
@@ -111,23 +136,14 @@ export function usePipelineProcessor() {
percent: UPLOAD_WEIGHT,
stage: "Processing...",
}));
// Gradually fill from upload weight to 95% over ~45s
const start = UPLOAD_WEIGHT;
const target = 95;
const step = (target - start) / 90; // 90 ticks over ~45s
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;
}
if (xhr.status >= 200 && xhr.status < 300) {
try {
@@ -166,7 +182,10 @@ export function usePipelineProcessor() {
xhr.onerror = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Network error - check your connection");
setProcessing(false);
setProgress(IDLE_PROGRESS);
@@ -174,7 +193,10 @@ export function usePipelineProcessor() {
xhr.ontimeout = () => {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
}
setError("Request timed out - the server may be overloaded. Try again.");
setProcessing(false);
setProgress(IDLE_PROGRESS);
+66 -121
View File
@@ -27,13 +27,10 @@ const IDLE_PROGRESS: ToolProgress = {
elapsed: 0,
};
// AI tools that go through Python/bridge.ts and can emit SSE progress.
// smart-crop is category "ai" but uses Sharp (no Python), so it's excluded.
// AI tools return 202 and deliver results via SSE (not XHR response).
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", "convert"]);
const UPLOAD_WEIGHT = 15;
export function useToolProcessor(toolId: string) {
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
@@ -47,15 +44,12 @@ export function useToolProcessor(toolId: string) {
const abortRef = useRef<AbortController | null>(null);
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
const isMediumTool = MEDIUM_TOOLS.has(toolId);
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? 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();
if (abortRef.current) abortRef.current.abort();
@@ -69,13 +63,10 @@ export function useToolProcessor(toolId: string) {
return;
}
// Capture the file index at request time so results are written
// to the correct entry even if the user navigates away.
const capturedIndex = useFileStore.getState().selectedIndex;
setError(null);
setWarning(null);
// Mark the target entry as processing and clear any old result
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: null,
processedPreviewUrl: null,
@@ -86,7 +77,6 @@ export function useToolProcessor(toolId: string) {
setProcessing(true);
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
// Start elapsed timer
const startTime = Date.now();
elapsedRef.current = setInterval(() => {
setProgress((prev) => ({
@@ -95,80 +85,76 @@ export function useToolProcessor(toolId: string) {
}));
}, 1000);
// Generate client job ID for SSE correlation
const clientJobId = generateId();
let asyncMode = false;
// For AI tools, open SSE before uploading
if (isAiTool) {
try {
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
eventSourceRef.current = es;
// Open SSE for real-time progress from the server (all tools)
try {
const es = new EventSource(`/api/v1/jobs/${clientJobId}/progress`);
eventSourceRef.current = es;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
es.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type !== "single") return;
if (data.phase === "complete" && data.result) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
processedFilename: null,
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (data.phase === "failed" && asyncMode) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
es.close();
eventSourceRef.current = null;
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (typeof data.percent === "number") {
const scaled = 15 + (data.percent / 100) * 85;
setProgress((prev) => ({
...prev,
phase: "processing",
percent: Math.max(prev.percent, scaled),
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
if (!asyncMode) {
// AI tools deliver results via SSE (they return 202 from the XHR)
if (data.phase === "complete" && data.result) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
const result = data.result as ProcessResult;
setWarning(result.warning ?? null);
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: result.downloadUrl,
processedPreviewUrl: result.previewUrl ?? null,
processedFilename: null,
status: "completed",
originalSize: result.originalSize,
processedSize: result.processedSize,
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
});
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
};
} catch {
// EventSource creation failed -- proceed without SSE
}
if (data.phase === "failed" && asyncMode) {
if (elapsedRef.current) clearInterval(elapsedRef.current);
es.close();
eventSourceRef.current = null;
setError(data.error || "Processing failed");
setProcessing(false);
setProgress(IDLE_PROGRESS);
return;
}
if (typeof data.percent === "number") {
const scaled = UPLOAD_WEIGHT + (data.percent / 100) * (100 - UPLOAD_WEIGHT);
setProgress((prev) => ({
...prev,
phase: "processing",
percent: Math.max(prev.percent, scaled),
stage: data.stage,
}));
}
} catch {
// Ignore malformed SSE
}
};
es.onerror = () => {
if (!asyncMode) {
es.close();
eventSourceRef.current = null;
}
};
} catch {
// EventSource creation failed -- proceed without SSE
}
// Build form data - extract any File objects from settings before JSON serialization
// Build form data
const cleanSettings = { ...settings };
const bgImageFile = cleanSettings._bgImageFile as File | undefined;
delete cleanSettings._bgImageFile;
@@ -179,27 +165,17 @@ export function useToolProcessor(toolId: string) {
if (bgImageFile) {
formData.append("backgroundImage", bgImageFile);
}
if (isAiTool) {
formData.append("clientJobId", clientJobId);
}
formData.append("clientJobId", clientJobId);
// If this file came from the Files page, include its ID for version tracking
const capturedEntry = useFileStore.getState().entries[capturedIndex];
if (capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
}
// Use XHR for upload progress tracking
const xhr = new XMLHttpRequest();
xhrRef.current = xhr;
// Timeout: 2 min for fast tools, 5 min for medium (seam carving), 10 min for AI
xhr.timeout = isAiTool ? 600_000 : isMediumTool ? 300_000 : 120_000;
// 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 : isMediumTool ? 40 : 100;
xhr.timeout = isAiTool ? 600_000 : 120_000;
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
@@ -216,36 +192,8 @@ export function useToolProcessor(toolId: string) {
...prev,
phase: "processing",
percent: UPLOAD_WEIGHT,
stage: isAiTool ? "Starting..." : "Processing...",
stage: "Processing...",
}));
// Medium tools: gradually fill from upload weight to 95% over ~45s
if (isMediumTool) {
const start = UPLOAD_WEIGHT;
const target = 95;
const step = (target - start) / 90; // 90 ticks over ~45s
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
const next = Math.min(target, prev.percent + step);
return { ...prev, percent: next };
});
}, 500);
}
// AI tools: asymptotic fill during long processing gaps.
// Slowly creeps toward 88% so the bar never stalls visually.
// Real SSE events always win via Math.max in the handler.
if (isAiTool) {
processingTimerRef.current = setInterval(() => {
setProgress((prev) => {
if (prev.phase !== "processing") return prev;
const remaining = 88 - prev.percent;
if (remaining <= 0.5) return prev;
return { ...prev, percent: prev.percent + remaining * 0.015 };
});
}, 1000);
}
};
xhr.onload = () => {
@@ -255,7 +203,6 @@ export function useToolProcessor(toolId: string) {
}
if (elapsedRef.current) clearInterval(elapsedRef.current);
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
@@ -299,7 +246,6 @@ 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;
@@ -311,7 +257,6 @@ 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;
@@ -327,7 +272,7 @@ export function useToolProcessor(toolId: string) {
});
xhr.send(formData);
},
[toolId, isAiTool, isMediumTool, setProcessing, setError, toolName],
[toolId, isAiTool, setProcessing, setError, toolName],
);
const processAllFiles = useCallback(