fix: format preservation, dispatcher stability, and health reporting

Closes #17, #18, #19, #31, #32, #33, #34

Format preservation (#17, #18, #19):
- Add resolveOutputFormat to rotate, resize, text-overlay, watermark-text,
  border, replace-color, blur-faces, upscale, erase-object, restore-photo
- Alpha-aware fallback: border with corner radius/shadow and replace-color
  with makeTransparent fall back to PNG for non-alpha formats (JPEG)
- Python sidecar tools (blur-faces, upscale, erase-object) now convert
  PNG output back to input format, matching restore-photo/colorize pattern
- Upscale and erase-object default to "auto" format detection instead of PNG

Dispatcher stability (#31, #32):
- Add gc.collect() and torch.cuda.empty_cache() after each dispatcher request
- Add configurable max_requests (default 50) for periodic dispatcher restart
- Add exponential backoff to dispatcher crash recovery in bridge.ts
- Circuit breaker: 5 crashes within 60s permanently disables dispatcher
- Reset crash counter on successful dispatcher startup

Health & security (#33, #34):
- Export getDispatcherStatus() from @snapotter/ai with running/ready/failed/
  gpu/pid/consecutiveCrashes fields
- Admin health endpoint now includes full dispatcher status
- Add pip-audit job to CI workflow for Python dependency scanning
This commit is contained in:
SnapOtter
2026-04-26 03:22:26 +08:00
parent 86db131198
commit dee9452c48
15 changed files with 227 additions and 34 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import cors from "@fastify/cors";
import rateLimit from "@fastify/rate-limit";
import { isGpuAvailable } from "@snapotter/ai";
import { getDispatcherStatus, isGpuAvailable } from "@snapotter/ai";
import { APP_VERSION } from "@snapotter/shared";
import { eq } from "drizzle-orm";
import Fastify from "fastify";
@@ -222,7 +222,7 @@ app.get("/api/v1/admin/health", async (request, reply) => {
storage: { mode: env.STORAGE_MODE, available: "N/A" },
database: dbOk ? "ok" : "error",
queue: { active: 0, pending: 0 },
ai: { gpu: isGpuAvailable() },
ai: { gpu: isGpuAvailable(), dispatcher: getDispatcherStatus() },
};
});
+29 -6
View File
@@ -4,6 +4,7 @@ import { basename, join } from "node:path";
import { blurFaces } from "@snapotter/ai";
import { getBundleForTool, TOOL_BUNDLE_MAP } from "@snapotter/shared";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { autoOrient } from "../../lib/auto-orient.js";
import { formatZodErrors } from "../../lib/errors.js";
@@ -11,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, ensureSharpCompat } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -139,10 +141,19 @@ export function registerBlurFaces(app: FastifyInstance) {
onProgress,
);
// Save output
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.png`;
// Resolve output format to match input
const outputFormat = await resolveOutputFormat(fileBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
const outputPath = join(workspacePath, "output", outputFilename);
await writeFile(outputPath, result.buffer);
await writeFile(outputPath, outputBuffer);
if (clientJobId) {
updateSingleFileProgress({
@@ -156,7 +167,7 @@ export function registerBlurFaces(app: FastifyInstance) {
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
originalSize: fileBuffer.length,
processedSize: result.buffer.length,
processedSize: outputBuffer.length,
facesDetected: result.facesDetected,
faces: result.faces,
...(result.facesDetected === 0 && {
@@ -189,8 +200,20 @@ export function registerBlurFaces(app: FastifyInstance) {
blurRadius: s.blurRadius ?? 30,
sensitivity: s.sensitivity ?? 0.5,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_blurred.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
},
});
}
+15 -3
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const hexColor = z.string().regex(/^#[0-9a-fA-F]{6}$/);
@@ -144,9 +145,20 @@ export function registerBorder(app: FastifyInstance) {
.toBuffer();
}
const buffer = await sharp(buf).png().toBuffer();
const outName = filename.replace(/\.[^.]+$/, ".png");
return { buffer, filename: outName, contentType: "image/png" };
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
const needsAlpha = settings.cornerRadius > 0 || settings.shadow;
if (needsAlpha && !ALPHA_FORMATS.has(outputFormat.format)) {
const buffer = await sharp(buf).png().toBuffer();
const outName = filename.replace(/\.[^.]+$/, ".png");
return { buffer, filename: outName, contentType: "image/png" };
}
const buffer = await sharp(buf)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
+9 -2
View File
@@ -11,6 +11,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
@@ -30,8 +31,8 @@ const BROWSER_PREVIEWABLE = new Set(["png", "jpg", "jpeg", "webp", "gif", "avif"
const settingsSchema = z.object({
format: z
.enum(["png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
.default("png"),
.enum(["auto", "png", "jpg", "jpeg", "webp", "tiff", "gif", "avif", "heic", "heif"])
.default("auto"),
quality: z.number().int().min(1).max(100).default(95),
});
@@ -122,6 +123,12 @@ export function registerEraseObject(app: FastifyInstance) {
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;
}
request.log.info(
{
toolId: "erase-object",
+11 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -73,13 +74,21 @@ export function registerReplaceColor(app: FastifyInstance) {
}
}
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const ALPHA_FORMATS = new Set(["png", "webp", "avif", "tiff"]);
const needsAlpha = settings.makeTransparent;
const useFormat =
needsAlpha && !ALPHA_FORMATS.has(outputFormat.format)
? { format: "png" as const, quality: 100, contentType: "image/png" }
: outputFormat;
const buffer = await sharp(pixels, {
raw: { width: info.width, height: info.height, channels: 4 },
})
.png()
.toFormat(useFormat.format, { quality: useFormat.quality })
.toBuffer();
return { buffer, filename, contentType: "image/png" };
return { buffer, filename, contentType: useFormat.contentType };
},
});
}
+6 -2
View File
@@ -2,6 +2,7 @@ import { resize } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -17,10 +18,13 @@ export function registerResize(app: FastifyInstance) {
toolId: "resize",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const image = sharp(inputBuffer);
const result = await resize(image, settings);
const buffer = await result.toBuffer();
return { buffer, filename, contentType: "image/png" };
const buffer = await result
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
+14 -2
View File
@@ -239,8 +239,20 @@ export function registerRestorePhoto(app: FastifyInstance) {
denoiseStrength: s.denoiseStrength,
colorize: s.colorize,
});
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_restored.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
},
});
}
+6 -2
View File
@@ -2,6 +2,7 @@ import { flip, rotate } from "@snapotter/image-engine";
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -15,6 +16,7 @@ export function registerRotate(app: FastifyInstance) {
toolId: "rotate",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let image = sharp(inputBuffer);
// Apply rotation first
@@ -30,8 +32,10 @@ export function registerRotate(app: FastifyInstance) {
});
}
const buffer = await image.toBuffer();
return { buffer, filename, contentType: "image/png" };
const buffer = await image
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
+6 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -33,6 +34,7 @@ export function registerTextOverlay(app: FastifyInstance) {
toolId: "text-overlay",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const image = sharp(inputBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 800;
@@ -79,9 +81,11 @@ export function registerTextOverlay(app: FastifyInstance) {
const svgBuffer = Buffer.from(svgOverlay);
const result = await image.composite([{ input: svgBuffer, top: 0, left: 0 }]);
const buffer = await result.toBuffer();
const buffer = await result
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: "image/png" };
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}
+22 -4
View File
@@ -12,6 +12,7 @@ import { isToolInstalled } from "../../lib/feature-status.js";
import { validateImageBuffer } from "../../lib/file-validation.js";
import { decodeToSharpCompat, needsCliDecode } from "../../lib/format-decoders.js";
import { decodeHeic, encodeHeic } from "../../lib/heic-converter.js";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createWorkspace } from "../../lib/workspace.js";
import { updateSingleFileProgress } from "../progress.js";
import { registerToolProcessFn } from "../tool-factory.js";
@@ -21,7 +22,7 @@ const settingsSchema = z.object({
model: z.string().default("auto"),
faceEnhance: z.boolean().default(false),
denoise: z.union([z.number(), z.string()]).transform(Number).default(0),
format: z.string().default("png"),
format: z.string().default("auto"),
quality: z.union([z.number(), z.string()]).transform(Number).default(95),
});
@@ -99,8 +100,13 @@ export function registerUpscale(app: FastifyInstance) {
const model = settings.model;
const faceEnhance = settings.faceEnhance;
const denoise = settings.denoise;
const format = settings.format;
let format = settings.format;
const outputQuality = settings.quality;
if (format === "auto") {
const detected = await resolveOutputFormat(fileBuffer, filename);
format = detected.format === "jpeg" ? "jpg" : detected.format;
}
request.log.info(
{ toolId: "upscale", imageSize: fileBuffer.length, scale, model, format },
"Starting upscale",
@@ -248,8 +254,20 @@ export function registerUpscale(app: FastifyInstance) {
const jobId = randomUUID();
const workspacePath = await createWorkspace(jobId);
const result = await upscale(orientedBuffer, join(workspacePath, "output"), { scale });
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.png`;
return { buffer: result.buffer, filename: outputFilename, contentType: "image/png" };
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
let outputBuffer = result.buffer;
if (outputFormat.format !== "png") {
outputBuffer = await sharp(result.buffer)
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
}
const ext = outputFormat.format === "jpeg" ? "jpg" : outputFormat.format;
const outputFilename = `${filename.replace(/\.[^.]+$/, "")}_${scale}x.${ext}`;
return {
buffer: outputBuffer,
filename: outputFilename,
contentType: outputFormat.contentType,
};
},
});
}
+6 -2
View File
@@ -1,6 +1,7 @@
import type { FastifyInstance } from "fastify";
import sharp from "sharp";
import { z } from "zod";
import { resolveOutputFormat } from "../../lib/output-format.js";
import { createToolRoute } from "../tool-factory.js";
const settingsSchema = z.object({
@@ -38,6 +39,7 @@ export function registerWatermarkText(app: FastifyInstance) {
toolId: "watermark-text",
settingsSchema,
process: async (inputBuffer, settings, filename) => {
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
const image = sharp(inputBuffer);
const metadata = await image.metadata();
const width = metadata.width ?? 800;
@@ -103,9 +105,11 @@ export function registerWatermarkText(app: FastifyInstance) {
const svgBuffer = Buffer.from(svgOverlay);
const result = await image.composite([{ input: svgBuffer, top: 0, left: 0 }]);
const buffer = await result.toBuffer();
const buffer = await result
.toFormat(outputFormat.format, { quality: outputFormat.quality })
.toBuffer();
return { buffer, filename, contentType: "image/png" };
return { buffer, filename, contentType: outputFormat.contentType };
},
});
}