mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat: SOTA overhaul of automate pipeline page (#53)
* feat(find-duplicates): upgrade to 128-bit dHash with metadata and thumbnails * feat(find-duplicates): add custom-results display mode and duplicate store * feat(find-duplicates): add results overview grid and detail comparison view * feat(find-duplicates): overhaul settings with sensitivity presets and download actions * feat(find-duplicates): update i18n description * chore: replace jsqr with zxing-wasm for barcode reading * feat(barcode-read): rewrite backend with zxing-wasm for all barcode types * feat(barcode-read): rewrite frontend with multi-file, results table, progress, export - Multi-file sequential processing with per-file progress - Structured results table with type badges and copy per-result - Copy All and Export CSV functionality - Thorough scan toggle (maps to tryHarder in zxing-wasm) - Before/after view shows annotated image with bounding boxes - Updated tool description in constants and i18n * feat(stitch): update tool name and description for redesign * feat(stitch): add grid layout, alignment, border, radius, quality, and new resize modes * feat(stitch): redesign settings UI with grid, alignment, border, radius, quality * test(stitch): add stitch to e2e tool navigation suite * feat(vectorize): redesign with dual-engine backend and preset-driven UI - Backend: potrace for B&W, VTracer (@neplex/vectorizer) for full-color vectorization - Frontend: 5 presets (logo, illustration, photo, sketch, custom) - Settings: color precision, gradient step, detail, smoothing, corner threshold, invert - Updated OpenAPI spec and i18n description * feat(border): redesign with presets, shadow, padding color, swatches - Add 8 one-click presets (Clean White, Gallery Black, Shadow, Rounded, Polaroid, Vintage, Minimal, Cinematic) - Implement proper shadow rendering with blur, offset X/Y, color, opacity - Add padding color control (was hardcoded white) - Add color swatches for quick color selection - Wrap in form for Enter key submission - Add smart validation (requires at least one effect active) - Align frontend/backend slider ranges - Organize UI with sections and collapsible shadow toggle * feat(split): overhaul image splitting with live grid overlay and tile preview - Add interactive-split display mode with SplitCanvas component - Live SVG grid overlay on uploaded image showing split boundaries - Two split modes: Grid (NxM) and Tile Size (px dimensions) - 9 grid presets (2x1, 1x2, 2x2, 3x1, 1x3, 3x3, 2x3, 3x2, 4x4) - Output format selection (original/PNG/JPG/WebP) with quality slider - Post-split tile preview thumbnails with individual download - Download All as ZIP button - HEIC/HEIF preview with loading spinner - Backend: tile-size mode, output format conversion, quality control - Zustand store for split state management * feat(split): rewrite backend and frontend settings Backend: tile-size mode, output format conversion, quality control. Frontend: split modes, presets, format selector, tile preview grid. * feat(border): add live CSS preview and remove before/after slider - Add imageWrapperStyle prop to ImageViewer for live border preview - Add onImageStyle callback through tool-page to settings components - Change border displayMode to no-comparison (no slider) - BorderControls sends live CSS styles (border, padding, radius, shadow) - Preview updates instantly as user adjusts sliders or clicks presets * fix: repair i18n file corrupted by formatter during merge conflict resolution * feat(border): enable live CSS preview in right pane as settings change * fix(border): keep CSS preview visible after processing for WYSIWYG consistency * chore: add @dnd-kit/core and @dnd-kit/sortable for pipeline drag-and-drop * feat(pipeline): add Zustand store for pipeline step management * feat(automate): add pipeline step settings summary utility with tests * feat(automate): add POST /api/v1/pipeline/batch for multi-file pipeline execution * feat(automate): add usePipelineProcessor hook for single and batch pipeline execution * fix(automate): pass settings prop to all pipeline step controls for state restoration * feat(automate): rewrite pipeline builder with dnd-kit drag-and-drop and compact step cards * feat(automate): rewrite page with two-panel layout, image preview, and batch support * test(automate): update e2e tests for new two-panel pipeline layout --------- Co-authored-by: Siddharth Kumar Sah <siddharth123sk@gmail.com>
This commit is contained in:
co-authored by
Siddharth Kumar Sah
parent
a1e11dff74
commit
fb33a46a64
@@ -0,0 +1,338 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
import { generateId } from "@/lib/utils";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
import type { PipelineStep } from "@/stores/pipeline-store";
|
||||
|
||||
interface ProcessResult {
|
||||
jobId: string;
|
||||
downloadUrl: string;
|
||||
previewUrl?: string;
|
||||
originalSize: number;
|
||||
processedSize: number;
|
||||
savedFileId?: string;
|
||||
}
|
||||
|
||||
export interface PipelineProgress {
|
||||
phase: "idle" | "uploading" | "processing" | "complete";
|
||||
percent: number;
|
||||
stage?: string;
|
||||
elapsed: number;
|
||||
}
|
||||
|
||||
const IDLE_PROGRESS: PipelineProgress = {
|
||||
phase: "idle",
|
||||
percent: 0,
|
||||
elapsed: 0,
|
||||
};
|
||||
|
||||
export function usePipelineProcessor() {
|
||||
const { processing, error, processedUrl, originalSize, processedSize, setProcessing, setError } =
|
||||
useFileStore();
|
||||
|
||||
const [progress, setProgress] = useState<PipelineProgress>(IDLE_PROGRESS);
|
||||
const elapsedRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const xhrRef = useRef<XMLHttpRequest | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | 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();
|
||||
};
|
||||
}, []);
|
||||
|
||||
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,
|
||||
processedFilename: null,
|
||||
status: "processing",
|
||||
error: null,
|
||||
});
|
||||
setProcessing(true);
|
||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||
|
||||
// Start elapsed timer
|
||||
const startTime = Date.now();
|
||||
elapsedRef.current = setInterval(() => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
elapsed: Math.floor((Date.now() - startTime) / 1000),
|
||||
}));
|
||||
}, 1000);
|
||||
|
||||
// Build pipeline payload
|
||||
const pipeline = {
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
formData.append("pipeline", JSON.stringify(pipeline));
|
||||
|
||||
// Use XHR for upload progress tracking
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhrRef.current = xhr;
|
||||
|
||||
// Pipeline runs multiple steps sequentially, allow up to 3 minutes
|
||||
xhr.timeout = 180_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;
|
||||
setProgress((prev) => {
|
||||
if (prev.phase !== "uploading") return prev;
|
||||
return { ...prev, percent: uploadPercent };
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
xhr.upload.onload = () => {
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "processing",
|
||||
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 (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const result: ProcessResult = JSON.parse(xhr.responseText);
|
||||
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 } : {}),
|
||||
});
|
||||
} catch {
|
||||
setError("Invalid response from server");
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
const msg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Processing failed: ${xhr.status}`;
|
||||
setError(msg);
|
||||
} catch {
|
||||
setError(`Processing failed: ${xhr.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
xhr.onerror = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
setError("Network error - check your connection");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
xhr.ontimeout = () => {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (processingTimerRef.current) clearInterval(processingTimerRef.current);
|
||||
setError("Request timed out - the server may be overloaded. Try again.");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
};
|
||||
|
||||
xhr.open("POST", "/api/v1/pipeline/execute");
|
||||
formatHeaders().forEach((value, key) => {
|
||||
xhr.setRequestHeader(key, value);
|
||||
});
|
||||
xhr.send(formData);
|
||||
},
|
||||
[setProcessing, setError],
|
||||
);
|
||||
|
||||
const processAll = useCallback(
|
||||
async (files: File[], steps: PipelineStep[]) => {
|
||||
if (files.length === 0) {
|
||||
setError("No files selected");
|
||||
return;
|
||||
}
|
||||
if (files.length === 1) {
|
||||
processSingle(files[0], steps);
|
||||
return;
|
||||
}
|
||||
|
||||
const { updateEntry, setBatchZip } = useFileStore.getState();
|
||||
|
||||
setError(null);
|
||||
setProcessing(true);
|
||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||
|
||||
const startTime = Date.now();
|
||||
elapsedRef.current = setInterval(() => {
|
||||
setProgress((prev) => ({ ...prev, elapsed: Math.floor((Date.now() - startTime) / 1000) }));
|
||||
}, 1000);
|
||||
|
||||
const clientJobId = generateId();
|
||||
|
||||
// Open SSE before upload for real-time progress
|
||||
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 === "batch") {
|
||||
const pct =
|
||||
data.totalFiles > 0 ? 15 + (data.completedFiles / data.totalFiles) * 85 : 15;
|
||||
setProgress((prev) => ({
|
||||
...prev,
|
||||
phase: "processing",
|
||||
percent: pct,
|
||||
stage: data.currentFile
|
||||
? `Processing ${data.currentFile} (${data.completedFiles}/${data.totalFiles})`
|
||||
: `Processing ${data.completedFiles}/${data.totalFiles}`,
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
/* ignore malformed SSE */
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
};
|
||||
} catch {
|
||||
/* SSE failed, proceed without */
|
||||
}
|
||||
|
||||
const pipeline = {
|
||||
steps: steps.map((s) => ({ toolId: s.toolId, settings: s.settings })),
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
for (const file of files) formData.append("file", file);
|
||||
formData.append("pipeline", JSON.stringify(pipeline));
|
||||
formData.append("clientJobId", clientJobId);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/v1/pipeline/batch", {
|
||||
method: "POST",
|
||||
headers: formatHeaders(),
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
let errorMsg: string;
|
||||
try {
|
||||
const body = JSON.parse(text);
|
||||
errorMsg = body.details
|
||||
? `${body.error}: ${body.details}`
|
||||
: body.error || `Batch processing failed: ${response.status}`;
|
||||
} catch {
|
||||
errorMsg = `Batch processing failed: ${response.status}`;
|
||||
}
|
||||
setError(errorMsg);
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
return;
|
||||
}
|
||||
|
||||
const zipBlob = await response.blob();
|
||||
setBatchZip(zipBlob, "batch-pipeline.zip");
|
||||
|
||||
// Extract files from ZIP using fflate
|
||||
const { unzipSync } = await import("fflate");
|
||||
const zipBuffer = new Uint8Array((await zipBlob.arrayBuffer()) as ArrayBuffer);
|
||||
const extracted = unzipSync(zipBuffer);
|
||||
|
||||
const entries = useFileStore.getState().entries;
|
||||
let fileResults: Record<string, string> = {};
|
||||
try {
|
||||
fileResults = JSON.parse(response.headers.get("X-File-Results") ?? "{}");
|
||||
} catch {
|
||||
// Malformed header - fall back to empty mapping, all entries marked failed
|
||||
}
|
||||
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const processedName = fileResults[String(i)];
|
||||
if (processedName && extracted[processedName]) {
|
||||
const blob = new Blob([extracted[processedName] as BlobPart]);
|
||||
updateEntry(i, {
|
||||
processedUrl: URL.createObjectURL(blob),
|
||||
processedFilename: processedName,
|
||||
processedSize: blob.size,
|
||||
status: "completed",
|
||||
error: null,
|
||||
});
|
||||
} else {
|
||||
updateEntry(i, { status: "failed", error: "File not found in batch results" });
|
||||
}
|
||||
}
|
||||
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
} catch (err) {
|
||||
if (elapsedRef.current) clearInterval(elapsedRef.current);
|
||||
if (eventSourceRef.current) {
|
||||
eventSourceRef.current.close();
|
||||
eventSourceRef.current = null;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "Batch processing failed");
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
}
|
||||
},
|
||||
[processSingle, setProcessing, setError],
|
||||
);
|
||||
|
||||
return {
|
||||
processSingle,
|
||||
processAll,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl: processedUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user