mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
363 lines
10 KiB
TypeScript
363 lines
10 KiB
TypeScript
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import {
|
|
colorize,
|
|
enhanceFaces,
|
|
isMemoryAllocError,
|
|
noiseRemoval,
|
|
removeBackground,
|
|
removeRedEye,
|
|
restorePhoto,
|
|
} from "@snapotter/ai";
|
|
import type { FastifyInstance } from "fastify";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { runAiToolJob } from "../../../apps/api/src/jobs/ai-handlers.js";
|
|
import type { ToolJobData } from "../../../apps/api/src/jobs/types.js";
|
|
import type { ToolProcessCtx } from "../../../apps/api/src/routes/tool-factory.js";
|
|
import { getToolConfig } from "../../../apps/api/src/routes/tool-factory.js";
|
|
import { registerColorize } from "../../../apps/api/src/routes/tools/colorize.js";
|
|
import { registerEnhanceFaces } from "../../../apps/api/src/routes/tools/enhance-faces.js";
|
|
import { registerNoiseRemoval } from "../../../apps/api/src/routes/tools/noise-removal.js";
|
|
import { registerRedEyeRemoval } from "../../../apps/api/src/routes/tools/red-eye-removal.js";
|
|
import { registerRestorePhoto } from "../../../apps/api/src/routes/tools/restore-photo.js";
|
|
import { registerTransparencyFixer } from "../../../apps/api/src/routes/tools/transparency-fixer.js";
|
|
import { fixtures, readFixture } from "../../fixtures/index.js";
|
|
|
|
const aiMocks = vi.hoisted(() => ({
|
|
colorize: vi.fn(),
|
|
enhanceFaces: vi.fn(),
|
|
isMemoryAllocError: vi.fn(),
|
|
noiseRemoval: vi.fn(),
|
|
removeBackground: vi.fn(),
|
|
removeRedEye: vi.fn(),
|
|
restorePhoto: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@snapotter/ai", () => ({
|
|
colorize: aiMocks.colorize,
|
|
enhanceFaces: aiMocks.enhanceFaces,
|
|
isMemoryAllocError: aiMocks.isMemoryAllocError,
|
|
noiseRemoval: aiMocks.noiseRemoval,
|
|
removeBackground: aiMocks.removeBackground,
|
|
removeRedEye: aiMocks.removeRedEye,
|
|
restorePhoto: aiMocks.restorePhoto,
|
|
}));
|
|
|
|
const PNG = readFixture(fixtures.image.base.png200);
|
|
const SCRATCH_DIR = join(tmpdir(), "snapotter-ai-handler-test");
|
|
|
|
const ctx: ToolProcessCtx = {
|
|
signal: new AbortController().signal,
|
|
scratchDir: SCRATCH_DIR,
|
|
report: vi.fn(),
|
|
};
|
|
|
|
const fakeApp = {
|
|
post: vi.fn(),
|
|
} as unknown as FastifyInstance;
|
|
|
|
function job(toolId: string, settings: unknown, filename = "photo.png"): ToolJobData {
|
|
return {
|
|
jobId: `job-${toolId}`,
|
|
toolId,
|
|
userId: null,
|
|
pool: "ai",
|
|
inputRefs: [`uploads/job-${toolId}/${filename}`],
|
|
filename,
|
|
settings,
|
|
kind: "ai-tool",
|
|
};
|
|
}
|
|
|
|
function resetAiMocks() {
|
|
vi.mocked(colorize).mockResolvedValue({
|
|
buffer: PNG,
|
|
width: 200,
|
|
height: 150,
|
|
method: "mock-colorizer",
|
|
});
|
|
vi.mocked(noiseRemoval).mockResolvedValue({
|
|
buffer: PNG,
|
|
format: "jpeg",
|
|
});
|
|
vi.mocked(restorePhoto).mockResolvedValue({
|
|
buffer: PNG,
|
|
width: 200,
|
|
height: 150,
|
|
steps: ["denoise"],
|
|
scratchCoverage: 0.12,
|
|
facesEnhanced: 1,
|
|
isGrayscale: false,
|
|
colorized: true,
|
|
});
|
|
vi.mocked(enhanceFaces).mockResolvedValue({
|
|
buffer: PNG,
|
|
facesDetected: 2,
|
|
faces: [{ x: 1, y: 2, width: 20, height: 30 }],
|
|
model: "codeformer",
|
|
});
|
|
vi.mocked(removeRedEye).mockResolvedValue({
|
|
buffer: PNG,
|
|
facesDetected: 1,
|
|
eyesCorrected: 2,
|
|
});
|
|
vi.mocked(removeBackground).mockResolvedValue(PNG);
|
|
vi.mocked(isMemoryAllocError).mockReturnValue(false);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
resetAiMocks();
|
|
ctx.report = vi.fn();
|
|
});
|
|
|
|
describe("AI image job handlers", () => {
|
|
it("runs colorize jobs with parsed settings and output metadata", async () => {
|
|
vi.mocked(colorize).mockImplementation(async (_input, _scratch, _settings, report) => {
|
|
report?.(40, "colorizing");
|
|
return { buffer: PNG, width: 200, height: 150, method: "mock-colorizer" };
|
|
});
|
|
|
|
const result = await runAiToolJob(
|
|
job("colorize", { intensity: 0.5, model: "opencv" }),
|
|
PNG,
|
|
ctx,
|
|
);
|
|
|
|
expect(colorize).toHaveBeenCalledWith(
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{ intensity: 0.5, model: "opencv" },
|
|
expect.any(Function),
|
|
);
|
|
expect(ctx.report).toHaveBeenCalledWith(40, "colorizing");
|
|
expect(result).toMatchObject({
|
|
filename: "photo_colorized.png",
|
|
contentType: "image/png",
|
|
resultPayload: { width: 200, height: 150, method: "mock-colorizer" },
|
|
});
|
|
});
|
|
|
|
it("runs denoise jobs and maps jpeg outputs to jpg filenames", async () => {
|
|
const result = await runAiToolJob(
|
|
job("noise-removal", {
|
|
tier: "quality",
|
|
strength: "60",
|
|
detailPreservation: 70,
|
|
colorNoise: 10,
|
|
format: "jpeg",
|
|
quality: 82,
|
|
}),
|
|
PNG,
|
|
ctx,
|
|
);
|
|
|
|
expect(noiseRemoval).toHaveBeenCalledWith(
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{
|
|
tier: "quality",
|
|
strength: 60,
|
|
detailPreservation: 70,
|
|
colorNoise: 10,
|
|
format: "jpeg",
|
|
quality: 82,
|
|
},
|
|
expect.any(Function),
|
|
);
|
|
expect(result).toMatchObject({
|
|
filename: "photo_denoised.jpg",
|
|
contentType: "image/jpeg",
|
|
});
|
|
});
|
|
|
|
it("runs restoration jobs and returns worker result payload details", async () => {
|
|
const result = await runAiToolJob(
|
|
job("restore-photo", {
|
|
scratchRemoval: true,
|
|
faceEnhancement: true,
|
|
fidelity: 0.75,
|
|
denoise: true,
|
|
denoiseStrength: 35,
|
|
colorize: true,
|
|
colorizeStrength: 80,
|
|
}),
|
|
PNG,
|
|
ctx,
|
|
);
|
|
|
|
expect(restorePhoto).toHaveBeenCalledWith(
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{
|
|
scratchRemoval: true,
|
|
faceEnhancement: true,
|
|
fidelity: 0.75,
|
|
denoise: true,
|
|
denoiseStrength: 35,
|
|
colorize: true,
|
|
colorizeStrength: 80,
|
|
},
|
|
expect.any(Function),
|
|
);
|
|
expect(result).toMatchObject({
|
|
filename: "photo_restored.png",
|
|
contentType: "image/png",
|
|
resultPayload: {
|
|
steps: ["denoise"],
|
|
scratchCoverage: 0.12,
|
|
facesEnhanced: 1,
|
|
isGrayscale: false,
|
|
colorized: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("runs face enhancement and red-eye handlers with AI result payloads", async () => {
|
|
const enhanced = await runAiToolJob(
|
|
job("enhance-faces", {
|
|
model: "codeformer",
|
|
strength: 0.65,
|
|
onlyCenterFace: true,
|
|
sensitivity: 0.7,
|
|
}),
|
|
PNG,
|
|
ctx,
|
|
);
|
|
const redEye = await runAiToolJob(
|
|
job("red-eye-removal", {
|
|
sensitivity: 45,
|
|
strength: 80,
|
|
format: "png",
|
|
quality: 90,
|
|
}),
|
|
PNG,
|
|
ctx,
|
|
);
|
|
|
|
expect(enhanceFaces).toHaveBeenCalledWith(
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{ model: "codeformer", strength: 0.65, onlyCenterFace: true, sensitivity: 0.7 },
|
|
expect.any(Function),
|
|
);
|
|
expect(enhanced).toMatchObject({
|
|
filename: "photo_enhanced.png",
|
|
contentType: "image/png",
|
|
resultPayload: { facesDetected: 2, model: "codeformer" },
|
|
});
|
|
expect(removeRedEye).toHaveBeenCalledWith(
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{ sensitivity: 45, strength: 80, format: "png", quality: 90 },
|
|
expect.any(Function),
|
|
);
|
|
expect(redEye).toMatchObject({
|
|
filename: "photo_redeye_fixed.png",
|
|
contentType: "image/png",
|
|
resultPayload: { facesDetected: 1, eyesCorrected: 2 },
|
|
});
|
|
});
|
|
|
|
it("falls back to the lower-memory transparency model on OOM", async () => {
|
|
vi.mocked(removeBackground)
|
|
.mockRejectedValueOnce(new Error("out of memory"))
|
|
.mockResolvedValueOnce(PNG);
|
|
vi.mocked(isMemoryAllocError).mockReturnValue(true);
|
|
|
|
const result = await runAiToolJob(
|
|
job("transparency-fixer", {
|
|
defringe: 0,
|
|
outputFormat: "png",
|
|
removeWatermark: false,
|
|
}),
|
|
PNG,
|
|
ctx,
|
|
);
|
|
|
|
expect(removeBackground).toHaveBeenNthCalledWith(
|
|
1,
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{ model: "birefnet-hr-matting" },
|
|
expect.any(Function),
|
|
);
|
|
expect(removeBackground).toHaveBeenNthCalledWith(
|
|
2,
|
|
PNG,
|
|
SCRATCH_DIR,
|
|
{ model: "birefnet-general" },
|
|
expect.any(Function),
|
|
);
|
|
expect(ctx.report).toHaveBeenCalledWith(5, "Retrying with fallback model (birefnet-general)");
|
|
expect(result).toMatchObject({
|
|
filename: "photo_fixed.png",
|
|
contentType: "image/png",
|
|
resultPayload: { filename: "photo.png" },
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("AI image pipeline process registrations", () => {
|
|
beforeEach(() => {
|
|
registerColorize(fakeApp);
|
|
registerNoiseRemoval(fakeApp);
|
|
registerRestorePhoto(fakeApp);
|
|
registerEnhanceFaces(fakeApp);
|
|
registerRedEyeRemoval(fakeApp);
|
|
registerTransparencyFixer(fakeApp);
|
|
});
|
|
|
|
it("registers pipeline processors for custom AI photo routes", async () => {
|
|
const colorizeConfig = getToolConfig("colorize");
|
|
const noiseConfig = getToolConfig("noise-removal");
|
|
const restoreConfig = getToolConfig("restore-photo");
|
|
const enhanceConfig = getToolConfig("enhance-faces");
|
|
const redEyeConfig = getToolConfig("red-eye-removal");
|
|
const transparencyConfig = getToolConfig("transparency-fixer");
|
|
|
|
expect(colorizeConfig).toBeDefined();
|
|
expect(noiseConfig).toBeDefined();
|
|
expect(restoreConfig).toBeDefined();
|
|
expect(enhanceConfig).toBeDefined();
|
|
expect(redEyeConfig).toBeDefined();
|
|
expect(transparencyConfig).toBeDefined();
|
|
|
|
await expect(
|
|
colorizeConfig?.process(PNG, { intensity: 0.9, model: "opencv" }, "photo.png", ctx),
|
|
).resolves.toMatchObject({ filename: "photo_colorized.png", contentType: "image/png" });
|
|
await expect(
|
|
noiseConfig?.process(
|
|
PNG,
|
|
{
|
|
tier: "balanced",
|
|
strength: 50,
|
|
detailPreservation: 40,
|
|
colorNoise: 20,
|
|
format: "jpeg",
|
|
quality: 90,
|
|
},
|
|
"photo.png",
|
|
ctx,
|
|
),
|
|
).resolves.toMatchObject({ filename: "photo_denoised.jpg", contentType: "image/jpeg" });
|
|
await expect(
|
|
restoreConfig?.process(PNG, { fidelity: 0.7 }, "photo.png", ctx),
|
|
).resolves.toMatchObject({ filename: "photo_restored.png", contentType: "image/png" });
|
|
await expect(
|
|
enhanceConfig?.process(PNG, { model: "auto" }, "photo.png", ctx),
|
|
).resolves.toMatchObject({ filename: "photo_enhanced.png", contentType: "image/png" });
|
|
await expect(
|
|
redEyeConfig?.process(PNG, { sensitivity: 50 }, "photo.png", ctx),
|
|
).resolves.toMatchObject({ filename: "photo_redeye_fixed.png", contentType: "image/png" });
|
|
await expect(
|
|
transparencyConfig?.process(
|
|
PNG,
|
|
{ defringe: 0, outputFormat: "png", removeWatermark: false },
|
|
"photo.png",
|
|
ctx,
|
|
),
|
|
).resolves.toMatchObject({ filename: "photo_fixed.png", contentType: "image/png" });
|
|
});
|
|
});
|