mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
fix(lint): resolve #280 Lint failures (route formatting + landing import sort)
#280 left 10 section-prefixed custom-route files mis-indented and one unsorted import block in the new landing section-index page. Fixed via biome formatter (api) and manual import sort (landing). No config change (biome.json is hook-protected); no suppression. pnpm lint + typecheck now exit 0.
This commit is contained in:
@@ -90,86 +90,89 @@ registerAiJobHandler("auto-subtitles", async (input, data, ctx) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
export function registerAutoSubtitles(app: FastifyInstance) {
|
export function registerAutoSubtitles(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/video/auto-subtitles", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
const toolId = "auto-subtitles";
|
"/api/v1/tools/video/auto-subtitles",
|
||||||
if (!isToolInstalled(toolId)) {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const bundle = getBundleForTool(toolId);
|
const toolId = "auto-subtitles";
|
||||||
return reply.status(501).send({
|
if (!isToolInstalled(toolId)) {
|
||||||
error: "Feature not installed",
|
const bundle = getBundleForTool(toolId);
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
return reply.status(501).send({
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
error: "Feature not installed",
|
||||||
featureName: bundle?.name ?? toolId,
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
feature: TOOL_BUNDLE_MAP[toolId],
|
||||||
});
|
featureName: bundle?.name ?? toolId,
|
||||||
}
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
|
|
||||||
const userId = getAuthUser(request)?.id ?? null;
|
|
||||||
const jobId = randomUUID();
|
|
||||||
let filename = "video";
|
|
||||||
let settingsRaw: string | null = null;
|
|
||||||
let clientJobId: string | null = null;
|
|
||||||
let fileId: string | null = null;
|
|
||||||
let inputKey: string | null = null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parts = request.parts();
|
|
||||||
for await (const part of parts) {
|
|
||||||
if (part.type === "file") {
|
|
||||||
const upload = await receiveUpload(part, jobId);
|
|
||||||
inputKey = upload.key;
|
|
||||||
filename = upload.filename;
|
|
||||||
} else if (part.fieldname === "settings") {
|
|
||||||
settingsRaw = part.value as string;
|
|
||||||
} else if (part.fieldname === "clientJobId") {
|
|
||||||
const raw = part.value as string;
|
|
||||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
|
||||||
clientJobId = raw;
|
|
||||||
}
|
|
||||||
} else if (part.fieldname === "fileId") {
|
|
||||||
fileId = part.value as string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Failed to parse multipart request",
|
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inputKey) {
|
|
||||||
return reply.status(400).send({ error: "No video file provided" });
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings: z.infer<typeof settingsSchema>;
|
|
||||||
try {
|
|
||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Invalid settings",
|
|
||||||
details: formatZodErrors(result.error.issues),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
const userId = getAuthUser(request)?.id ?? null;
|
||||||
|
const jobId = randomUUID();
|
||||||
|
let filename = "video";
|
||||||
|
let settingsRaw: string | null = null;
|
||||||
|
let clientJobId: string | null = null;
|
||||||
|
let fileId: string | null = null;
|
||||||
|
let inputKey: string | null = null;
|
||||||
|
|
||||||
await enqueueToolJob({
|
try {
|
||||||
jobId,
|
const parts = request.parts();
|
||||||
toolId,
|
for await (const part of parts) {
|
||||||
userId,
|
if (part.type === "file") {
|
||||||
pool: "ai",
|
const upload = await receiveUpload(part, jobId);
|
||||||
inputRefs: [inputKey],
|
inputKey = upload.key;
|
||||||
filename,
|
filename = upload.filename;
|
||||||
settings,
|
} else if (part.fieldname === "settings") {
|
||||||
clientJobId: clientJobId ?? undefined,
|
settingsRaw = part.value as string;
|
||||||
fileId: fileId ?? undefined,
|
} else if (part.fieldname === "clientJobId") {
|
||||||
kind: "ai-tool",
|
const raw = part.value as string;
|
||||||
});
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
||||||
|
clientJobId = raw;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
if (!inputKey) {
|
||||||
});
|
return reply.status(400).send({ error: "No video file provided" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
|
try {
|
||||||
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
const result = settingsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Invalid settings",
|
||||||
|
details: formatZodErrors(result.error.issues),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressJobId = clientJobId || jobId;
|
||||||
|
|
||||||
|
await enqueueToolJob({
|
||||||
|
jobId,
|
||||||
|
toolId,
|
||||||
|
userId,
|
||||||
|
pool: "ai",
|
||||||
|
inputRefs: [inputKey],
|
||||||
|
filename,
|
||||||
|
settings,
|
||||||
|
clientJobId: clientJobId ?? undefined,
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
kind: "ai-tool",
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -79,189 +79,192 @@ function buildOverlaySvg(
|
|||||||
* Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm.
|
* Read barcodes (all 1D + 2D types) from uploaded images using zxing-wasm.
|
||||||
*/
|
*/
|
||||||
export function registerBarcodeRead(app: FastifyInstance) {
|
export function registerBarcodeRead(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/barcode-read", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
let fileBuffer: Buffer | null = null;
|
"/api/v1/tools/image/barcode-read",
|
||||||
let filename = "image";
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
let settingsRaw: string | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
let settingsRaw: string | null = null;
|
||||||
|
|
||||||
// --- Parse multipart ---
|
// --- Parse multipart ---
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of part.file) {
|
for await (const chunk of part.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
fileBuffer = Buffer.concat(chunks);
|
||||||
|
filename = sanitizeFilename(part.filename ?? "image");
|
||||||
|
} else if (part.fieldname === "settings") {
|
||||||
|
settingsRaw = part.value as string;
|
||||||
}
|
}
|
||||||
fileBuffer = Buffer.concat(chunks);
|
|
||||||
filename = sanitizeFilename(part.filename ?? "image");
|
|
||||||
} else if (part.fieldname === "settings") {
|
|
||||||
settingsRaw = part.value as string;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Failed to parse multipart request",
|
|
||||||
details: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileBuffer || fileBuffer.length === 0) {
|
if (!fileBuffer || fileBuffer.length === 0) {
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
|
||||||
|
|
||||||
// --- Validate ---
|
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: `Invalid image: ${validation.reason}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate settings
|
|
||||||
let settings: z.infer<typeof settingsSchema>;
|
|
||||||
try {
|
|
||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
|
||||||
}
|
}
|
||||||
settings = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// --- Validate ---
|
||||||
const tryHarder = settings.tryHarder;
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: `Invalid image: ${validation.reason}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (validation.format === "heif") {
|
// Parse and validate settings
|
||||||
try {
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
try {
|
||||||
} catch (err) {
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
return reply.status(422).send({
|
const result = settingsSchema.safeParse(parsed);
|
||||||
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
if (!result.success) {
|
||||||
details: err instanceof Error ? err.message : String(err),
|
return reply
|
||||||
});
|
.status(400)
|
||||||
|
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||||
}
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
if (needsCliDecode(validation.format)) {
|
|
||||||
try {
|
try {
|
||||||
const fileExt = filename.split(".").pop()?.toLowerCase();
|
const tryHarder = settings.tryHarder;
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
|
||||||
} catch {
|
if (validation.format === "heif") {
|
||||||
try {
|
try {
|
||||||
await sharp(fileBuffer).metadata();
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reply.status(422).send({
|
return reply.status(422).send({
|
||||||
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
error: "Failed to decode HEIC file. Ensure libheif-examples is installed.",
|
||||||
details: err instanceof Error ? err.message : String(err),
|
details: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
if (needsCliDecode(validation.format)) {
|
||||||
if (validation.format === "svg") {
|
try {
|
||||||
try {
|
const fileExt = filename.split(".").pop()?.toLowerCase();
|
||||||
fileBuffer = decompressSvgz(fileBuffer);
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format, fileExt);
|
||||||
fileBuffer = sanitizeSvg(fileBuffer);
|
} catch {
|
||||||
} catch (err) {
|
try {
|
||||||
return reply.status(400).send({
|
await sharp(fileBuffer).metadata();
|
||||||
error: err instanceof Error ? err.message : "Invalid SVG",
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: `Failed to decode ${validation.format.toUpperCase()} file`,
|
||||||
|
details: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (validation.format === "svg") {
|
||||||
|
try {
|
||||||
|
fileBuffer = decompressSvgz(fileBuffer);
|
||||||
|
fileBuffer = sanitizeSvg(fileBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: err instanceof Error ? err.message : "Invalid SVG",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
|
||||||
|
// Convert to raw RGBA pixel data
|
||||||
|
const image = sharp(fileBuffer);
|
||||||
|
const metadata = await image.metadata();
|
||||||
|
const width = metadata.width ?? 0;
|
||||||
|
const height = metadata.height ?? 0;
|
||||||
|
|
||||||
|
if (width === 0 || height === 0) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Could not determine image dimensions",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
|
||||||
|
|
||||||
// Convert to raw RGBA pixel data
|
const rawData = await image.ensureAlpha().raw().toBuffer();
|
||||||
const image = sharp(fileBuffer);
|
|
||||||
const metadata = await image.metadata();
|
|
||||||
const width = metadata.width ?? 0;
|
|
||||||
const height = metadata.height ?? 0;
|
|
||||||
|
|
||||||
if (width === 0 || height === 0) {
|
// --- Detect barcodes via zxing-wasm ---
|
||||||
return reply.status(422).send({
|
const imageData = {
|
||||||
error: "Could not determine image dimensions",
|
data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
} as ImageData;
|
||||||
|
|
||||||
|
const results = await readBarcodes(imageData, {
|
||||||
|
tryHarder,
|
||||||
|
maxNumberOfSymbols: 255,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const rawData = await image.ensureAlpha().raw().toBuffer();
|
const validResults = results.filter((r) => r.isValid);
|
||||||
|
|
||||||
// --- Detect barcodes via zxing-wasm ---
|
// Map to the response shape
|
||||||
const imageData = {
|
const barcodes = validResults.map((r) => ({
|
||||||
data: new Uint8ClampedArray(rawData.buffer, rawData.byteOffset, rawData.length),
|
type: r.format,
|
||||||
width,
|
text: r.text,
|
||||||
height,
|
position: {
|
||||||
} as ImageData;
|
topLeft: { x: r.position.topLeft.x, y: r.position.topLeft.y },
|
||||||
|
topRight: { x: r.position.topRight.x, y: r.position.topRight.y },
|
||||||
const results = await readBarcodes(imageData, {
|
bottomLeft: {
|
||||||
tryHarder,
|
x: r.position.bottomLeft.x,
|
||||||
maxNumberOfSymbols: 255,
|
y: r.position.bottomLeft.y,
|
||||||
});
|
},
|
||||||
|
bottomRight: {
|
||||||
const validResults = results.filter((r) => r.isValid);
|
x: r.position.bottomRight.x,
|
||||||
|
y: r.position.bottomRight.y,
|
||||||
// Map to the response shape
|
},
|
||||||
const barcodes = validResults.map((r) => ({
|
|
||||||
type: r.format,
|
|
||||||
text: r.text,
|
|
||||||
position: {
|
|
||||||
topLeft: { x: r.position.topLeft.x, y: r.position.topLeft.y },
|
|
||||||
topRight: { x: r.position.topRight.x, y: r.position.topRight.y },
|
|
||||||
bottomLeft: {
|
|
||||||
x: r.position.bottomLeft.x,
|
|
||||||
y: r.position.bottomLeft.y,
|
|
||||||
},
|
},
|
||||||
bottomRight: {
|
}));
|
||||||
x: r.position.bottomRight.x,
|
|
||||||
y: r.position.bottomRight.y,
|
// No barcodes found - return early
|
||||||
},
|
if (barcodes.length === 0) {
|
||||||
},
|
return reply.send({
|
||||||
}));
|
filename,
|
||||||
|
barcodes: [],
|
||||||
|
annotatedUrl: null,
|
||||||
|
previewUrl: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Generate annotated image ---
|
||||||
|
const jobId = randomUUID();
|
||||||
|
|
||||||
|
// Save original input
|
||||||
|
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
|
||||||
|
|
||||||
|
// Build SVG overlay with bounding boxes
|
||||||
|
const overlaySvg = buildOverlaySvg(width, height, barcodes);
|
||||||
|
|
||||||
|
const stem = filename.replace(/\.[^.]+$/, "");
|
||||||
|
const outputFilename = `annotated-${stem}.png`;
|
||||||
|
|
||||||
|
const annotatedBuffer = await sharp(fileBuffer)
|
||||||
|
.composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
await putObject(`outputs/${jobId}/${outputFilename}`, annotatedBuffer);
|
||||||
|
|
||||||
|
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
|
||||||
|
|
||||||
// No barcodes found - return early
|
|
||||||
if (barcodes.length === 0) {
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
filename,
|
filename,
|
||||||
barcodes: [],
|
barcodes,
|
||||||
annotatedUrl: null,
|
annotatedUrl: downloadUrl,
|
||||||
previewUrl: null,
|
previewUrl: downloadUrl,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "barcode-read" }, "Barcode read failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Barcode reading failed",
|
||||||
|
details: err instanceof Error ? err.message : "Unknown error",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// --- Generate annotated image ---
|
);
|
||||||
const jobId = randomUUID();
|
|
||||||
|
|
||||||
// Save original input
|
|
||||||
await putObject(`uploads/${jobId}/${filename}`, fileBuffer);
|
|
||||||
|
|
||||||
// Build SVG overlay with bounding boxes
|
|
||||||
const overlaySvg = buildOverlaySvg(width, height, barcodes);
|
|
||||||
|
|
||||||
const stem = filename.replace(/\.[^.]+$/, "");
|
|
||||||
const outputFilename = `annotated-${stem}.png`;
|
|
||||||
|
|
||||||
const annotatedBuffer = await sharp(fileBuffer)
|
|
||||||
.composite([{ input: Buffer.from(overlaySvg), top: 0, left: 0 }])
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
await putObject(`outputs/${jobId}/${outputFilename}`, annotatedBuffer);
|
|
||||||
|
|
||||||
const downloadUrl = `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`;
|
|
||||||
|
|
||||||
return reply.send({
|
|
||||||
filename,
|
|
||||||
barcodes,
|
|
||||||
annotatedUrl: downloadUrl,
|
|
||||||
previewUrl: downloadUrl,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
request.log.error({ err, toolId: "barcode-read" }, "Barcode read failed");
|
|
||||||
return reply.status(422).send({
|
|
||||||
error: "Barcode reading failed",
|
|
||||||
details: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,119 +65,122 @@ registerAiJobHandler("blur-faces", async (input, data, ctx) => {
|
|||||||
|
|
||||||
/** Face detection and blurring route. */
|
/** Face detection and blurring route. */
|
||||||
export function registerBlurFaces(app: FastifyInstance) {
|
export function registerBlurFaces(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/blur-faces", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
const toolId = "blur-faces";
|
"/api/v1/tools/image/blur-faces",
|
||||||
if (!isToolInstalled(toolId)) {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const bundle = getBundleForTool(toolId);
|
const toolId = "blur-faces";
|
||||||
return reply.status(501).send({
|
if (!isToolInstalled(toolId)) {
|
||||||
error: "Feature not installed",
|
const bundle = getBundleForTool(toolId);
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
return reply.status(501).send({
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
error: "Feature not installed",
|
||||||
featureName: bundle?.name ?? toolId,
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
feature: TOOL_BUNDLE_MAP[toolId],
|
||||||
});
|
featureName: bundle?.name ?? toolId,
|
||||||
}
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userId = getAuthUser(request)?.id ?? null;
|
const userId = getAuthUser(request)?.id ?? null;
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let clientJobId: string | null = null;
|
let clientJobId: string | null = null;
|
||||||
let fileId: string | null = null;
|
let fileId: string | null = null;
|
||||||
let inputKey: string | null = null;
|
let inputKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const upload = await receiveUpload(part, jobId);
|
const upload = await receiveUpload(part, jobId);
|
||||||
inputKey = upload.key;
|
inputKey = upload.key;
|
||||||
filename = upload.filename;
|
filename = upload.filename;
|
||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} else if (part.fieldname === "clientJobId") {
|
||||||
const raw = part.value as string;
|
const raw = part.value as string;
|
||||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
||||||
clientJobId = raw;
|
clientJobId = raw;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "fileId") {
|
|
||||||
fileId = part.value as string;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
if (!inputKey) {
|
||||||
error: "Failed to parse multipart request",
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
}
|
||||||
|
|
||||||
|
fileBuffer = await getObjectBuffer(inputKey);
|
||||||
|
|
||||||
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
|
try {
|
||||||
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
const result = settingsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
return reply
|
||||||
|
.status(400)
|
||||||
|
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||||
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (validation.format === "heif") {
|
||||||
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
|
}
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Face blur failed",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedKey = `uploads/${jobId}/${filename}`;
|
||||||
|
if (decodedKey !== inputKey) {
|
||||||
|
await putObject(decodedKey, fileBuffer);
|
||||||
|
inputKey = decodedKey;
|
||||||
|
} else {
|
||||||
|
await putObject(inputKey, fileBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressJobId = clientJobId || jobId;
|
||||||
|
|
||||||
|
await enqueueToolJob({
|
||||||
|
jobId,
|
||||||
|
toolId,
|
||||||
|
userId,
|
||||||
|
pool: "ai",
|
||||||
|
inputRefs: [inputKey],
|
||||||
|
filename,
|
||||||
|
settings,
|
||||||
|
clientJobId: clientJobId ?? undefined,
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!inputKey) {
|
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
fileBuffer = await getObjectBuffer(inputKey);
|
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings: z.infer<typeof settingsSchema>;
|
|
||||||
try {
|
|
||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
|
||||||
}
|
|
||||||
settings = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (validation.format === "heif") {
|
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
|
||||||
}
|
|
||||||
if (needsCliDecode(validation.format)) {
|
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
|
||||||
}
|
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
|
||||||
} catch (err) {
|
|
||||||
request.log.error({ err, toolId: "blur-faces" }, "Input decoding failed");
|
|
||||||
return reply.status(422).send({
|
|
||||||
error: "Face blur failed",
|
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedKey = `uploads/${jobId}/${filename}`;
|
|
||||||
if (decodedKey !== inputKey) {
|
|
||||||
await putObject(decodedKey, fileBuffer);
|
|
||||||
inputKey = decodedKey;
|
|
||||||
} else {
|
|
||||||
await putObject(inputKey, fileBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
|
||||||
jobId,
|
|
||||||
toolId,
|
|
||||||
userId,
|
|
||||||
pool: "ai",
|
|
||||||
inputRefs: [inputKey],
|
|
||||||
filename,
|
|
||||||
settings,
|
|
||||||
clientJobId: clientJobId ?? undefined,
|
|
||||||
fileId: fileId ?? undefined,
|
|
||||||
kind: "ai-tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register in the pipeline/batch registry
|
// Register in the pipeline/batch registry
|
||||||
registerToolProcessFn({
|
registerToolProcessFn({
|
||||||
|
|||||||
@@ -109,107 +109,110 @@ export function registerEditMetadata(app: FastifyInstance) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Edit endpoint - writes metadata in-place using ExifTool (no pixel re-encoding)
|
// Edit endpoint - writes metadata in-place using ExifTool (no pixel re-encoding)
|
||||||
app.post("/api/v1/tools/image/edit-metadata", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
let fileBuffer: Buffer | null = null;
|
"/api/v1/tools/image/edit-metadata",
|
||||||
let filename = "image";
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
let settingsRaw: string | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
|
let filename = "image";
|
||||||
|
let settingsRaw: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of part.file) {
|
for await (const chunk of part.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
fileBuffer = Buffer.concat(chunks);
|
||||||
|
filename = sanitizeFilename(part.filename ?? "image");
|
||||||
|
} else if (part.fieldname === "settings") {
|
||||||
|
settingsRaw = part.value as string;
|
||||||
}
|
}
|
||||||
fileBuffer = Buffer.concat(chunks);
|
|
||||||
filename = sanitizeFilename(part.filename ?? "image");
|
|
||||||
} else if (part.fieldname === "settings") {
|
|
||||||
settingsRaw = part.value as string;
|
|
||||||
}
|
}
|
||||||
}
|
} catch (err) {
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Failed to parse multipart request",
|
|
||||||
details: err instanceof Error ? err.message : String(err),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileBuffer || fileBuffer.length === 0) {
|
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
|
||||||
}
|
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse and validate settings
|
|
||||||
let settings: Settings;
|
|
||||||
try {
|
|
||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply.status(400).send({
|
return reply.status(400).send({
|
||||||
error: "Invalid settings",
|
error: "Failed to parse multipart request",
|
||||||
details: formatZodErrors(result.error.issues),
|
details: err instanceof Error ? err.message : String(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
settings = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (!fileBuffer || fileBuffer.length === 0) {
|
||||||
// Build ExifTool arguments from settings
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
const tags = buildTagArgs(settings as EditMetadataSettings);
|
|
||||||
|
|
||||||
// If no changes requested, return the original buffer
|
|
||||||
let outputBuffer: Buffer;
|
|
||||||
if (tags.length === 0) {
|
|
||||||
outputBuffer = fileBuffer;
|
|
||||||
} else {
|
|
||||||
outputBuffer = await writeMetadata(fileBuffer, filename, tags);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine content type from validated format
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
// Save output to object storage
|
// Parse and validate settings
|
||||||
const jobId = randomUUID();
|
let settings: Settings;
|
||||||
await putObject(`outputs/${jobId}/${filename}`, outputBuffer);
|
try {
|
||||||
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
// Generate preview for non-browser-previewable formats (HEIF, TIFF)
|
const result = settingsSchema.safeParse(parsed);
|
||||||
let previewUrl: string | undefined;
|
if (!result.success) {
|
||||||
if (!BROWSER_PREVIEWABLE.has(contentType)) {
|
return reply.status(400).send({
|
||||||
try {
|
error: "Invalid settings",
|
||||||
let previewInput = outputBuffer;
|
details: formatZodErrors(result.error.issues),
|
||||||
if (contentType === "image/heif" || contentType === "image/heic") {
|
});
|
||||||
previewInput = await decodeHeic(outputBuffer);
|
|
||||||
}
|
|
||||||
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
|
|
||||||
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
|
|
||||||
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
|
||||||
} catch {
|
|
||||||
// Non-fatal - frontend shows fallback
|
|
||||||
}
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
|
|
||||||
return reply.send({
|
try {
|
||||||
jobId,
|
// Build ExifTool arguments from settings
|
||||||
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
|
const tags = buildTagArgs(settings as EditMetadataSettings);
|
||||||
previewUrl,
|
|
||||||
originalSize: fileBuffer.length,
|
// If no changes requested, return the original buffer
|
||||||
processedSize: outputBuffer.length,
|
let outputBuffer: Buffer;
|
||||||
});
|
if (tags.length === 0) {
|
||||||
} catch (err) {
|
outputBuffer = fileBuffer;
|
||||||
request.log.error({ err, toolId: "edit-metadata" }, "Metadata edit failed");
|
} else {
|
||||||
return reply.status(422).send({
|
outputBuffer = await writeMetadata(fileBuffer, filename, tags);
|
||||||
error: "Metadata edit failed",
|
}
|
||||||
details: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
});
|
// Determine content type from validated format
|
||||||
}
|
const contentType = MIME_BY_FORMAT[validation.format] ?? "image/jpeg";
|
||||||
});
|
|
||||||
|
// Save output to object storage
|
||||||
|
const jobId = randomUUID();
|
||||||
|
await putObject(`outputs/${jobId}/${filename}`, outputBuffer);
|
||||||
|
|
||||||
|
// Generate preview for non-browser-previewable formats (HEIF, TIFF)
|
||||||
|
let previewUrl: string | undefined;
|
||||||
|
if (!BROWSER_PREVIEWABLE.has(contentType)) {
|
||||||
|
try {
|
||||||
|
let previewInput = outputBuffer;
|
||||||
|
if (contentType === "image/heif" || contentType === "image/heic") {
|
||||||
|
previewInput = await decodeHeic(outputBuffer);
|
||||||
|
}
|
||||||
|
const previewBuffer = await sharp(previewInput).webp({ quality: 80 }).toBuffer();
|
||||||
|
await putObject(`outputs/${jobId}/preview.webp`, previewBuffer);
|
||||||
|
previewUrl = `/api/v1/download/${jobId}/preview.webp`;
|
||||||
|
} catch {
|
||||||
|
// Non-fatal - frontend shows fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
jobId,
|
||||||
|
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(filename)}`,
|
||||||
|
previewUrl,
|
||||||
|
originalSize: fileBuffer.length,
|
||||||
|
processedSize: outputBuffer.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "edit-metadata" }, "Metadata edit failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Metadata edit failed",
|
||||||
|
details: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Register in pipeline/batch registry
|
// Register in pipeline/batch registry
|
||||||
registerToolProcessFn({
|
registerToolProcessFn({
|
||||||
|
|||||||
@@ -58,119 +58,122 @@ registerAiJobHandler("enhance-faces", async (input, data, ctx) => {
|
|||||||
|
|
||||||
/** Face enhancement route using GFPGAN/CodeFormer. */
|
/** Face enhancement route using GFPGAN/CodeFormer. */
|
||||||
export function registerEnhanceFaces(app: FastifyInstance) {
|
export function registerEnhanceFaces(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/enhance-faces", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
const toolId = "enhance-faces";
|
"/api/v1/tools/image/enhance-faces",
|
||||||
if (!isToolInstalled(toolId)) {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const bundle = getBundleForTool(toolId);
|
const toolId = "enhance-faces";
|
||||||
return reply.status(501).send({
|
if (!isToolInstalled(toolId)) {
|
||||||
error: "Feature not installed",
|
const bundle = getBundleForTool(toolId);
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
return reply.status(501).send({
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
error: "Feature not installed",
|
||||||
featureName: bundle?.name ?? toolId,
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
feature: TOOL_BUNDLE_MAP[toolId],
|
||||||
});
|
featureName: bundle?.name ?? toolId,
|
||||||
}
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userId = getAuthUser(request)?.id ?? null;
|
const userId = getAuthUser(request)?.id ?? null;
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let clientJobId: string | null = null;
|
let clientJobId: string | null = null;
|
||||||
let fileId: string | null = null;
|
let fileId: string | null = null;
|
||||||
let inputKey: string | null = null;
|
let inputKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const upload = await receiveUpload(part, jobId);
|
const upload = await receiveUpload(part, jobId);
|
||||||
inputKey = upload.key;
|
inputKey = upload.key;
|
||||||
filename = upload.filename;
|
filename = upload.filename;
|
||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} else if (part.fieldname === "clientJobId") {
|
||||||
const raw = part.value as string;
|
const raw = part.value as string;
|
||||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
||||||
clientJobId = raw;
|
clientJobId = raw;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "fileId") {
|
|
||||||
fileId = part.value as string;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
if (!inputKey) {
|
||||||
error: "Failed to parse multipart request",
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
}
|
||||||
|
|
||||||
|
fileBuffer = await getObjectBuffer(inputKey);
|
||||||
|
|
||||||
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
|
try {
|
||||||
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
const result = settingsSchema.safeParse(parsed);
|
||||||
|
if (!result.success) {
|
||||||
|
return reply
|
||||||
|
.status(400)
|
||||||
|
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||||
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (validation.format === "heif") {
|
||||||
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
|
}
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Face enhancement failed",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedKey = `uploads/${jobId}/${filename}`;
|
||||||
|
if (decodedKey !== inputKey) {
|
||||||
|
await putObject(decodedKey, fileBuffer);
|
||||||
|
inputKey = decodedKey;
|
||||||
|
} else {
|
||||||
|
await putObject(inputKey, fileBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressJobId = clientJobId || jobId;
|
||||||
|
|
||||||
|
await enqueueToolJob({
|
||||||
|
jobId,
|
||||||
|
toolId,
|
||||||
|
userId,
|
||||||
|
pool: "ai",
|
||||||
|
inputRefs: [inputKey],
|
||||||
|
filename,
|
||||||
|
settings,
|
||||||
|
clientJobId: clientJobId ?? undefined,
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!inputKey) {
|
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
fileBuffer = await getObjectBuffer(inputKey);
|
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings: z.infer<typeof settingsSchema>;
|
|
||||||
try {
|
|
||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
|
||||||
}
|
|
||||||
settings = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (validation.format === "heif") {
|
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
|
||||||
}
|
|
||||||
if (needsCliDecode(validation.format)) {
|
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
|
||||||
}
|
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
|
||||||
} catch (err) {
|
|
||||||
request.log.error({ err, toolId: "enhance-faces" }, "Input decoding failed");
|
|
||||||
return reply.status(422).send({
|
|
||||||
error: "Face enhancement failed",
|
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedKey = `uploads/${jobId}/${filename}`;
|
|
||||||
if (decodedKey !== inputKey) {
|
|
||||||
await putObject(decodedKey, fileBuffer);
|
|
||||||
inputKey = decodedKey;
|
|
||||||
} else {
|
|
||||||
await putObject(inputKey, fileBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
|
||||||
jobId,
|
|
||||||
toolId,
|
|
||||||
userId,
|
|
||||||
pool: "ai",
|
|
||||||
inputRefs: [inputKey],
|
|
||||||
filename,
|
|
||||||
settings,
|
|
||||||
clientJobId: clientJobId ?? undefined,
|
|
||||||
fileId: fileId ?? undefined,
|
|
||||||
kind: "ai-tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register in the pipeline/batch registry
|
// Register in the pipeline/batch registry
|
||||||
registerToolProcessFn({
|
registerToolProcessFn({
|
||||||
|
|||||||
@@ -33,147 +33,150 @@ const settingsSchema = z.object({
|
|||||||
* via getObjectBuffer(data.inputRefs[1]) inside the handler.
|
* via getObjectBuffer(data.inputRefs[1]) inside the handler.
|
||||||
*/
|
*/
|
||||||
export function registerEraseObject(app: FastifyInstance) {
|
export function registerEraseObject(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/erase-object", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
const toolId = "erase-object";
|
"/api/v1/tools/image/erase-object",
|
||||||
if (!isToolInstalled(toolId)) {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const bundle = getBundleForTool(toolId);
|
const toolId = "erase-object";
|
||||||
return reply.status(501).send({
|
if (!isToolInstalled(toolId)) {
|
||||||
error: "Feature not installed",
|
const bundle = getBundleForTool(toolId);
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
return reply.status(501).send({
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
error: "Feature not installed",
|
||||||
featureName: bundle?.name ?? toolId,
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
feature: TOOL_BUNDLE_MAP[toolId],
|
||||||
});
|
featureName: bundle?.name ?? toolId,
|
||||||
}
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userId = getAuthUser(request)?.id ?? null;
|
const userId = getAuthUser(request)?.id ?? null;
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
let imageBuffer: Buffer | null = null;
|
let imageBuffer: Buffer | null = null;
|
||||||
let maskBuffer: Buffer | null = null;
|
let maskBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let clientJobId: string | null = null;
|
let clientJobId: string | null = null;
|
||||||
let fileId: string | null = null;
|
let fileId: string | null = null;
|
||||||
let format = "png";
|
let format = "png";
|
||||||
let quality = 95;
|
let quality = 95;
|
||||||
let imageKey: string | null = null;
|
let imageKey: string | null = null;
|
||||||
let maskKey: string | null = null;
|
let maskKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
if (part.fieldname === "mask") {
|
if (part.fieldname === "mask") {
|
||||||
const upload = await receiveUpload(part, jobId);
|
const upload = await receiveUpload(part, jobId);
|
||||||
maskKey = upload.key;
|
maskKey = upload.key;
|
||||||
} else {
|
} else {
|
||||||
const upload = await receiveUpload(part, jobId);
|
const upload = await receiveUpload(part, jobId);
|
||||||
imageKey = upload.key;
|
imageKey = upload.key;
|
||||||
filename = upload.filename;
|
filename = upload.filename;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "clientJobId") {
|
||||||
|
const raw = part.value as string;
|
||||||
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
||||||
|
clientJobId = raw;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
|
} else if (part.fieldname === "format") {
|
||||||
|
format = (part.value as string) || "png";
|
||||||
|
} else if (part.fieldname === "quality") {
|
||||||
|
quality = Number(part.value) || 95;
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "clientJobId") {
|
|
||||||
const raw = part.value as string;
|
|
||||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
|
||||||
clientJobId = raw;
|
|
||||||
}
|
|
||||||
} else if (part.fieldname === "fileId") {
|
|
||||||
fileId = part.value as string;
|
|
||||||
} else if (part.fieldname === "format") {
|
|
||||||
format = (part.value as string) || "png";
|
|
||||||
} else if (part.fieldname === "quality") {
|
|
||||||
quality = Number(part.value) || 95;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Failed to parse multipart request",
|
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!imageKey) {
|
if (!imageKey) {
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
|
||||||
if (!maskKey) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
imageBuffer = await getObjectBuffer(imageKey);
|
|
||||||
maskBuffer = await getObjectBuffer(maskKey);
|
|
||||||
|
|
||||||
const imageValidation = await validateImageBuffer(imageBuffer, filename);
|
|
||||||
if (!imageValidation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
|
|
||||||
}
|
|
||||||
const maskValidation = await validateImageBuffer(maskBuffer, "mask.png");
|
|
||||||
if (!maskValidation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate format and quality via Zod
|
|
||||||
const settingsResult = settingsSchema.safeParse({ format, quality });
|
|
||||||
if (!settingsResult.success) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Invalid settings",
|
|
||||||
details: settingsResult.error.issues
|
|
||||||
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
|
|
||||||
.join("; "),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
format = settingsResult.data.format;
|
|
||||||
quality = settingsResult.data.quality;
|
|
||||||
|
|
||||||
if (format === "auto") {
|
|
||||||
const detected = await resolveOutputFormat(imageBuffer, filename);
|
|
||||||
format = detected.format === "jpeg" ? "jpg" : detected.format;
|
|
||||||
quality = detected.quality;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (imageValidation.format === "heif") {
|
|
||||||
imageBuffer = await decodeHeic(imageBuffer);
|
|
||||||
}
|
}
|
||||||
if (needsCliDecode(imageValidation.format)) {
|
if (!maskKey) {
|
||||||
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format);
|
return reply.status(400).send({
|
||||||
|
error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
imageBuffer = await autoOrient(imageBuffer);
|
|
||||||
} catch (err) {
|
imageBuffer = await getObjectBuffer(imageKey);
|
||||||
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed");
|
maskBuffer = await getObjectBuffer(maskKey);
|
||||||
return reply.status(422).send({
|
|
||||||
error: "Object erasing failed",
|
const imageValidation = await validateImageBuffer(imageBuffer, filename);
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
if (!imageValidation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${imageValidation.reason}` });
|
||||||
|
}
|
||||||
|
const maskValidation = await validateImageBuffer(maskBuffer, "mask.png");
|
||||||
|
if (!maskValidation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid mask: ${maskValidation.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate format and quality via Zod
|
||||||
|
const settingsResult = settingsSchema.safeParse({ format, quality });
|
||||||
|
if (!settingsResult.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Invalid settings",
|
||||||
|
details: settingsResult.error.issues
|
||||||
|
.map((i) => (i.path.length > 0 ? `${i.path.join(".")}: ${i.message}` : i.message))
|
||||||
|
.join("; "),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
format = settingsResult.data.format;
|
||||||
|
quality = settingsResult.data.quality;
|
||||||
|
|
||||||
|
if (format === "auto") {
|
||||||
|
const detected = await resolveOutputFormat(imageBuffer, filename);
|
||||||
|
format = detected.format === "jpeg" ? "jpg" : detected.format;
|
||||||
|
quality = detected.quality;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (imageValidation.format === "heif") {
|
||||||
|
imageBuffer = await decodeHeic(imageBuffer);
|
||||||
|
}
|
||||||
|
if (needsCliDecode(imageValidation.format)) {
|
||||||
|
imageBuffer = await decodeToSharpCompat(imageBuffer, imageValidation.format);
|
||||||
|
}
|
||||||
|
imageBuffer = await autoOrient(imageBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "erase-object" }, "Input decoding failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Object erasing failed",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write decoded image for the worker
|
||||||
|
const decodedKey = `uploads/${jobId}/${filename}`;
|
||||||
|
if (decodedKey !== imageKey) {
|
||||||
|
await putObject(decodedKey, imageBuffer);
|
||||||
|
imageKey = decodedKey;
|
||||||
|
} else {
|
||||||
|
await putObject(imageKey, imageBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressJobId = clientJobId || jobId;
|
||||||
|
|
||||||
|
// Enqueue with both image and mask as inputRefs; the worker handler
|
||||||
|
// reads them via getObjectBuffer.
|
||||||
|
await enqueueToolJob({
|
||||||
|
jobId,
|
||||||
|
toolId,
|
||||||
|
userId,
|
||||||
|
pool: "ai",
|
||||||
|
inputRefs: [imageKey, maskKey],
|
||||||
|
filename,
|
||||||
|
settings: { format, quality },
|
||||||
|
clientJobId: clientJobId ?? undefined,
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
// Write decoded image for the worker
|
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
const decodedKey = `uploads/${jobId}/${filename}`;
|
},
|
||||||
if (decodedKey !== imageKey) {
|
);
|
||||||
await putObject(decodedKey, imageBuffer);
|
|
||||||
imageKey = decodedKey;
|
|
||||||
} else {
|
|
||||||
await putObject(imageKey, imageBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
// Enqueue with both image and mask as inputRefs; the worker handler
|
|
||||||
// reads them via getObjectBuffer.
|
|
||||||
await enqueueToolJob({
|
|
||||||
jobId,
|
|
||||||
toolId,
|
|
||||||
userId,
|
|
||||||
pool: "ai",
|
|
||||||
inputRefs: [imageKey, maskKey],
|
|
||||||
filename,
|
|
||||||
settings: { format, quality },
|
|
||||||
clientJobId: clientJobId ?? undefined,
|
|
||||||
fileId: fileId ?? undefined,
|
|
||||||
kind: "ai-tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── AI job handler (separate import for the worker) ───────────────
|
// ── AI job handler (separate import for the worker) ───────────────
|
||||||
|
|||||||
@@ -98,46 +98,49 @@ const settingsSchema = z.object({
|
|||||||
|
|
||||||
export function registerGifTools(app: FastifyInstance) {
|
export function registerGifTools(app: FastifyInstance) {
|
||||||
// ── Metadata endpoint ───────────────────────────────────────────
|
// ── Metadata endpoint ───────────────────────────────────────────
|
||||||
app.post("/api/v1/tools/image/gif-tools/info", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
let fileBuffer: Buffer | null = null;
|
"/api/v1/tools/image/gif-tools/info",
|
||||||
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
|
let fileBuffer: Buffer | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
for await (const chunk of part.file) {
|
for await (const chunk of part.file) {
|
||||||
chunks.push(chunk);
|
chunks.push(chunk);
|
||||||
|
}
|
||||||
|
fileBuffer = Buffer.concat(chunks);
|
||||||
}
|
}
|
||||||
fileBuffer = Buffer.concat(chunks);
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Failed to parse request" });
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Failed to parse request" });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!fileBuffer || fileBuffer.length === 0) {
|
if (!fileBuffer || fileBuffer.length === 0) {
|
||||||
return reply.status(400).send({ error: "No file provided" });
|
return reply.status(400).send({ error: "No file provided" });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const meta = await sharp(fileBuffer).metadata();
|
const meta = await sharp(fileBuffer).metadata();
|
||||||
const pages = meta.pages ?? 1;
|
const pages = meta.pages ?? 1;
|
||||||
const delay = meta.delay ?? Array(pages).fill(100);
|
const delay = meta.delay ?? Array(pages).fill(100);
|
||||||
|
|
||||||
return reply.send({
|
return reply.send({
|
||||||
width: meta.width ?? 0,
|
width: meta.width ?? 0,
|
||||||
height: meta.pageHeight ?? meta.height ?? 0,
|
height: meta.pageHeight ?? meta.height ?? 0,
|
||||||
pages,
|
pages,
|
||||||
delay,
|
delay,
|
||||||
loop: meta.loop ?? 0,
|
loop: meta.loop ?? 0,
|
||||||
fileSize: fileBuffer.length,
|
fileSize: fileBuffer.length,
|
||||||
duration: delay.reduce((sum: number, d: number) => sum + d, 0),
|
duration: delay.reduce((sum: number, d: number) => sum + d, 0),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
return reply.status(422).send({ error: "Could not read image metadata" });
|
return reply.status(422).send({ error: "Could not read image metadata" });
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// ── Processing endpoint ─────────────────────────────────────────
|
// ── Processing endpoint ─────────────────────────────────────────
|
||||||
createToolRoute(app, {
|
createToolRoute(app, {
|
||||||
|
|||||||
@@ -69,119 +69,122 @@ registerAiJobHandler("noise-removal", async (input, data, ctx) => {
|
|||||||
* Uses the Python sidecar for multi-tier denoising.
|
* Uses the Python sidecar for multi-tier denoising.
|
||||||
*/
|
*/
|
||||||
export function registerNoiseRemoval(app: FastifyInstance) {
|
export function registerNoiseRemoval(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/noise-removal", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
const toolId = "noise-removal";
|
"/api/v1/tools/image/noise-removal",
|
||||||
if (!isToolInstalled(toolId)) {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
const bundle = getBundleForTool(toolId);
|
const toolId = "noise-removal";
|
||||||
return reply.status(501).send({
|
if (!isToolInstalled(toolId)) {
|
||||||
error: "Feature not installed",
|
const bundle = getBundleForTool(toolId);
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
return reply.status(501).send({
|
||||||
feature: TOOL_BUNDLE_MAP[toolId],
|
error: "Feature not installed",
|
||||||
featureName: bundle?.name ?? toolId,
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
feature: TOOL_BUNDLE_MAP[toolId],
|
||||||
});
|
featureName: bundle?.name ?? toolId,
|
||||||
}
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userId = getAuthUser(request)?.id ?? null;
|
const userId = getAuthUser(request)?.id ?? null;
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let clientJobId: string | null = null;
|
let clientJobId: string | null = null;
|
||||||
let fileId: string | null = null;
|
let fileId: string | null = null;
|
||||||
let inputKey: string | null = null;
|
let inputKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const upload = await receiveUpload(part, jobId);
|
const upload = await receiveUpload(part, jobId);
|
||||||
inputKey = upload.key;
|
inputKey = upload.key;
|
||||||
filename = upload.filename;
|
filename = upload.filename;
|
||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} else if (part.fieldname === "clientJobId") {
|
||||||
const raw = part.value as string;
|
const raw = part.value as string;
|
||||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
||||||
clientJobId = raw;
|
clientJobId = raw;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "fileId") {
|
|
||||||
fileId = part.value as string;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
if (!inputKey) {
|
||||||
error: "Failed to parse multipart request",
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
}
|
||||||
|
|
||||||
|
fileBuffer = await getObjectBuffer(inputKey);
|
||||||
|
|
||||||
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed: z.infer<typeof settingsSchema>;
|
||||||
|
try {
|
||||||
|
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
|
const result = settingsSchema.safeParse(raw);
|
||||||
|
if (!result.success) {
|
||||||
|
return reply
|
||||||
|
.status(400)
|
||||||
|
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||||
|
}
|
||||||
|
parsed = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (validation.format === "heif") {
|
||||||
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
|
}
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Noise removal failed",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedKey = `uploads/${jobId}/${filename}`;
|
||||||
|
if (decodedKey !== inputKey) {
|
||||||
|
await putObject(decodedKey, fileBuffer);
|
||||||
|
inputKey = decodedKey;
|
||||||
|
} else {
|
||||||
|
await putObject(inputKey, fileBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressJobId = clientJobId || jobId;
|
||||||
|
|
||||||
|
await enqueueToolJob({
|
||||||
|
jobId,
|
||||||
|
toolId,
|
||||||
|
userId,
|
||||||
|
pool: "ai",
|
||||||
|
inputRefs: [inputKey],
|
||||||
|
filename,
|
||||||
|
settings: parsed,
|
||||||
|
clientJobId: clientJobId ?? undefined,
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
if (!inputKey) {
|
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
},
|
||||||
}
|
);
|
||||||
|
|
||||||
fileBuffer = await getObjectBuffer(inputKey);
|
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
let parsed: z.infer<typeof settingsSchema>;
|
|
||||||
try {
|
|
||||||
const raw = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(raw);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
|
||||||
}
|
|
||||||
parsed = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (validation.format === "heif") {
|
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
|
||||||
}
|
|
||||||
if (needsCliDecode(validation.format)) {
|
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
|
||||||
}
|
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
|
||||||
} catch (err) {
|
|
||||||
request.log.error({ err, toolId: "noise-removal" }, "Input decoding failed");
|
|
||||||
return reply.status(422).send({
|
|
||||||
error: "Noise removal failed",
|
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedKey = `uploads/${jobId}/${filename}`;
|
|
||||||
if (decodedKey !== inputKey) {
|
|
||||||
await putObject(decodedKey, fileBuffer);
|
|
||||||
inputKey = decodedKey;
|
|
||||||
} else {
|
|
||||||
await putObject(inputKey, fileBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
|
||||||
jobId,
|
|
||||||
toolId,
|
|
||||||
userId,
|
|
||||||
pool: "ai",
|
|
||||||
inputRefs: [inputKey],
|
|
||||||
filename,
|
|
||||||
settings: parsed,
|
|
||||||
clientJobId: clientJobId ?? undefined,
|
|
||||||
fileId: fileId ?? undefined,
|
|
||||||
kind: "ai-tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register in the pipeline/batch registry
|
// Register in the pipeline/batch registry
|
||||||
registerToolProcessFn({
|
registerToolProcessFn({
|
||||||
|
|||||||
@@ -30,83 +30,86 @@ const settingsSchema = z.object({
|
|||||||
* images from text input, not from uploaded files.
|
* images from text input, not from uploaded files.
|
||||||
*/
|
*/
|
||||||
export function registerQrGenerate(app: FastifyInstance) {
|
export function registerQrGenerate(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/qr-generate", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
let body: unknown;
|
"/api/v1/tools/image/qr-generate",
|
||||||
try {
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
body = request.body;
|
let body: unknown;
|
||||||
} catch {
|
try {
|
||||||
return reply.status(400).send({ error: "Invalid request body" });
|
body = request.body;
|
||||||
}
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Invalid request body" });
|
||||||
const result = settingsSchema.safeParse(body);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Invalid settings",
|
|
||||||
details: formatZodErrors(result.error.issues),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const settings = result.data;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// When a logo is present, force max error correction so the QR
|
|
||||||
// remains scannable despite the logo occluding the center.
|
|
||||||
const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
|
|
||||||
|
|
||||||
let buffer = await QRCode.toBuffer(settings.text, {
|
|
||||||
width: settings.size,
|
|
||||||
errorCorrectionLevel: ecLevel,
|
|
||||||
color: {
|
|
||||||
dark: settings.foreground,
|
|
||||||
light: settings.background,
|
|
||||||
},
|
|
||||||
type: "png",
|
|
||||||
margin: 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (settings.logoDataUri) {
|
|
||||||
// Decode the data-URI base64 payload into a buffer
|
|
||||||
const base64Part = settings.logoDataUri.split(",")[1];
|
|
||||||
let logoBuffer: Buffer;
|
|
||||||
try {
|
|
||||||
logoBuffer = Buffer.from(base64Part, "base64");
|
|
||||||
// Validate that sharp can decode it
|
|
||||||
await sharp(logoBuffer).metadata();
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Invalid logo image" });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Resize logo to 22% of QR size and composite centered
|
|
||||||
const logoSize = Math.round(settings.size * 0.22);
|
|
||||||
const resizedLogo = await sharp(logoBuffer)
|
|
||||||
.resize(logoSize, logoSize, {
|
|
||||||
fit: "contain",
|
|
||||||
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
|
||||||
})
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
|
|
||||||
buffer = await sharp(buffer)
|
|
||||||
.composite([{ input: resizedLogo, gravity: "centre" }])
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const jobId = randomUUID();
|
const result = settingsSchema.safeParse(body);
|
||||||
const filename = "qrcode.png";
|
if (!result.success) {
|
||||||
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
return reply.status(400).send({
|
||||||
|
error: "Invalid settings",
|
||||||
|
details: formatZodErrors(result.error.issues),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return reply.send({
|
const settings = result.data;
|
||||||
jobId,
|
|
||||||
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
try {
|
||||||
originalSize: 0,
|
// When a logo is present, force max error correction so the QR
|
||||||
processedSize: buffer.length,
|
// remains scannable despite the logo occluding the center.
|
||||||
});
|
const ecLevel = settings.logoDataUri ? "H" : settings.errorCorrection;
|
||||||
} catch (err) {
|
|
||||||
return reply.status(422).send({
|
let buffer = await QRCode.toBuffer(settings.text, {
|
||||||
error: "QR code generation failed",
|
width: settings.size,
|
||||||
details: err instanceof Error ? err.message : "Unknown error",
|
errorCorrectionLevel: ecLevel,
|
||||||
});
|
color: {
|
||||||
}
|
dark: settings.foreground,
|
||||||
});
|
light: settings.background,
|
||||||
|
},
|
||||||
|
type: "png",
|
||||||
|
margin: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (settings.logoDataUri) {
|
||||||
|
// Decode the data-URI base64 payload into a buffer
|
||||||
|
const base64Part = settings.logoDataUri.split(",")[1];
|
||||||
|
let logoBuffer: Buffer;
|
||||||
|
try {
|
||||||
|
logoBuffer = Buffer.from(base64Part, "base64");
|
||||||
|
// Validate that sharp can decode it
|
||||||
|
await sharp(logoBuffer).metadata();
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Invalid logo image" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize logo to 22% of QR size and composite centered
|
||||||
|
const logoSize = Math.round(settings.size * 0.22);
|
||||||
|
const resizedLogo = await sharp(logoBuffer)
|
||||||
|
.resize(logoSize, logoSize, {
|
||||||
|
fit: "contain",
|
||||||
|
background: { r: 255, g: 255, b: 255, alpha: 0 },
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
|
||||||
|
buffer = await sharp(buffer)
|
||||||
|
.composite([{ input: resizedLogo, gravity: "centre" }])
|
||||||
|
.png()
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const jobId = randomUUID();
|
||||||
|
const filename = "qrcode.png";
|
||||||
|
await putObject(`outputs/${jobId}/${filename}`, buffer);
|
||||||
|
|
||||||
|
return reply.send({
|
||||||
|
jobId,
|
||||||
|
downloadUrl: `/api/v1/download/${jobId}/${filename}`,
|
||||||
|
originalSize: 0,
|
||||||
|
processedSize: buffer.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "QR code generation failed",
|
||||||
|
details: err instanceof Error ? err.message : "Unknown error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,125 +83,128 @@ registerAiJobHandler("restore-photo", async (input, data, ctx) => {
|
|||||||
* optional colorization.
|
* optional colorization.
|
||||||
*/
|
*/
|
||||||
export function registerRestorePhoto(app: FastifyInstance) {
|
export function registerRestorePhoto(app: FastifyInstance) {
|
||||||
app.post("/api/v1/tools/image/restore-photo", async (request: FastifyRequest, reply: FastifyReply) => {
|
app.post(
|
||||||
if (!isToolInstalled("restore-photo")) {
|
"/api/v1/tools/image/restore-photo",
|
||||||
const bundle = getBundleForTool("restore-photo");
|
async (request: FastifyRequest, reply: FastifyReply) => {
|
||||||
return reply.status(501).send({
|
if (!isToolInstalled("restore-photo")) {
|
||||||
error: "Feature not installed",
|
const bundle = getBundleForTool("restore-photo");
|
||||||
code: "FEATURE_NOT_INSTALLED",
|
return reply.status(501).send({
|
||||||
feature: "photo-restoration",
|
error: "Feature not installed",
|
||||||
featureName: bundle?.name ?? "Photo Restoration",
|
code: "FEATURE_NOT_INSTALLED",
|
||||||
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
feature: "photo-restoration",
|
||||||
});
|
featureName: bundle?.name ?? "Photo Restoration",
|
||||||
}
|
estimatedSize: bundle?.estimatedSize ?? "unknown",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const userId = getAuthUser(request)?.id ?? null;
|
const userId = getAuthUser(request)?.id ?? null;
|
||||||
const jobId = randomUUID();
|
const jobId = randomUUID();
|
||||||
let fileBuffer: Buffer | null = null;
|
let fileBuffer: Buffer | null = null;
|
||||||
let filename = "image";
|
let filename = "image";
|
||||||
let settingsRaw: string | null = null;
|
let settingsRaw: string | null = null;
|
||||||
let clientJobId: string | null = null;
|
let clientJobId: string | null = null;
|
||||||
let fileId: string | null = null;
|
let fileId: string | null = null;
|
||||||
let inputKey: string | null = null;
|
let inputKey: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parts = request.parts();
|
const parts = request.parts();
|
||||||
for await (const part of parts) {
|
for await (const part of parts) {
|
||||||
if (part.type === "file") {
|
if (part.type === "file") {
|
||||||
const upload = await receiveUpload(part, jobId);
|
const upload = await receiveUpload(part, jobId);
|
||||||
inputKey = upload.key;
|
inputKey = upload.key;
|
||||||
filename = upload.filename;
|
filename = upload.filename;
|
||||||
} else if (part.fieldname === "settings") {
|
} else if (part.fieldname === "settings") {
|
||||||
settingsRaw = part.value as string;
|
settingsRaw = part.value as string;
|
||||||
} else if (part.fieldname === "clientJobId") {
|
} else if (part.fieldname === "clientJobId") {
|
||||||
const raw = part.value as string;
|
const raw = part.value as string;
|
||||||
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(raw)) {
|
||||||
clientJobId = raw;
|
clientJobId = raw;
|
||||||
|
}
|
||||||
|
} else if (part.fieldname === "fileId") {
|
||||||
|
fileId = part.value as string;
|
||||||
}
|
}
|
||||||
} else if (part.fieldname === "fileId") {
|
|
||||||
fileId = part.value as string;
|
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Failed to parse multipart request",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
return reply.status(400).send({
|
|
||||||
error: "Failed to parse multipart request",
|
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : String(err)),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!inputKey) {
|
if (!inputKey) {
|
||||||
return reply.status(400).send({ error: "No image file provided" });
|
return reply.status(400).send({ error: "No image file provided" });
|
||||||
}
|
|
||||||
|
|
||||||
fileBuffer = await getObjectBuffer(inputKey);
|
|
||||||
|
|
||||||
const validation = await validateImageBuffer(fileBuffer, filename);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings: z.infer<typeof settingsSchema>;
|
|
||||||
try {
|
|
||||||
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
|
||||||
const result = settingsSchema.safeParse(parsed);
|
|
||||||
if (!result.success) {
|
|
||||||
return reply
|
|
||||||
.status(400)
|
|
||||||
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
|
||||||
}
|
}
|
||||||
settings = result.data;
|
|
||||||
} catch {
|
|
||||||
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
fileBuffer = await getObjectBuffer(inputKey);
|
||||||
if (validation.format === "heif") {
|
|
||||||
fileBuffer = await decodeHeic(fileBuffer);
|
const validation = await validateImageBuffer(fileBuffer, filename);
|
||||||
|
if (!validation.valid) {
|
||||||
|
return reply.status(400).send({ error: `Invalid image: ${validation.reason}` });
|
||||||
}
|
}
|
||||||
if (needsCliDecode(validation.format)) {
|
|
||||||
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
let settings: z.infer<typeof settingsSchema>;
|
||||||
}
|
try {
|
||||||
fileBuffer = await autoOrient(fileBuffer);
|
const parsed = settingsRaw ? JSON.parse(settingsRaw) : {};
|
||||||
if (validation.format === "avif") {
|
const result = settingsSchema.safeParse(parsed);
|
||||||
try {
|
if (!result.success) {
|
||||||
fileBuffer = await sharp(fileBuffer).png().toBuffer();
|
return reply
|
||||||
} catch {
|
.status(400)
|
||||||
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
.send({ error: "Invalid settings", details: formatZodErrors(result.error.issues) });
|
||||||
}
|
}
|
||||||
|
settings = result.data;
|
||||||
|
} catch {
|
||||||
|
return reply.status(400).send({ error: "Settings must be valid JSON" });
|
||||||
}
|
}
|
||||||
} catch (err) {
|
|
||||||
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed");
|
try {
|
||||||
return reply.status(422).send({
|
if (validation.format === "heif") {
|
||||||
error: "Photo restoration failed",
|
fileBuffer = await decodeHeic(fileBuffer);
|
||||||
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
}
|
||||||
|
if (needsCliDecode(validation.format)) {
|
||||||
|
fileBuffer = await decodeToSharpCompat(fileBuffer, validation.format);
|
||||||
|
}
|
||||||
|
fileBuffer = await autoOrient(fileBuffer);
|
||||||
|
if (validation.format === "avif") {
|
||||||
|
try {
|
||||||
|
fileBuffer = await sharp(fileBuffer).png().toBuffer();
|
||||||
|
} catch {
|
||||||
|
fileBuffer = await decodeAnyFormat(fileBuffer, "avif");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
request.log.error({ err, toolId: "restore-photo" }, "Input decoding failed");
|
||||||
|
return reply.status(422).send({
|
||||||
|
error: "Photo restoration failed",
|
||||||
|
details: stripInternalPaths(err instanceof Error ? err.message : "Unknown error"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const decodedKey = `uploads/${jobId}/${filename}`;
|
||||||
|
if (decodedKey !== inputKey) {
|
||||||
|
await putObject(decodedKey, fileBuffer);
|
||||||
|
inputKey = decodedKey;
|
||||||
|
} else {
|
||||||
|
await putObject(inputKey, fileBuffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
const progressJobId = clientJobId || jobId;
|
||||||
|
|
||||||
|
await enqueueToolJob({
|
||||||
|
jobId,
|
||||||
|
toolId: "restore-photo",
|
||||||
|
userId,
|
||||||
|
pool: "ai",
|
||||||
|
inputRefs: [inputKey],
|
||||||
|
filename,
|
||||||
|
settings,
|
||||||
|
clientJobId: clientJobId ?? undefined,
|
||||||
|
fileId: fileId ?? undefined,
|
||||||
|
kind: "ai-tool",
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const decodedKey = `uploads/${jobId}/${filename}`;
|
return reply.status(202).send({ jobId: progressJobId, async: true });
|
||||||
if (decodedKey !== inputKey) {
|
},
|
||||||
await putObject(decodedKey, fileBuffer);
|
);
|
||||||
inputKey = decodedKey;
|
|
||||||
} else {
|
|
||||||
await putObject(inputKey, fileBuffer);
|
|
||||||
}
|
|
||||||
|
|
||||||
const progressJobId = clientJobId || jobId;
|
|
||||||
|
|
||||||
await enqueueToolJob({
|
|
||||||
jobId,
|
|
||||||
toolId: "restore-photo",
|
|
||||||
userId,
|
|
||||||
pool: "ai",
|
|
||||||
inputRefs: [inputKey],
|
|
||||||
filename,
|
|
||||||
settings,
|
|
||||||
clientJobId: clientJobId ?? undefined,
|
|
||||||
fileId: fileId ?? undefined,
|
|
||||||
kind: "ai-tool",
|
|
||||||
});
|
|
||||||
|
|
||||||
return reply.status(202).send({ jobId: progressJobId, async: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register in the pipeline/batch registry
|
// Register in the pipeline/batch registry
|
||||||
registerToolProcessFn({
|
registerToolProcessFn({
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
import { SECTIONS, TOOLS, toolSection } from "@snapotter/shared";
|
||||||
import Base from "@/layouts/Base.astro";
|
|
||||||
import Navbar from "@/components/Navbar.astro";
|
|
||||||
import Footer from "@/components/Footer.astro";
|
import Footer from "@/components/Footer.astro";
|
||||||
|
import Navbar from "@/components/Navbar.astro";
|
||||||
|
import Base from "@/layouts/Base.astro";
|
||||||
|
|
||||||
export function getStaticPaths() {
|
export function getStaticPaths() {
|
||||||
return SECTIONS.map((s) => ({
|
return SECTIONS.map((s) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user