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 { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { autoSaveToLibrary } from "../../jobs/postprocess.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import {
|
||||
@@ -243,6 +244,8 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let bgImageBuffer: Buffer | null = null;
|
||||
let bgFilename = "background";
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
|
||||
try {
|
||||
const parts = request.parts();
|
||||
@@ -254,6 +257,10 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
bgFilename = sanitizeFilename(part.filename ?? "background");
|
||||
} else if (part.type === "field" && part.fieldname === "settings") {
|
||||
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) {
|
||||
@@ -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) {
|
||||
return reply.status(400).send({ error: "No settings provided" });
|
||||
}
|
||||
@@ -340,10 +354,23 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
const outputFilename = `${baseName}_nobg.${fmt}`;
|
||||
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({
|
||||
jobId,
|
||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
|
||||
processedSize: resultBuffer.length,
|
||||
savedFileId,
|
||||
});
|
||||
} catch (err) {
|
||||
request.log.error({ err }, "Effects processing failed");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
* so the selector must stay hidden: either the tool's hand-written route
|
||||
* ignores the multipart fileId/saveMode pair, or its settings component
|
||||
* submits through its own XHR without sending them. For these tools a
|
||||
* library-sourced edit is never auto-saved (the pre-#495 status quo).
|
||||
* so the selector must stay hidden: the tool's hand-written route ignores the
|
||||
* multipart fileId/saveMode pair, so a 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
|
||||
* 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
|
||||
* 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([
|
||||
// Custom routes without fileId/saveMode handling
|
||||
@@ -43,12 +44,6 @@ export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set(
|
||||
"strip-metadata",
|
||||
"vectorize",
|
||||
"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 /
|
||||
// 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),
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* 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 { INVALID_SAVE_MODE_ERROR } from "../../../apps/api/src/jobs/types.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", () => {
|
||||
it("forwards fileId and saveMode into the enqueued job", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
* 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 { putObject } from "../../../../apps/api/src/lib/object-storage.js";
|
||||
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
@@ -502,3 +505,184 @@ describe("Remove Background", () => {
|
||||
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" },
|
||||
});
|
||||
|
||||
await expect(promise).resolves.toBe("queued OCR text");
|
||||
await expect(promise).resolves.toMatchObject({ text: "queued OCR text" });
|
||||
expect(events.close).toHaveBeenCalledTimes(1);
|
||||
|
||||
events.emit({ type: "single", phase: "failed", error: "late duplicate" });
|
||||
@@ -109,7 +109,7 @@ describe("OCR async response handling", () => {
|
||||
phase: "complete",
|
||||
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.onload?.();
|
||||
@@ -127,7 +127,24 @@ describe("OCR async response handling", () => {
|
||||
xhr.responseText = JSON.stringify({ text: "sync OCR text" });
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -186,6 +203,6 @@ describe("OCR async response handling", () => {
|
||||
phase: "complete",
|
||||
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