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:
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||||
|
import { autoSaveToLibrary } from "../../jobs/postprocess.js";
|
||||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||||
import { autoOrient } from "../../lib/auto-orient.js";
|
import { autoOrient } from "../../lib/auto-orient.js";
|
||||||
import {
|
import {
|
||||||
@@ -243,6 +244,8 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let bgImageBuffer: Buffer | null = null;
|
let bgImageBuffer: Buffer | null = null;
|
||||||
let bgFilename = "background";
|
let bgFilename = "background";
|
||||||
|
let fileId: string | null = null;
|
||||||
|
let saveModeRaw: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
@@ -254,6 +257,10 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
bgFilename = sanitizeFilename(part.filename ?? "background");
|
bgFilename = sanitizeFilename(part.filename ?? "background");
|
||||||
} else if (part.type === "field" && part.fieldname === "settings") {
|
} else if (part.type === "field" && part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
|
} else if (part.type === "field" && part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
|
} else if (part.type === "field" && part.fieldname === "saveMode") {
|
||||||
|
saveModeRaw = part.value as string;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -263,6 +270,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Same 400 gate as the Phase 1 route: this is the FINAL request when the
|
||||||
|
// user applies effects, so it carries the library save choice (#565).
|
||||||
|
const saveMode = parseSaveModeField(saveModeRaw);
|
||||||
|
if (saveMode === null) {
|
||||||
|
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||||
|
}
|
||||||
|
|
||||||
if (!settingsRaw) {
|
if (!settingsRaw) {
|
||||||
return reply.status(400).send({ error: "No settings provided" });
|
return reply.status(400).send({ error: "No settings provided" });
|
||||||
}
|
}
|
||||||
@@ -340,10 +354,23 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
|||||||
const outputFilename = `${baseName}_nobg.${fmt}`;
|
const outputFilename = `${baseName}_nobg.${fmt}`;
|
||||||
await putObject(`outputs/${jobId}/${outputFilename}`, resultBuffer);
|
await putObject(`outputs/${jobId}/${outputFilename}`, resultBuffer);
|
||||||
|
|
||||||
|
// Auto-save the composited result (not the Phase 1 transparent
|
||||||
|
// intermediate) to the library when the run referenced a library file.
|
||||||
|
const savedFileId = await autoSaveToLibrary({
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
saveMode,
|
||||||
|
userId: getAuthUser(request)?.id ?? null,
|
||||||
|
buffer: resultBuffer,
|
||||||
|
outName: outputFilename,
|
||||||
|
contentType: BG_FORMAT_CONTENT_TYPES[fmt],
|
||||||
|
toolId: "remove-background",
|
||||||
|
});
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
jobId,
|
jobId,
|
||||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||||
processedSize: resultBuffer.length,
|
processedSize: resultBuffer.length,
|
||||||
|
savedFileId,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
request.log.error({ err }, "Effects processing failed");
|
request.log.error({ err }, "Effects processing failed");
|
||||||
|
|||||||
@@ -271,6 +271,11 @@ export function EraseObjectSettings({
|
|||||||
if (files.length === 0 || !eraserRef.current) return;
|
if (files.length === 0 || !eraserRef.current) return;
|
||||||
|
|
||||||
const capturedIndex = useFileStore.getState().selectedIndex;
|
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();
|
const maskBlob = await eraserRef.current.exportMask();
|
||||||
if (!maskBlob) return;
|
if (!maskBlob) return;
|
||||||
@@ -295,6 +300,9 @@ export function EraseObjectSettings({
|
|||||||
const clientJobId = generateId();
|
const clientJobId = generateId();
|
||||||
|
|
||||||
const applyResult = (r: Record<string, unknown>) => {
|
const applyResult = (r: Record<string, unknown>) => {
|
||||||
|
if (r.savedFileId) {
|
||||||
|
useFileStore.getState().setLastSavedLibraryFileId(r.savedFileId as string);
|
||||||
|
}
|
||||||
useFileStore.getState().updateEntry(capturedIndex, {
|
useFileStore.getState().updateEntry(capturedIndex, {
|
||||||
processedUrl: r.downloadUrl as string,
|
processedUrl: r.downloadUrl as string,
|
||||||
processedPreviewUrl: (r.previewUrl as string) ?? null,
|
processedPreviewUrl: (r.previewUrl as string) ?? null,
|
||||||
@@ -302,6 +310,9 @@ export function EraseObjectSettings({
|
|||||||
status: "completed",
|
status: "completed",
|
||||||
originalSize: r.originalSize as number,
|
originalSize: r.originalSize as number,
|
||||||
processedSize: r.processedSize 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("format", outputFormat);
|
||||||
formData.append("quality", String(quality));
|
formData.append("quality", String(quality));
|
||||||
formData.append("qualityMode", qualityMode);
|
formData.append("qualityMode", qualityMode);
|
||||||
|
if (capturedEntry?.serverFileId) {
|
||||||
|
formData.append("fileId", capturedEntry.serverFileId);
|
||||||
|
formData.append("saveMode", saveMode);
|
||||||
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.timeout = 600_000;
|
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 { useEffect, useRef, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { ProgressCard } from "@/components/common/progress-card";
|
import { ProgressCard } from "@/components/common/progress-card";
|
||||||
import { useTranslation } from "@/contexts/i18n-context";
|
import { useTranslation } from "@/contexts/i18n-context";
|
||||||
import { formatHeaders } from "@/lib/api";
|
import { formatHeaders } from "@/lib/api";
|
||||||
@@ -50,7 +52,10 @@ export function ocrOneFile(
|
|||||||
networkError?: string;
|
networkError?: string;
|
||||||
processingFailed?: 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) => {
|
return new Promise((resolve, reject) => {
|
||||||
const clientJobId = generateId();
|
const clientJobId = generateId();
|
||||||
let settled = false;
|
let settled = false;
|
||||||
@@ -65,11 +70,11 @@ export function ocrOneFile(
|
|||||||
es = null;
|
es = null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveOnce = (text: string) => {
|
const resolveOnce = (result: { text: string; savedFileId?: string }) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
cleanup();
|
cleanup();
|
||||||
resolve(text);
|
resolve(result);
|
||||||
};
|
};
|
||||||
|
|
||||||
const rejectOnce = (error: Error) => {
|
const rejectOnce = (error: Error) => {
|
||||||
@@ -107,7 +112,11 @@ export function ocrOneFile(
|
|||||||
if (data.type !== "single") return;
|
if (data.type !== "single") return;
|
||||||
armStallTimer();
|
armStallTimer();
|
||||||
if (data.phase === "complete" && data.result) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (data.phase === "failed") {
|
if (data.phase === "failed") {
|
||||||
@@ -127,6 +136,10 @@ export function ocrOneFile(
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
formData.append("settings", JSON.stringify(settings));
|
formData.append("settings", JSON.stringify(settings));
|
||||||
formData.append("clientJobId", clientJobId);
|
formData.append("clientJobId", clientJobId);
|
||||||
|
if (library) {
|
||||||
|
formData.append("fileId", library.fileId);
|
||||||
|
formData.append("saveMode", library.saveMode);
|
||||||
|
}
|
||||||
|
|
||||||
const xhr = new XMLHttpRequest();
|
const xhr = new XMLHttpRequest();
|
||||||
xhr.timeout = 600_000;
|
xhr.timeout = 600_000;
|
||||||
@@ -144,7 +157,10 @@ export function ocrOneFile(
|
|||||||
if (xhr.status >= 200 && xhr.status < 300) {
|
if (xhr.status >= 200 && xhr.status < 300) {
|
||||||
try {
|
try {
|
||||||
const body = JSON.parse(xhr.responseText);
|
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 {
|
} catch {
|
||||||
rejectOnce(new Error(messages.processingFailed ?? "Invalid response"));
|
rejectOnce(new Error(messages.processingFailed ?? "Invalid response"));
|
||||||
}
|
}
|
||||||
@@ -179,6 +195,10 @@ export function OcrSettings() {
|
|||||||
const [langOpen, setLangOpen] = useState(false);
|
const [langOpen, setLangOpen] = useState(false);
|
||||||
|
|
||||||
const [text, setText] = useState<string | null>(null);
|
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 [copied, setCopied] = useState(false);
|
||||||
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
|
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
|
||||||
const [progressPercent, setProgressPercent] = useState(0);
|
const [progressPercent, setProgressPercent] = useState(0);
|
||||||
@@ -205,6 +225,7 @@ export function OcrSettings() {
|
|||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
setText(null);
|
setText(null);
|
||||||
|
setSavedLibraryFileId(null);
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
setProgressPhase("uploading");
|
setProgressPhase("uploading");
|
||||||
setProgressPercent(0);
|
setProgressPercent(0);
|
||||||
@@ -228,8 +249,16 @@ export function OcrSettings() {
|
|||||||
const fileBase = (i / total) * 100;
|
const fileBase = (i / total) * 100;
|
||||||
const fileShare = 100 / total;
|
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 {
|
try {
|
||||||
const text = await ocrOneFile(
|
const { text, savedFileId } = await ocrOneFile(
|
||||||
file,
|
file,
|
||||||
settings,
|
settings,
|
||||||
{
|
{
|
||||||
@@ -249,7 +278,13 @@ export function OcrSettings() {
|
|||||||
networkError: t.errors.networkError,
|
networkError: t.errors.networkError,
|
||||||
processingFailed: t.errors.processingFailed,
|
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);
|
results.push(total > 1 ? `--- ${file.name} ---\n${text || "(no text detected)"}` : text);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : String(err);
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
@@ -382,6 +417,15 @@ export function OcrSettings() {
|
|||||||
{/* Result */}
|
{/* Result */}
|
||||||
{text !== null && (
|
{text !== null && (
|
||||||
<div className="space-y-2">
|
<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">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-xs font-medium text-muted-foreground">
|
<span className="text-xs font-medium text-muted-foreground">
|
||||||
{t.toolSettings.ocr.extractedText}
|
{t.toolSettings.ocr.extractedText}
|
||||||
|
|||||||
@@ -651,7 +651,7 @@ interface RemoveBgSettingsProps {
|
|||||||
|
|
||||||
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { files } = useFileStore();
|
const { files, currentEntry } = useFileStore();
|
||||||
const {
|
const {
|
||||||
processFiles,
|
processFiles,
|
||||||
processAllFiles,
|
processAllFiles,
|
||||||
@@ -722,6 +722,22 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
const hasFile = files.length > 0;
|
const hasFile = files.length > 0;
|
||||||
const bgRemoved = bgJobId !== null && !processing;
|
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
|
// Build CSS preview state from current settings and send to tool-page
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!bgRemoved || !onBgPreview) return;
|
if (!bgRemoved || !onBgPreview) return;
|
||||||
@@ -817,7 +833,9 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
// Use processFiles for the progress/SSE flow - it handles everything
|
// Use processFiles for the progress/SSE flow - it handles everything
|
||||||
// But we need the extended response. Override via a fetch after processFiles completes.
|
// But we need the extended response. Override via a fetch after processFiles completes.
|
||||||
// Actually, let's use processFiles and then fetch the job info.
|
// 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
|
// After processFiles completes, extract jobId from downloadUrl
|
||||||
@@ -842,7 +860,14 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
const handleDownloadWithEffects = async () => {
|
const handleDownloadWithEffects = async () => {
|
||||||
if (!bgJobId || !bgFilename) return;
|
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);
|
setApplyingEffects(true);
|
||||||
|
setEffectsError(null);
|
||||||
|
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
const effectSettings: Record<string, unknown> = {
|
const effectSettings: Record<string, unknown> = {
|
||||||
@@ -866,6 +891,11 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
formData.append("backgroundImage", bgImageFile);
|
formData.append("backgroundImage", bgImageFile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (capturedEntry?.serverFileId) {
|
||||||
|
formData.append("fileId", capturedEntry.serverFileId);
|
||||||
|
formData.append("saveMode", librarySaveMode);
|
||||||
|
}
|
||||||
|
|
||||||
const headers = formatHeaders();
|
const headers = formatHeaders();
|
||||||
const response = await fetch("/api/v1/tools/image/remove-background/effects", {
|
const response = await fetch("/api/v1/tools/image/remove-background/effects", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -882,6 +912,16 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
setEffectsDownloadUrl(result.downloadUrl);
|
setEffectsDownloadUrl(result.downloadUrl);
|
||||||
setEffectsError(null);
|
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
|
// Auto-trigger download
|
||||||
const a = document.createElement("a");
|
const a = document.createElement("a");
|
||||||
a.href = result.downloadUrl;
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<RemoveBgControls settings={settings} onChange={setSettings} />
|
<RemoveBgControls settings={settings} onChange={setSettings} />
|
||||||
@@ -941,10 +976,12 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
|
|||||||
</button>
|
</button>
|
||||||
) : null}
|
) : 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 && (
|
{bgRemoved && files.length <= 1 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{hasEffectsToApply ? (
|
{needsEffectsRequest ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
data-testid="remove-background-download-effects"
|
data-testid="remove-background-download-effects"
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ export function useToolProcessor(toolId: string) {
|
|||||||
}, [reconnectSSE]);
|
}, [reconnectSSE]);
|
||||||
|
|
||||||
const processFiles = useCallback(
|
const processFiles = useCallback(
|
||||||
(files: File[], settings: Record<string, unknown>) => {
|
(files: File[], settings: Record<string, unknown>, opts?: { skipLibrarySave?: boolean }) => {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
setError("No files selected");
|
setError("No files selected");
|
||||||
return;
|
return;
|
||||||
@@ -437,7 +437,10 @@ export function useToolProcessor(toolId: string) {
|
|||||||
|
|
||||||
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
||||||
saveModeRef.current = useFileStore.getState().librarySaveMode;
|
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("fileId", capturedEntry.serverFileId);
|
||||||
formData.append("saveMode", saveModeRef.current);
|
formData.append("saveMode", saveModeRef.current);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,16 +2,17 @@ import { BASE_CONFIG, CONVERSION_PRESETS } from "./conversion-presets.js";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Tools where the library save-mode choice (#495) does not work end to end,
|
* Tools where the library save-mode choice (#495) does not work end to end,
|
||||||
* so the selector must stay hidden: either the tool's hand-written route
|
* so the selector must stay hidden: the tool's hand-written route ignores the
|
||||||
* ignores the multipart fileId/saveMode pair, or its settings component
|
* multipart fileId/saveMode pair, so a library-sourced edit is never
|
||||||
* submits through its own XHR without sending them. For these tools a
|
* auto-saved (the pre-#495 status quo).
|
||||||
* library-sourced edit is never auto-saved (the pre-#495 status quo).
|
|
||||||
*
|
*
|
||||||
* When wiring one of these up (parse fileId/saveMode in the route like
|
* When wiring one of these up (parse fileId/saveMode in the route like
|
||||||
* tool-factory.ts, append them in the client like sign-pdf-settings.tsx),
|
* tool-factory.ts, append them in the client like sign-pdf-settings.tsx),
|
||||||
* remove it from this set so the selector appears. The integration suite
|
* remove it from this set so the selector appears. The integration suite
|
||||||
* pins the other direction: every tool NOT listed here returns 400 for an
|
* pins the other direction: every tool NOT listed here returns 400 for an
|
||||||
* invalid saveMode.
|
* invalid saveMode. (#565 wired the five custom-client submitters that had a
|
||||||
|
* ready route but a submitter that dropped the pair: ocr, erase-object,
|
||||||
|
* remove-background, background-replace, blur-background.)
|
||||||
*/
|
*/
|
||||||
export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set([
|
export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set([
|
||||||
// Custom routes without fileId/saveMode handling
|
// Custom routes without fileId/saveMode handling
|
||||||
@@ -43,12 +44,6 @@ export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set(
|
|||||||
"strip-metadata",
|
"strip-metadata",
|
||||||
"vectorize",
|
"vectorize",
|
||||||
"watermark-image",
|
"watermark-image",
|
||||||
// Routes accept saveMode but the custom client submitters do not send it yet
|
|
||||||
"ocr",
|
|
||||||
"remove-background",
|
|
||||||
"erase-object",
|
|
||||||
"background-replace",
|
|
||||||
"blur-background",
|
|
||||||
// Conversion presets served by the custom image-to-pdf / pdf-to-image /
|
// Conversion presets served by the custom image-to-pdf / pdf-to-image /
|
||||||
// svg-to-raster routes (the registry group rides the factory and works)
|
// svg-to-raster routes (the registry group rides the factory and works)
|
||||||
...CONVERSION_PRESETS.filter((p) => BASE_CONFIG[p.base]?.group !== "registry").map((p) => p.id),
|
...CONVERSION_PRESETS.filter((p) => BASE_CONFIG[p.base]?.group !== "registry").map((p) => p.id),
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
* enqueueToolJob mocked so no Python model or worker runs.
|
* enqueueToolJob mocked so no Python model or worker runs.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiToolPath } from "@snapotter/shared";
|
import { apiToolPath, LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS } from "@snapotter/shared";
|
||||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { INVALID_SAVE_MODE_ERROR } from "../../../apps/api/src/jobs/types.js";
|
import { INVALID_SAVE_MODE_ERROR } from "../../../apps/api/src/jobs/types.js";
|
||||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||||
@@ -136,6 +136,122 @@ describe("saveMode is ignored by routes outside the feature", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("custom-client tools participate in the saveMode feature (#565)", () => {
|
||||||
|
// Wiring their submitters to send fileId/saveMode means the selector must
|
||||||
|
// now show for them, so they must NOT be in the unsupported set.
|
||||||
|
for (const toolId of [
|
||||||
|
"ocr",
|
||||||
|
"erase-object",
|
||||||
|
"remove-background",
|
||||||
|
"background-replace",
|
||||||
|
"blur-background",
|
||||||
|
]) {
|
||||||
|
it(`${toolId} is not in LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS`, () => {
|
||||||
|
expect(LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS.has(toolId)).toBe(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("custom-client routes forward fileId and saveMode into the job", () => {
|
||||||
|
it("erase-object forwards the pair", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
|
||||||
|
{ name: "mask", filename: "mask.png", contentType: "image/png", content: PNG },
|
||||||
|
{ name: "fileId", content: "lib-erase" },
|
||||||
|
{ name: "saveMode", content: "overwrite" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: apiToolPath("erase-object"),
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(202);
|
||||||
|
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
|
||||||
|
toolId: "erase-object",
|
||||||
|
fileId: "lib-erase",
|
||||||
|
saveMode: "overwrite",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blur-background forwards the pair", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
|
||||||
|
{ name: "settings", content: JSON.stringify({ intensity: 40 }) },
|
||||||
|
{ name: "fileId", content: "lib-blur" },
|
||||||
|
{ name: "saveMode", content: "new" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: apiToolPath("blur-background"),
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(202);
|
||||||
|
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
|
||||||
|
toolId: "blur-background",
|
||||||
|
fileId: "lib-blur",
|
||||||
|
saveMode: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("background-replace forwards the pair", async () => {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
|
||||||
|
{ name: "settings", content: JSON.stringify({ backgroundType: "color", color: "#ffffff" }) },
|
||||||
|
{ name: "fileId", content: "lib-bgr" },
|
||||||
|
{ name: "saveMode", content: "overwrite" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: apiToolPath("background-replace"),
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(202);
|
||||||
|
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
|
||||||
|
toolId: "background-replace",
|
||||||
|
fileId: "lib-bgr",
|
||||||
|
saveMode: "overwrite",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ocr forwards the pair", async () => {
|
||||||
|
// quality "fast" + a non-Korean language reaches enqueue without any OCR
|
||||||
|
// runtime bundle (resolveOcrIngressSettings short-circuits before the gate).
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
|
||||||
|
{ name: "settings", content: JSON.stringify({ quality: "fast", language: "en" }) },
|
||||||
|
{ name: "fileId", content: "lib-ocr" },
|
||||||
|
{ name: "saveMode", content: "new" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: apiToolPath("ocr"),
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(202);
|
||||||
|
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
|
||||||
|
toolId: "ocr",
|
||||||
|
fileId: "lib-ocr",
|
||||||
|
saveMode: "new",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("sign-pdf saveMode pass-through", () => {
|
describe("sign-pdf saveMode pass-through", () => {
|
||||||
it("forwards fileId and saveMode into the enqueued job", async () => {
|
it("forwards fileId and saveMode into the enqueued job", async () => {
|
||||||
const { body, contentType } = createMultipartPayload([
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
|||||||
@@ -8,7 +8,10 @@
|
|||||||
* Also covers the /effects sub-route for Phase 2 compositing.
|
* Also covers the /effects sub-route for Phase 2 compositing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import sharp from "sharp";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { putObject } from "../../../../apps/api/src/lib/object-storage.js";
|
||||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||||
import {
|
import {
|
||||||
buildTestApp,
|
buildTestApp,
|
||||||
@@ -502,3 +505,184 @@ describe("Remove Background", () => {
|
|||||||
expect(res.statusCode).toBe(401);
|
expect(res.statusCode).toBe(401);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Phase 2 effects route: library auto-save (#495 / #565) ─────────
|
||||||
|
//
|
||||||
|
// The compositing route is pure Sharp (no AI sidecar), so its library
|
||||||
|
// auto-save is testable end to end in CI. It saves the FINAL composited
|
||||||
|
// image (not the transparent Phase 1 intermediate) under the chosen
|
||||||
|
// saveMode when the request references a library file.
|
||||||
|
|
||||||
|
/** Upload a PNG into the library, return its file id. */
|
||||||
|
async function uploadLibraryFile(filename: string): Promise<string> {
|
||||||
|
const { body, contentType } = createMultipartPayload([
|
||||||
|
{ name: "file", filename, contentType: "image/png", content: PNG },
|
||||||
|
]);
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/files/upload",
|
||||||
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(201);
|
||||||
|
return JSON.parse(res.body).files[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch file detail (metadata + version chain). */
|
||||||
|
async function getFileDetail(id: string) {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/api/v1/files/${id}`,
|
||||||
|
headers: { authorization: `Bearer ${adminToken}` },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
return JSON.parse(res.body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed the cached Phase 1 artifacts the effects route reads: a transparent
|
||||||
|
* subject (`_mask.png`) and the original (`_original.png`) under the job's
|
||||||
|
* output prefix, both matching the `<base>` derived from the filename.
|
||||||
|
*/
|
||||||
|
async function seedPhase1Cache(jobId: string, base: string): Promise<void> {
|
||||||
|
const mask = await sharp({
|
||||||
|
create: { width: 20, height: 20, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
const original = await sharp({
|
||||||
|
create: { width: 20, height: 20, channels: 3, background: { r: 10, g: 20, b: 30 } },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
await putObject(`outputs/${jobId}/${base}_mask.png`, mask);
|
||||||
|
await putObject(`outputs/${jobId}/${base}_original.png`, original);
|
||||||
|
}
|
||||||
|
|
||||||
|
function effectsPayload(fields: Array<{ name: string; content: string }>) {
|
||||||
|
return createMultipartPayload(fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Remove Background effects route: library saveMode", () => {
|
||||||
|
it("saves the composited result as an independent new library file", async () => {
|
||||||
|
const originalId = await uploadLibraryFile("rbfxnew.png");
|
||||||
|
const jobId = randomUUID();
|
||||||
|
await seedPhase1Cache(jobId, "rbfxnew");
|
||||||
|
|
||||||
|
const { body, contentType } = effectsPayload([
|
||||||
|
{
|
||||||
|
name: "settings",
|
||||||
|
content: JSON.stringify({
|
||||||
|
jobId,
|
||||||
|
filename: "rbfxnew.png",
|
||||||
|
backgroundType: "color",
|
||||||
|
backgroundColor: "#FF0000",
|
||||||
|
outputFormat: "png",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ name: "fileId", content: originalId },
|
||||||
|
{ name: "saveMode", content: "new" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/image/remove-background/effects",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const parsed = JSON.parse(res.body);
|
||||||
|
expect(parsed.savedFileId).toBeDefined();
|
||||||
|
expect(parsed.savedFileId).not.toBe(originalId);
|
||||||
|
|
||||||
|
const detail = await getFileDetail(parsed.savedFileId);
|
||||||
|
expect(detail.file.version).toBe(1);
|
||||||
|
expect(detail.file.parentId).toBeNull();
|
||||||
|
expect(detail.file.toolChain).toContain("remove-background");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrite creates a superseding version linked to the original", async () => {
|
||||||
|
const originalId = await uploadLibraryFile("rbfxover.png");
|
||||||
|
const jobId = randomUUID();
|
||||||
|
await seedPhase1Cache(jobId, "rbfxover");
|
||||||
|
|
||||||
|
const { body, contentType } = effectsPayload([
|
||||||
|
{
|
||||||
|
name: "settings",
|
||||||
|
content: JSON.stringify({
|
||||||
|
jobId,
|
||||||
|
filename: "rbfxover.png",
|
||||||
|
backgroundType: "color",
|
||||||
|
backgroundColor: "#00FF00",
|
||||||
|
outputFormat: "png",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ name: "fileId", content: originalId },
|
||||||
|
{ name: "saveMode", content: "overwrite" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/image/remove-background/effects",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const parsed = JSON.parse(res.body);
|
||||||
|
expect(parsed.savedFileId).toBeDefined();
|
||||||
|
|
||||||
|
const detail = await getFileDetail(parsed.savedFileId);
|
||||||
|
expect(detail.file.version).toBe(2);
|
||||||
|
expect(detail.file.parentId).toBe(originalId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not save when no fileId is sent", async () => {
|
||||||
|
const jobId = randomUUID();
|
||||||
|
await seedPhase1Cache(jobId, "rbfxnolib");
|
||||||
|
|
||||||
|
const { body, contentType } = effectsPayload([
|
||||||
|
{
|
||||||
|
name: "settings",
|
||||||
|
content: JSON.stringify({
|
||||||
|
jobId,
|
||||||
|
filename: "rbfxnolib.png",
|
||||||
|
backgroundType: "color",
|
||||||
|
backgroundColor: "#0000FF",
|
||||||
|
outputFormat: "png",
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/image/remove-background/effects",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(JSON.parse(res.body).savedFileId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an invalid saveMode with 400", async () => {
|
||||||
|
const { body, contentType } = effectsPayload([
|
||||||
|
{
|
||||||
|
name: "settings",
|
||||||
|
content: JSON.stringify({ jobId: randomUUID(), filename: "x.png" }),
|
||||||
|
},
|
||||||
|
{ name: "saveMode", content: "destroy-everything" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/tools/image/remove-background/effects",
|
||||||
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(400);
|
||||||
|
expect(JSON.parse(res.body).error).toMatch(/saveMode/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ describe("OCR async response handling", () => {
|
|||||||
result: { text: "queued OCR text", actualQuality: "fast" },
|
result: { text: "queued OCR text", actualQuality: "fast" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(promise).resolves.toBe("queued OCR text");
|
await expect(promise).resolves.toMatchObject({ text: "queued OCR text" });
|
||||||
expect(events.close).toHaveBeenCalledTimes(1);
|
expect(events.close).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
events.emit({ type: "single", phase: "failed", error: "late duplicate" });
|
events.emit({ type: "single", phase: "failed", error: "late duplicate" });
|
||||||
@@ -109,7 +109,7 @@ describe("OCR async response handling", () => {
|
|||||||
phase: "complete",
|
phase: "complete",
|
||||||
result: { text: "fast worker result" },
|
result: { text: "fast worker result" },
|
||||||
});
|
});
|
||||||
await expect(promise).resolves.toBe("fast worker result");
|
await expect(promise).resolves.toMatchObject({ text: "fast worker result" });
|
||||||
|
|
||||||
xhr.status = 202;
|
xhr.status = 202;
|
||||||
xhr.onload?.();
|
xhr.onload?.();
|
||||||
@@ -127,7 +127,24 @@ describe("OCR async response handling", () => {
|
|||||||
xhr.responseText = JSON.stringify({ text: "sync OCR text" });
|
xhr.responseText = JSON.stringify({ text: "sync OCR text" });
|
||||||
xhr.onload?.();
|
xhr.onload?.();
|
||||||
|
|
||||||
await expect(promise).resolves.toBe("sync OCR text");
|
await expect(promise).resolves.toMatchObject({ text: "sync OCR text" });
|
||||||
|
expect(events.close).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces the saved library file id from the terminal worker result", async () => {
|
||||||
|
const promise = runOcr();
|
||||||
|
const xhr = xhrs[0];
|
||||||
|
const events = MockEventSource.instances[0];
|
||||||
|
|
||||||
|
xhr.status = 202;
|
||||||
|
xhr.onload?.();
|
||||||
|
events.emit({
|
||||||
|
type: "single",
|
||||||
|
phase: "complete",
|
||||||
|
result: { text: "saved text", savedFileId: "lib-42" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(promise).resolves.toEqual({ text: "saved text", savedFileId: "lib-42" });
|
||||||
expect(events.close).toHaveBeenCalledTimes(1);
|
expect(events.close).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -186,6 +203,6 @@ describe("OCR async response handling", () => {
|
|||||||
phase: "complete",
|
phase: "complete",
|
||||||
result: { text: "still queued safely" },
|
result: { text: "still queued safely" },
|
||||||
});
|
});
|
||||||
await expect(promise).resolves.toBe("still queued safely");
|
await expect(promise).resolves.toMatchObject({ text: "still queued safely" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user