mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(files): add save-as-new vs overwrite choice for library file edits (#564)
Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior. The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged. Closes #495
This commit is contained in:
@@ -11,6 +11,7 @@ import { mkdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { extname, join } from "node:path";
|
||||
import { probeMedia } from "@snapotter/media-engine";
|
||||
import type { LibrarySaveMode } from "@snapotter/shared";
|
||||
import { eq } from "drizzle-orm";
|
||||
import sharp from "sharp";
|
||||
import { db, schema } from "../db/index.js";
|
||||
@@ -173,6 +174,8 @@ export async function generatePreview(
|
||||
|
||||
export interface AutoSaveOpts {
|
||||
fileId?: string;
|
||||
/** "overwrite" supersedes the original with a linked version; "new" (default) keeps it. */
|
||||
saveMode?: LibrarySaveMode;
|
||||
userId: string | null;
|
||||
buffer: Buffer;
|
||||
outName: string;
|
||||
@@ -182,8 +185,12 @@ export interface AutoSaveOpts {
|
||||
|
||||
/**
|
||||
* Auto-save a processed output to the persistent user file library when a
|
||||
* fileId is provided. Creates a new version linked to the parent file with
|
||||
* the tool appended to the toolChain.
|
||||
* fileId is provided. The tool is appended to the parent's toolChain either
|
||||
* way; saveMode decides the linkage (issue #495):
|
||||
* - "new" (default): independent root row (version 1, no parentId), so the
|
||||
* original stays visible in the leaf-only library list.
|
||||
* - "overwrite": new version linked to the parent (version + 1, parentId),
|
||||
* which supersedes the original in the list.
|
||||
*
|
||||
* Returns the new file ID on success, undefined when no fileId or on error.
|
||||
*/
|
||||
@@ -201,7 +208,7 @@ export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | un
|
||||
// another user's file via a known fileId.
|
||||
if (parent.userId !== opts.userId) return undefined;
|
||||
|
||||
const newVersion = parent.version + 1;
|
||||
const overwrite = opts.saveMode === "overwrite";
|
||||
const parentChain: string[] = parent.toolChain ?? [];
|
||||
const newToolChain = [...parentChain, opts.toolId];
|
||||
const storedName = await saveFile(opts.buffer, opts.outName);
|
||||
@@ -245,8 +252,8 @@ export async function autoSaveToLibrary(opts: AutoSaveOpts): Promise<string | un
|
||||
size: opts.buffer.length,
|
||||
width,
|
||||
height,
|
||||
version: newVersion,
|
||||
parentId: opts.fileId,
|
||||
version: overwrite ? parent.version + 1 : 1,
|
||||
parentId: overwrite ? opts.fileId : null,
|
||||
toolChain: newToolChain,
|
||||
});
|
||||
return newId;
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
/**
|
||||
* Shared types and naming helpers for the BullMQ job system.
|
||||
* Shared types and naming helpers for the BullMQ job system, plus the
|
||||
* saveMode multipart-field validation shared by tool-factory and the
|
||||
* hand-written tool routes.
|
||||
*/
|
||||
|
||||
import { LIBRARY_SAVE_MODES, type LibrarySaveMode } from "@snapotter/shared";
|
||||
|
||||
/** The five processing pools that partition work by resource profile. */
|
||||
export const POOLS = ["image", "media", "ai", "docs", "system"] as const;
|
||||
export type Pool = (typeof POOLS)[number];
|
||||
@@ -32,6 +36,8 @@ export interface ToolJobData {
|
||||
*/
|
||||
dbSettings?: Record<string, unknown>;
|
||||
fileId?: string;
|
||||
/** How to save the result to the library when fileId is set; defaults to "new". */
|
||||
saveMode?: LibrarySaveMode;
|
||||
clientJobId?: string;
|
||||
kind:
|
||||
| "tool"
|
||||
@@ -50,6 +56,18 @@ export interface ToolJobData {
|
||||
_otel?: { traceparent: string; tracestate?: string };
|
||||
}
|
||||
|
||||
/** Error message for the { error } 400 response when a multipart saveMode field has an unknown value. */
|
||||
export const INVALID_SAVE_MODE_ERROR = 'Invalid saveMode (expected "new" or "overwrite")';
|
||||
|
||||
/**
|
||||
* Validate a client-supplied multipart saveMode field.
|
||||
* Returns undefined when the field was absent, null when the value is invalid.
|
||||
*/
|
||||
export function parseSaveModeField(raw: string | null): LibrarySaveMode | undefined | null {
|
||||
if (raw === null) return undefined;
|
||||
return (LIBRARY_SAVE_MODES as readonly string[]).includes(raw) ? (raw as LibrarySaveMode) : null;
|
||||
}
|
||||
|
||||
/** Result returned by a completed BullMQ job. */
|
||||
export interface ToolJobResult {
|
||||
outputRefs: string[];
|
||||
|
||||
@@ -417,12 +417,14 @@ async function processToolJob(job: Job<ToolJobData>): Promise<ToolJobResult> {
|
||||
// Generate preview for non-browser-previewable formats
|
||||
const previewRef = await generatePreview(resultBuffer, resultContentType, jobId, inputBuffer);
|
||||
|
||||
// Auto-save a new version when the input came from the user's library
|
||||
// (data.fileId is set by tool-factory when the upload referenced a
|
||||
// library file). Without a fileId this is a no-op, so tool-first uploads
|
||||
// are not auto-saved.
|
||||
// Auto-save when the input came from the user's library (data.fileId is
|
||||
// set by the route when the upload referenced a library file). saveMode
|
||||
// picks between an independent new file (default) and a superseding
|
||||
// version. Without a fileId this is a no-op, so tool-first uploads are
|
||||
// not auto-saved.
|
||||
const savedFileId = await autoSaveToLibrary({
|
||||
fileId: data.fileId,
|
||||
saveMode: data.saveMode,
|
||||
userId: data.userId,
|
||||
buffer: resultBuffer,
|
||||
outName,
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { z } from "zod";
|
||||
import { env } from "../config.js";
|
||||
import { db, schema } from "../db/index.js";
|
||||
import { enqueueToolJob, waitForJob } from "../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../jobs/types.js";
|
||||
import { formatZodErrors, friendlyError, stripInternalPaths } from "../lib/errors.js";
|
||||
import { getFirstMissingBundleForTool, isToolInstalled } from "../lib/feature-status.js";
|
||||
import { getObjectBuffer, putObject } from "../lib/object-storage.js";
|
||||
@@ -246,6 +247,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
let filename = "file";
|
||||
let settingsRaw: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileCount = 0;
|
||||
const received: ReceivedUpload[] = [];
|
||||
@@ -283,6 +285,9 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
}
|
||||
if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
if (part.fieldname === "clientJobId") {
|
||||
const raw = part.value as string;
|
||||
if (typeof raw === "string" && raw.length > 0 && raw.length <= 128) {
|
||||
@@ -304,6 +309,11 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
// Require at least one file
|
||||
if (received.length === 0) {
|
||||
return reply.status(400).send({ error: "No file provided" });
|
||||
@@ -522,6 +532,7 @@ export function createToolRoute<T>(app: FastifyInstance, config: ToolRouteConfig
|
||||
settings,
|
||||
dbSettings,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
kind: "tool",
|
||||
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
|
||||
|
||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -153,6 +154,7 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -171,6 +173,8 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -180,6 +184,11 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -252,6 +261,7 @@ export function registerAiCanvasExpand(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
|
||||
@@ -112,6 +113,7 @@ export function registerAutoSubtitles(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -130,6 +132,8 @@ export function registerAutoSubtitles(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -139,6 +143,11 @@ export function registerAutoSubtitles(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No video file provided" });
|
||||
}
|
||||
@@ -168,6 +177,7 @@ export function registerAutoSubtitles(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { compositeOnColor, createGradientBackground } from "../../lib/bg-effects.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
@@ -132,6 +133,7 @@ export function registerBackgroundReplace(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -150,6 +152,8 @@ export function registerBackgroundReplace(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -159,6 +163,11 @@ export function registerBackgroundReplace(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -227,6 +236,7 @@ export function registerBackgroundReplace(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { blurBackground } from "../../lib/bg-effects.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
@@ -102,6 +103,7 @@ export function registerBlurBackground(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -120,6 +122,8 @@ export function registerBlurBackground(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -129,6 +133,11 @@ export function registerBlurBackground(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -197,6 +206,7 @@ export function registerBlurBackground(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -88,6 +89,7 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -106,6 +108,8 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -115,6 +119,11 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -174,6 +183,7 @@ export function registerBlurFaces(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -87,6 +88,7 @@ export function registerColorize(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -105,6 +107,8 @@ export function registerColorize(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -114,6 +118,11 @@ export function registerColorize(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -173,6 +182,7 @@ export function registerColorize(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { getFirstMissingBundleForTool, isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -82,6 +83,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -100,6 +102,8 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -109,6 +113,11 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -168,6 +177,7 @@ export function registerEnhanceFaces(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -56,6 +57,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
let filename = "image";
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let format = "png";
|
||||
let quality = 95;
|
||||
let imageKey: string | null = null;
|
||||
@@ -80,6 +82,8 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
} else if (part.fieldname === "format") {
|
||||
format = (part.value as string) || "png";
|
||||
} else if (part.fieldname === "quality") {
|
||||
@@ -93,6 +97,11 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!imageKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -170,6 +179,7 @@ export function registerEraseObject(app: FastifyInstance) {
|
||||
settings: { format, quality },
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -92,6 +93,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -110,6 +112,8 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -119,6 +123,11 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -178,6 +187,7 @@ export function registerNoiseRemoval(app: FastifyInstance) {
|
||||
settings: parsed,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { z } from "zod";
|
||||
import { env } from "../../config.js";
|
||||
import { registerAiPathJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { copyObjectToFile, deleteObject } from "../../lib/object-storage.js";
|
||||
import { resolveOcrIngressSettings } from "../../lib/ocr-capability.js";
|
||||
@@ -123,6 +124,7 @@ export function registerOcrPdf(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
const ingressAbort = new AbortController();
|
||||
const abortIngress = () => ingressAbort.abort();
|
||||
@@ -150,6 +152,8 @@ export function registerOcrPdf(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -161,6 +165,12 @@ export function registerOcrPdf(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
if (inputKey) await deleteObject(inputKey).catch(() => {});
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No PDF file provided" });
|
||||
}
|
||||
@@ -250,6 +260,7 @@ export function registerOcrPdf(app: FastifyInstance) {
|
||||
settings: { ...normalizedSettings, quality },
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { z } from "zod";
|
||||
import { env } from "../../config.js";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { deleteObject } from "../../lib/object-storage.js";
|
||||
import { resolveOcrIngressSettings } from "../../lib/ocr-capability.js";
|
||||
@@ -134,6 +135,7 @@ export function registerOcr(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -155,6 +157,8 @@ export function registerOcr(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -166,6 +170,12 @@ export function registerOcr(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
if (inputKey) await deleteObject(inputKey).catch(() => {});
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -223,6 +233,7 @@ export function registerOcr(app: FastifyInstance) {
|
||||
settings: { ...normalizedSettings, quality },
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
analyticsDistinctId: request.headers["x-posthog-distinct-id"] as string | undefined,
|
||||
});
|
||||
|
||||
@@ -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 { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -80,6 +81,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -98,6 +100,8 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -107,6 +111,11 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -166,6 +175,7 @@ export function registerRedEyeRemoval(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -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 { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import {
|
||||
applyEffects,
|
||||
@@ -114,6 +115,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -132,6 +134,8 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -141,6 +145,11 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -218,6 +227,7 @@ export function registerRemoveBackground(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { z } from "zod";
|
||||
import { env } from "../../config.js";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { detectAnimation } from "../../lib/animation-detect.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -132,6 +133,7 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
let bgBuffer: Buffer | null = null;
|
||||
let bgName = "background";
|
||||
@@ -157,6 +159,8 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -166,6 +170,11 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -247,6 +256,7 @@ export function registerRemoveGifBackground(app: FastifyInstance) {
|
||||
dbSettings: settings, // keep the internal bgImageKey out of the audit row
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -105,6 +106,7 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -123,6 +125,8 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -132,6 +136,11 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -198,6 +207,7 @@ export function registerRestorePhoto(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob, waitForJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { stripInternalPaths } from "../../lib/errors.js";
|
||||
import { validateImageBuffer } from "../../lib/file-validation.js";
|
||||
import { getObjectBuffer } from "../../lib/object-storage.js";
|
||||
@@ -55,6 +56,7 @@ export function registerSignPdf(app: FastifyInstance) {
|
||||
let filename = "document.pdf";
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let placementsRaw: string | null = null;
|
||||
const sigParts: Array<{ index: number; key: string }> = [];
|
||||
|
||||
@@ -81,6 +83,8 @@ export function registerSignPdf(app: FastifyInstance) {
|
||||
if (/^[0-9a-f-]{36}$/i.test(raw)) clientJobId = raw;
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -90,6 +94,11 @@ export function registerSignPdf(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!pdfKey) return reply.status(400).send({ error: "No PDF file provided" });
|
||||
if (!placementsRaw) return reply.status(400).send({ error: "No placements provided" });
|
||||
|
||||
@@ -142,6 +151,7 @@ export function registerSignPdf(app: FastifyInstance) {
|
||||
settings: { placements: parsed.placements },
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,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 { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
import { type TranscriptSegment, toSrt, toVtt } from "../../lib/subtitle-format.js";
|
||||
@@ -100,6 +101,7 @@ export function registerTranscribeAudio(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -118,6 +120,8 @@ export function registerTranscribeAudio(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -127,6 +131,11 @@ export function registerTranscribeAudio(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No audio file provided" });
|
||||
}
|
||||
@@ -156,6 +165,7 @@ export function registerTranscribeAudio(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -173,6 +174,7 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -191,6 +193,8 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -200,6 +204,11 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -263,6 +272,7 @@ export function registerTransparencyFixer(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import sharp from "sharp";
|
||||
import { z } from "zod";
|
||||
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
|
||||
import { enqueueToolJob } from "../../jobs/enqueue.js";
|
||||
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
|
||||
import { autoOrient } from "../../lib/auto-orient.js";
|
||||
import { formatZodErrors, stripInternalPaths } from "../../lib/errors.js";
|
||||
import { isToolInstalled } from "../../lib/feature-status.js";
|
||||
@@ -137,6 +138,7 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
let settingsRaw: string | null = null;
|
||||
let clientJobId: string | null = null;
|
||||
let fileId: string | null = null;
|
||||
let saveModeRaw: string | null = null;
|
||||
let inputKey: string | null = null;
|
||||
|
||||
try {
|
||||
@@ -155,6 +157,8 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
}
|
||||
} else if (part.fieldname === "fileId") {
|
||||
fileId = part.value as string;
|
||||
} else if (part.fieldname === "saveMode") {
|
||||
saveModeRaw = part.value as string;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -164,6 +168,11 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const saveMode = parseSaveModeField(saveModeRaw);
|
||||
if (saveMode === null) {
|
||||
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
|
||||
}
|
||||
|
||||
if (!inputKey) {
|
||||
return reply.status(400).send({ error: "No image file provided" });
|
||||
}
|
||||
@@ -225,6 +234,7 @@ export function registerUpscale(app: FastifyInstance) {
|
||||
settings,
|
||||
clientJobId: clientJobId ?? undefined,
|
||||
fileId: fileId ?? undefined,
|
||||
saveMode,
|
||||
kind: "ai-tool",
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS } from "@snapotter/shared";
|
||||
import { useTranslation } from "@/contexts/i18n-context";
|
||||
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
/**
|
||||
* Per-edit choice for library-sourced files (#495): save the processed result
|
||||
* to the file library as a new file (default, keeps the original) or
|
||||
* overwrite the original. Renders nothing when the choice would not be
|
||||
* honored: entry not linked to a library file, tool whose route or submitter
|
||||
* ignores the saveMode field, or a multi-file batch run (batches never send
|
||||
* fileId, so nothing is auto-saved).
|
||||
*/
|
||||
export function LibrarySaveModeSelector({ toolId }: { toolId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const currentEntry = useFileStore((s) => s.currentEntry);
|
||||
const entries = useFileStore((s) => s.entries);
|
||||
const librarySaveMode = useFileStore((s) => s.librarySaveMode);
|
||||
const setLibrarySaveMode = useFileStore((s) => s.setLibrarySaveMode);
|
||||
const processing = useFileStore((s) => s.processing);
|
||||
|
||||
if (!currentEntry?.serverFileId) return null;
|
||||
if (LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS.has(toolId)) return null;
|
||||
const isBatchRun = entries.length > 1 && !MULTI_FILE_TOOLS.has(toolId);
|
||||
if (isBatchRun) return null;
|
||||
|
||||
return (
|
||||
<fieldset className="space-y-2 rounded-lg border border-border p-3">
|
||||
<legend className="px-1 text-xs font-medium text-muted-foreground">
|
||||
{t.toolPage.librarySaveTitle}
|
||||
</legend>
|
||||
<label className="flex items-start gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="library-save-mode"
|
||||
className="mt-1"
|
||||
checked={librarySaveMode === "new"}
|
||||
onChange={() => setLibrarySaveMode("new")}
|
||||
disabled={processing}
|
||||
/>
|
||||
<span>
|
||||
{t.toolPage.librarySaveAsNew}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.toolPage.librarySaveAsNewHint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
<label className="flex items-start gap-2 text-sm text-foreground">
|
||||
<input
|
||||
type="radio"
|
||||
name="library-save-mode"
|
||||
className="mt-1"
|
||||
checked={librarySaveMode === "overwrite"}
|
||||
onChange={() => setLibrarySaveMode("overwrite")}
|
||||
disabled={processing}
|
||||
/>
|
||||
<span>
|
||||
{t.toolPage.libraryOverwrite}
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t.toolPage.libraryOverwriteHint}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
@@ -48,6 +48,8 @@ interface ReviewPanelProps {
|
||||
totalCount?: number;
|
||||
successCount?: number;
|
||||
failedCount?: number;
|
||||
/** Library id of the auto-saved result (#495); replaces the manual save link. */
|
||||
savedLibraryFileId?: string | null;
|
||||
}
|
||||
|
||||
export function ReviewPanel({
|
||||
@@ -62,6 +64,7 @@ export function ReviewPanel({
|
||||
totalCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
savedLibraryFileId,
|
||||
}: ReviewPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -194,8 +197,20 @@ export function ReviewPanel({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Result already auto-saved to the library: show where it went
|
||||
instead of the manual save link (avoids duplicate saves). */}
|
||||
{!isDataOutput && savedLibraryFileId && (
|
||||
<div className="flex items-center justify-center gap-1.5 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Save to Files -- subtle text link */}
|
||||
{!isDataOutput && (
|
||||
{!isDataOutput && !savedLibraryFileId && (
|
||||
<div className="flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -432,7 +432,9 @@ export function FileDetails({ mobile = false }: FileDetailsProps) {
|
||||
navigate("/", { state: { fromLibrary: true } });
|
||||
}
|
||||
|
||||
// Set serverFileId on each entry so tool processing creates new versions
|
||||
// Set serverFileId on each entry so tool processing auto-saves the result
|
||||
// to the library: an independent new file by default, or a superseding
|
||||
// version when the user picks overwrite (#495)
|
||||
setTimeout(() => {
|
||||
const store = useFileStore.getState();
|
||||
for (let i = 0; i < valid.length; i++) {
|
||||
|
||||
@@ -186,8 +186,12 @@ export function SignPdfSettings({ signProps }: { signProps?: SignProps }) {
|
||||
form.append("placements", JSON.stringify(placements));
|
||||
form.append("clientJobId", clientJobId);
|
||||
// Forward the library file id (when the PDF came from the library) so the
|
||||
// worker auto-saves the signed result as a new version.
|
||||
if (currentEntry?.serverFileId) form.append("fileId", currentEntry.serverFileId);
|
||||
// worker auto-saves the signed result, honoring the chosen save mode
|
||||
// (new file by default, overwrite on request).
|
||||
if (currentEntry?.serverFileId) {
|
||||
form.append("fileId", currentEntry.serverFileId);
|
||||
form.append("saveMode", useFileStore.getState().librarySaveMode);
|
||||
}
|
||||
pngs.forEach((png, i) => {
|
||||
form.append(`sig${i}`, new File([png], `sig${i}.png`, { type: "image/png" }));
|
||||
});
|
||||
|
||||
@@ -91,6 +91,10 @@ export function useToolProcessor(toolId: string) {
|
||||
const stallTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const activeJobIdRef = useRef<string | null>(null);
|
||||
const asyncModeRef = useRef(false);
|
||||
// Save mode captured at run start (#495). Only "overwrite" re-anchors
|
||||
// serverFileId to the saved result, so "new" keeps deriving from the
|
||||
// original library file on re-runs.
|
||||
const saveModeRef = useRef<"new" | "overwrite">("new");
|
||||
|
||||
const isAiTool = AI_PYTHON_TOOLS.has(toolId);
|
||||
const toolName = TOOLS.find((t) => t.id === toolId)?.name ?? toolId;
|
||||
@@ -180,6 +184,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const result = data.result as ProcessResult;
|
||||
setWarning(result.warning ?? null);
|
||||
setResultPayload(result as unknown as Record<string, unknown>);
|
||||
if (result.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
|
||||
}
|
||||
const idx = useFileStore.getState().selectedIndex;
|
||||
useFileStore.getState().updateEntry(idx, {
|
||||
processedUrl: result.downloadUrl,
|
||||
@@ -188,7 +195,9 @@ export function useToolProcessor(toolId: string) {
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
...(result.savedFileId && saveModeRef.current === "overwrite"
|
||||
? { serverFileId: result.savedFileId }
|
||||
: {}),
|
||||
});
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
@@ -275,6 +284,7 @@ export function useToolProcessor(toolId: string) {
|
||||
setError(null);
|
||||
setWarning(null);
|
||||
setResultPayload(null);
|
||||
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: null,
|
||||
processedPreviewUrl: null,
|
||||
@@ -353,6 +363,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const result = data.result as ProcessResult;
|
||||
setWarning(result.warning ?? null);
|
||||
setResultPayload(result as unknown as Record<string, unknown>);
|
||||
if (result.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
|
||||
}
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: result.downloadUrl,
|
||||
processedPreviewUrl: result.previewUrl ?? null,
|
||||
@@ -360,7 +373,9 @@ export function useToolProcessor(toolId: string) {
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
...(result.savedFileId && saveModeRef.current === "overwrite"
|
||||
? { serverFileId: result.savedFileId }
|
||||
: {}),
|
||||
});
|
||||
setProcessing(false);
|
||||
setProgress(IDLE_PROGRESS);
|
||||
@@ -421,8 +436,10 @@ export function useToolProcessor(toolId: string) {
|
||||
formData.append("clientJobId", clientJobId);
|
||||
|
||||
const capturedEntry = useFileStore.getState().entries[capturedIndex];
|
||||
saveModeRef.current = useFileStore.getState().librarySaveMode;
|
||||
if (capturedEntry?.serverFileId) {
|
||||
formData.append("fileId", capturedEntry.serverFileId);
|
||||
formData.append("saveMode", saveModeRef.current);
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
@@ -469,6 +486,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const result: ProcessResult = JSON.parse(xhr.responseText);
|
||||
setWarning(result.warning ?? null);
|
||||
setResultPayload(result as unknown as Record<string, unknown>);
|
||||
if (result.savedFileId) {
|
||||
useFileStore.getState().setLastSavedLibraryFileId(result.savedFileId);
|
||||
}
|
||||
useFileStore.getState().updateEntry(capturedIndex, {
|
||||
processedUrl: result.downloadUrl,
|
||||
processedPreviewUrl: result.previewUrl ?? null,
|
||||
@@ -476,7 +496,9 @@ export function useToolProcessor(toolId: string) {
|
||||
status: "completed",
|
||||
originalSize: result.originalSize,
|
||||
processedSize: result.processedSize,
|
||||
...(result.savedFileId ? { serverFileId: result.savedFileId } : {}),
|
||||
...(result.savedFileId && saveModeRef.current === "overwrite"
|
||||
? { serverFileId: result.savedFileId }
|
||||
: {}),
|
||||
});
|
||||
} catch {
|
||||
setError("Invalid response from server");
|
||||
@@ -580,6 +602,9 @@ export function useToolProcessor(toolId: string) {
|
||||
const { updateEntry, setBatchZip } = useFileStore.getState();
|
||||
|
||||
setError(null);
|
||||
// Batch runs never auto-save to the library (no fileId is sent), so a
|
||||
// previous single run's saved indicator must not survive into this one.
|
||||
useFileStore.getState().setLastSavedLibraryFileId(null);
|
||||
setProcessing(true);
|
||||
setProgress({ phase: "uploading", percent: 0, elapsed: 0 });
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { BeforeAfterSlider } from "@/components/common/before-after-slider";
|
||||
import { BottomSheet } from "@/components/common/bottom-sheet";
|
||||
import { Dropzone } from "@/components/common/dropzone";
|
||||
import { type BgPreviewState, ImageViewer } from "@/components/common/image-viewer";
|
||||
import { LibrarySaveModeSelector } from "@/components/common/library-save-mode-selector";
|
||||
import { ReviewPanel } from "@/components/common/review-panel";
|
||||
import { SideBySideComparison } from "@/components/common/side-by-side-comparison";
|
||||
import { ThumbnailStrip } from "@/components/common/thumbnail-strip";
|
||||
@@ -303,6 +304,7 @@ export function ToolPage() {
|
||||
navigateNext,
|
||||
navigatePrev,
|
||||
currentEntry,
|
||||
lastSavedLibraryFileId,
|
||||
} = useFileStore();
|
||||
const isMobile = useMobile();
|
||||
const hasMultiple = entries.length > 1;
|
||||
@@ -1115,6 +1117,8 @@ export function ToolPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LibrarySaveModeSelector toolId={tool?.id ?? ""} />
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -1161,6 +1165,7 @@ export function ToolPage() {
|
||||
totalCount={batchTotal}
|
||||
successCount={batchSuccess}
|
||||
failedCount={batchFailed}
|
||||
savedLibraryFileId={lastSavedLibraryFileId}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { ANALYTICS_EVENTS, detectModalityFromMime, type Modality } from "@snapotter/shared";
|
||||
import {
|
||||
ANALYTICS_EVENTS,
|
||||
detectModalityFromMime,
|
||||
type LibrarySaveMode,
|
||||
type Modality,
|
||||
} from "@snapotter/shared";
|
||||
import { create } from "zustand";
|
||||
import { fetchDecodedPreview, needsServerPreview } from "@/lib/image-preview";
|
||||
|
||||
@@ -118,6 +123,10 @@ interface FileState {
|
||||
error: string | null;
|
||||
activeJobId: string | null;
|
||||
cancelCurrentJob: (() => Promise<void>) | null;
|
||||
/** How library-sourced results are saved (#495): "new" keeps the original. */
|
||||
librarySaveMode: LibrarySaveMode;
|
||||
/** Library file id of the last run's auto-saved result, for the review UI. */
|
||||
lastSavedLibraryFileId: string | null;
|
||||
|
||||
// Derived from entries (selected entry fields)
|
||||
readonly files: File[];
|
||||
@@ -142,6 +151,8 @@ interface FileState {
|
||||
setProcessing: (v: boolean) => void;
|
||||
setError: (e: string | null) => void;
|
||||
setActiveJob: (id: string | null, cancelFn: (() => Promise<void>) | null) => void;
|
||||
setLibrarySaveMode: (mode: LibrarySaveMode) => void;
|
||||
setLastSavedLibraryFileId: (id: string | null) => void;
|
||||
setJobId: (id: string) => void;
|
||||
setProcessedUrl: (url: string | null, previewUrl?: string | null) => void;
|
||||
setSizes: (original: number, processed: number) => void;
|
||||
@@ -158,6 +169,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
error: null,
|
||||
activeJobId: null,
|
||||
cancelCurrentJob: null,
|
||||
librarySaveMode: "new",
|
||||
lastSavedLibraryFileId: null,
|
||||
|
||||
// Initial derived values (empty state)
|
||||
files: [],
|
||||
@@ -175,6 +188,9 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
entries,
|
||||
selectedIndex: 0,
|
||||
error: null,
|
||||
// A fresh file set is a fresh edit: the save-mode choice made for a
|
||||
// previous file must not carry over (#495 defaults to non-destructive).
|
||||
librarySaveMode: "new",
|
||||
files: deriveFiles(entries),
|
||||
...deriveSelected(entries, 0),
|
||||
});
|
||||
@@ -347,6 +363,10 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
set({ entries: updated, ...deriveSelected(updated, selectedIndex) });
|
||||
},
|
||||
|
||||
setLibrarySaveMode: (mode) => set({ librarySaveMode: mode }),
|
||||
|
||||
setLastSavedLibraryFileId: (id) => set({ lastSavedLibraryFileId: id }),
|
||||
|
||||
undoProcessing: () => {
|
||||
const { entries, selectedIndex } = get();
|
||||
for (const entry of entries) {
|
||||
@@ -370,6 +390,7 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
error: null,
|
||||
activeJobId: null,
|
||||
cancelCurrentJob: null,
|
||||
lastSavedLibraryFileId: null,
|
||||
files: deriveFiles(resetEntries),
|
||||
...deriveSelected(resetEntries, selectedIndex),
|
||||
});
|
||||
@@ -387,6 +408,8 @@ export const useFileStore = create<FileState>((set, get) => ({
|
||||
error: null,
|
||||
activeJobId: null,
|
||||
cancelCurrentJob: null,
|
||||
librarySaveMode: "new",
|
||||
lastSavedLibraryFileId: null,
|
||||
files: [],
|
||||
...deriveSelected([], 0),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user