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:
SnapOtter
2026-07-19 22:35:25 +08:00
committed by GitHub
parent 4fea434859
commit 1113c761ea
9 changed files with 472 additions and 34 deletions
@@ -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;
+51 -7
View File
@@ -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"
+5 -2
View File
@@ -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);
}