mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(library): wire save-mode into the five custom-client tool submitters (#577)
Closes #565. Wires the fileId/saveMode pair into the ocr, erase-object, remove-background, background-replace, and blur-background submitters so the library save-mode selector works for them; remove-background's two-phase effects route now auto-saves the final composite instead of the transparent intermediate.
This commit is contained in:
@@ -271,6 +271,11 @@ export function EraseObjectSettings({
|
||||
if (files.length === 0 || !eraserRef.current) return;
|
||||
|
||||
const capturedIndex = useFileStore.getState().selectedIndex;
|
||||
// Library file this single-file run derives from (#565). Batch runs
|
||||
// (handleProcessAll) never auto-save, matching the standard processor.
|
||||
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
||||
const saveMode = useFileStore.getState().librarySaveMode;
|
||||
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||
|
||||
const maskBlob = await eraserRef.current.exportMask();
|
||||
if (!maskBlob) return;
|
||||
@@ -295,6 +300,9 @@ export function EraseObjectSettings({
|
||||
const clientJobId = generateId();
|
||||
|
||||
const applyResult = (r: Record<string, unknown>) => {
|
||||
if (r.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(r.savedFileId as string);
|
||||
}
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: r.downloadUrl as string,
|
||||
processedPreviewUrl: (r.previewUrl as string) ?? null,
|
||||
@@ -302,6 +310,9 @@ export function EraseObjectSettings({
|
||||
status: "completed",
|
||||
originalSize: r.originalSize as number,
|
||||
processedSize: r.processedSize as number,
|
||||
...(r.savedFileId && saveMode === "overwrite"
|
||||
? { serverFileId: r.savedFileId as string }
|
||||
: {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -346,6 +357,10 @@ export function EraseObjectSettings({
|
||||
formData.append("format", outputFormat);
|
||||
formData.append("quality", String(quality));
|
||||
formData.append("qualityMode", qualityMode);
|
||||
if (capturedEntry?.serverFileId) {
|
||||
formData.append("fileId", capturedEntry.serverFileId);
|
||||
formData.append("saveMode", saveMode);
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 600_000;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
|
||||
import type { LibrarySaveMode } from "@snapotter/shared";
|
||||
import { Check, CheckCircle2, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { formatHeaders } from "@/lib/api";
|
||||
@@ -50,7 +52,10 @@ export function ocrOneFile(
|
||||
networkError?: string;
|
||||
processingFailed?: string;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
// When the file came from the library, forward the save choice so the
|
||||
// extracted-text artifact auto-saves (#565). Only sent for single-file runs.
|
||||
library?: { fileId: string; saveMode: LibrarySaveMode },
|
||||
): Promise<{ text: string; savedFileId?: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const clientJobId = generateId();
|
||||
let settled = false;
|
||||
@@ -65,11 +70,11 @@ export function ocrOneFile(
|
||||
es = null;
|
||||
};
|
||||
|
||||
const resolveOnce = (text: string) => {
|
||||
const resolveOnce = (result: { text: string; savedFileId?: string }) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
resolve(text);
|
||||
resolve(result);
|
||||
};
|
||||
|
||||
const rejectOnce = (error: Error) => {
|
||||
@@ -107,7 +112,11 @@ export function ocrOneFile(
|
||||
if (data.type !== "single") return;
|
||||
armStallTimer();
|
||||
if (data.phase === "complete" && data.result) {
|
||||
resolveOnce(typeof data.result.text === "string" ? data.result.text : "");
|
||||
resolveOnce({
|
||||
text: typeof data.result.text === "string" ? data.result.text : "",
|
||||
savedFileId:
|
||||
typeof data.result.savedFileId === "string" ? data.result.savedFileId : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (data.phase === "failed") {
|
||||
@@ -127,6 +136,10 @@ export function ocrOneFile(
|
||||
formData.append("file", file);
|
||||
formData.append("settings", JSON.stringify(settings));
|
||||
formData.append("clientJobId", clientJobId);
|
||||
if (library) {
|
||||
formData.append("fileId", library.fileId);
|
||||
formData.append("saveMode", library.saveMode);
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.timeout = 600_000;
|
||||
@@ -144,7 +157,10 @@ export function ocrOneFile(
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText);
|
||||
resolveOnce(typeof body.text === "string" ? body.text : "");
|
||||
resolveOnce({
|
||||
text: typeof body.text === "string" ? body.text : "",
|
||||
savedFileId: typeof body.savedFileId === "string" ? body.savedFileId : undefined,
|
||||
});
|
||||
} catch {
|
||||
rejectOnce(new Error(messages.processingFailed ?? "Invalid response"));
|
||||
}
|
||||
@@ -179,6 +195,10 @@ export function OcrSettings() {
|
||||
const [langOpen, setLangOpen] = useState(false);
|
||||
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
// Library file id the extracted text was auto-saved as (single-file runs from
|
||||
// the library only). OCR renders its own text result, not the shared
|
||||
// ReviewPanel, so it surfaces the "saved to Files" confirmation inline (#565).
|
||||
const [savedLibraryFileId, setSavedLibraryFileId] = useState<string | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
@@ -205,6 +225,7 @@ export function OcrSettings() {
|
||||
|
||||
setError(null);
|
||||
setText(null);
|
||||
setSavedLibraryFileId(null);
|
||||
setProcessing(true);
|
||||
setProgressPhase("uploading");
|
||||
setProgressPercent(0);
|
||||
@@ -228,8 +249,16 @@ export function OcrSettings() {
|
||||
const fileBase = (i / total) * 100;
|
||||
const fileShare = 100 / total;
|
||||
|
||||
// Only a single-file run auto-saves to the library; a multi-file batch
|
||||
// never sends a fileId (matching the standard batch processor).
|
||||
const serverFileId =
|
||||
total === 1 ? useFileStore.getState().entries[i]?.serverFileId : undefined;
|
||||
const library = serverFileId
|
||||
? { fileId: serverFileId, saveMode: useFileStore.getState().librarySaveMode }
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const text = await ocrOneFile(
|
||||
const { text, savedFileId } = await ocrOneFile(
|
||||
file,
|
||||
settings,
|
||||
{
|
||||
@@ -249,7 +278,13 @@ export function OcrSettings() {
|
||||
networkError: t.errors.networkError,
|
||||
processingFailed: t.errors.processingFailed,
|
||||
},
|
||||
library,
|
||||
);
|
||||
// Only single-file runs send a fileId, so savedFileId is single-file only.
|
||||
if (savedFileId) {
|
||||
setSavedLibraryFileId(savedFileId);
|
||||
useFileStore.getState().setLastSavedLibraryFileId(savedFileId);
|
||||
}
|
||||
results.push(total > 1 ? `--- ${file.name} ---\n${text || "(no text detected)"}` : text);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
@@ -382,6 +417,15 @@ export function OcrSettings() {
|
||||
{/* Result */}
|
||||
{text !== null && (
|
||||
<div className="space-y-2">
|
||||
{savedLibraryFileId && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-success-ink">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t.toolPage.savedToFiles}
|
||||
<Link to="/files" className="underline underline-offset-2 hover:text-foreground">
|
||||
{t.toolPage.viewInFiles}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-muted-foreground">
|
||||
{t.toolSettings.ocr.extractedText}
|
||||
|
||||
@@ -651,7 +651,7 @@ interface RemoveBgSettingsProps {
|
||||
|
||||
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
const { t } = useTranslation();
|
||||
const { files } = useFileStore();
|
||||
const { files, currentEntry } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
@@ -722,6 +722,22 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
const hasFile = files.length > 0;
|
||||
const bgRemoved = bgJobId !== null && !processing;
|
||||
|
||||
// Whether the user has configured any compositing effect.
|
||||
const hasEffectsToApply =
|
||||
settings.blurEnabled ||
|
||||
settings.shadowEnabled ||
|
||||
((settings.backgroundType as string) || "transparent") !== "transparent";
|
||||
|
||||
// The Phase 2 effects request is the single owner of the library save (#565):
|
||||
// it produces the final artifact the user downloads, whether that is the
|
||||
// composite (effects on) or the transparent result (effects off). Route the
|
||||
// download through it whenever effects apply, or whenever the file came from
|
||||
// the library and so needs saving. Phase 1 never saves an intermediate. This
|
||||
// keeps the skip and save decisions reading the same live state at one point,
|
||||
// so toggling effects after Phase 1 can neither drop nor double the save.
|
||||
const fromLibrary = Boolean(currentEntry?.serverFileId);
|
||||
const needsEffectsRequest = Boolean(hasEffectsToApply) || fromLibrary;
|
||||
|
||||
// Build CSS preview state from current settings and send to tool-page
|
||||
useEffect(() => {
|
||||
if (!bgRemoved || !onBgPreview) return;
|
||||
@@ -817,7 +833,9 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
// Use processFiles for the progress/SSE flow - it handles everything
|
||||
// But we need the extended response. Override via a fetch after processFiles completes.
|
||||
// Actually, let's use processFiles and then fetch the job info.
|
||||
processFiles(files, { model: settings.model });
|
||||
// Phase 1 is always an intermediate here: the Phase 2 effects request owns
|
||||
// the library save (#565), so never auto-save the transparent result.
|
||||
processFiles(files, { model: settings.model }, { skipLibrarySave: true });
|
||||
};
|
||||
|
||||
// After processFiles completes, extract jobId from downloadUrl
|
||||
@@ -842,7 +860,14 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
const handleDownloadWithEffects = async () => {
|
||||
if (!bgJobId || !bgFilename) return;
|
||||
|
||||
// Library file this run derives from (when opened from the file library):
|
||||
// the effects request is the FINAL step, so it carries the save choice.
|
||||
const { entries, selectedIndex, librarySaveMode } = useFileStore.getState();
|
||||
const capturedEntry = entries[selectedIndex];
|
||||
|
||||
setApplyingEffects(true);
|
||||
setEffectsError(null);
|
||||
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
const effectSettings: Record<string, unknown> = {
|
||||
@@ -866,6 +891,11 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
formData.append("backgroundImage", bgImageFile);
|
||||
}
|
||||
|
||||
if (capturedEntry?.serverFileId) {
|
||||
formData.append("fileId", capturedEntry.serverFileId);
|
||||
formData.append("saveMode", librarySaveMode);
|
||||
}
|
||||
|
||||
const headers = formatHeaders();
|
||||
const response = await fetch("/api/v1/tools/image/remove-background/effects", {
|
||||
method: "POST",
|
||||
@@ -882,6 +912,16 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
setEffectsDownloadUrl(result.downloadUrl);
|
||||
setEffectsError(null);
|
||||
|
||||
// Surface the "saved to your files" indicator and, on overwrite,
|
||||
// re-anchor so a subsequent run derives from the saved version (#565).
|
||||
if (result.savedFileId) {
|
||||
const store = useFileStore.getState();
|
||||
store.setLastSavedLibraryFileId(result.savedFileId as string);
|
||||
if (librarySaveMode === "overwrite") {
|
||||
store.updateEntry(selectedIndex, { serverFileId: result.savedFileId as string });
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-trigger download
|
||||
const a = document.createElement("a");
|
||||
a.href = result.downloadUrl;
|
||||
@@ -896,11 +936,6 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
const hasEffectsToApply =
|
||||
settings.blurEnabled ||
|
||||
settings.shadowEnabled ||
|
||||
((settings.backgroundType as string) || "transparent") !== "transparent";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<RemoveBgControls settings={settings} onChange={setSettings} />
|
||||
@@ -941,10 +976,12 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
{/* Phase 2: Single smart download button */}
|
||||
{/* Phase 2: Single smart download button. Routes through the effects
|
||||
request (which saves) when effects apply or the file came from the
|
||||
library; otherwise a plain instant download with no save needed. */}
|
||||
{bgRemoved && files.length <= 1 && (
|
||||
<div className="space-y-2">
|
||||
{hasEffectsToApply ? (
|
||||
{needsEffectsRequest ? (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="remove-background-download-effects"
|
||||
|
||||
@@ -265,7 +265,7 @@ export function useToolProcessor(toolId: string) {
|
||||
}, [reconnectSSE]);
|
||||
|
||||
const processFiles = useCallback(
|
||||
(files: File[], settings: Record<string, unknown>) => {
|
||||
(files: File[], settings: Record<string, unknown>, opts?: { skipLibrarySave?: boolean }) => {
|
||||
if (files.length === 0) {
|
||||
setError("No files selected");
|
||||
return;
|
||||
@@ -437,7 +437,10 @@ export function useToolProcessor(toolId: string) {
|
||||
|
||||
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
||||
saveModeRef.current = useFileStore.getState().librarySaveMode;
|
||||
if (capturedEntry?.serverFileId) {
|
||||
// skipLibrarySave lets a multi-phase tool suppress auto-saving an
|
||||
// intermediate output (e.g. remove-background's Phase 1 transparent
|
||||
// result) so the final phase owns the library save instead.
|
||||
if (!opts?.skipLibrarySave && capturedEntry?.serverFileId) {
|
||||
formData.append("fileId", capturedEntry.serverFileId);
|
||||
formData.append("saveMode", saveModeRef.current);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user