mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Merge feat/color-blindness: add Color Blindness Simulation tool (#49)
This commit is contained in:
@@ -15,7 +15,7 @@
|
||||
|
||||
## Key Features
|
||||
|
||||
- **48 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more
|
||||
- **49 image tools** - Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, find duplicates, generate passport photos, and more
|
||||
- **Local AI** - Remove backgrounds, upscale images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR). All on your hardware - no internet required
|
||||
- **Pipelines** - Chain tools into reusable workflows with unlimited steps. Batch process unlimited images at once
|
||||
- **REST API** - Every tool available via API with API key auth. Interactive docs at `/api/docs`
|
||||
|
||||
@@ -2053,6 +2053,50 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
|
||||
/api/v1/tools/color-blindness:
|
||||
post:
|
||||
tags: [Tools]
|
||||
summary: Color blindness simulation
|
||||
description: Simulate how an image appears to people with various types of color vision deficiency.
|
||||
security:
|
||||
- bearerAuth: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
required: [file]
|
||||
properties:
|
||||
file:
|
||||
type: string
|
||||
format: binary
|
||||
description: Image file to process
|
||||
settings:
|
||||
type: string
|
||||
description: |
|
||||
JSON string with options:
|
||||
- `simulationType` (enum, default "deuteranomaly") -- Type of color vision deficiency to simulate. One of: protanopia, deuteranopia, tritanopia, protanomaly, deuteranomaly, tritanomaly, achromatopsia, blueConeMonochromacy
|
||||
responses:
|
||||
"200":
|
||||
description: Processed image
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ToolResponse"
|
||||
"400":
|
||||
description: Invalid input
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
"401":
|
||||
description: Authentication required
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/UnauthorizedError"
|
||||
|
||||
/api/v1/tools/gif-tools:
|
||||
post:
|
||||
tags: [Tools]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { colorBlindness } 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({
|
||||
simulationType: z
|
||||
.enum([
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"protanomaly",
|
||||
"deuteranomaly",
|
||||
"tritanomaly",
|
||||
"achromatopsia",
|
||||
"blueConeMonochromacy",
|
||||
])
|
||||
.default("deuteranomaly"),
|
||||
});
|
||||
|
||||
export function registerColorBlindness(app: FastifyInstance) {
|
||||
createToolRoute(app, {
|
||||
toolId: "color-blindness",
|
||||
settingsSchema,
|
||||
process: async (inputBuffer, settings, filename) => {
|
||||
const image = sharp(inputBuffer);
|
||||
const result = await colorBlindness(image, { type: settings.simulationType });
|
||||
|
||||
const outputFormat = await resolveOutputFormat(inputBuffer, filename);
|
||||
const buffer = await result
|
||||
.toFormat(outputFormat.format, { quality: outputFormat.quality })
|
||||
.toBuffer();
|
||||
|
||||
return { buffer, filename, contentType: outputFormat.contentType };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { registerBorder } from "./border.js";
|
||||
import { registerBulkRename } from "./bulk-rename.js";
|
||||
import { registerCollage } from "./collage.js";
|
||||
import { registerColorAdjustments } from "./color-adjustments.js";
|
||||
import { registerColorBlindness } from "./color-blindness.js";
|
||||
import { registerColorPalette } from "./color-palette.js";
|
||||
import { registerColorize } from "./colorize.js";
|
||||
import { registerCompare } from "./compare.js";
|
||||
@@ -131,6 +132,7 @@ export async function registerToolRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
// Adjustments extra
|
||||
{ id: "replace-color", register: registerReplaceColor },
|
||||
{ id: "color-blindness", register: registerColorBlindness },
|
||||
|
||||
// AI Tools
|
||||
{ id: "remove-background", register: registerRemoveBackground },
|
||||
|
||||
@@ -4,7 +4,7 @@ import llmstxt from "vitepress-plugin-llms";
|
||||
export default defineConfig({
|
||||
title: "SnapOtter",
|
||||
description:
|
||||
"Documentation for SnapOtter - A Self Hosted Image Manipulator. 48 tools, local AI, pipelines, REST API.",
|
||||
"Documentation for SnapOtter - A Self Hosted Image Manipulator. 49 tools, local AI, pipelines, REST API.",
|
||||
base: "/",
|
||||
appearance: { initialValue: "light" },
|
||||
srcDir: ".",
|
||||
@@ -48,7 +48,7 @@ export default defineConfig({
|
||||
`,
|
||||
customTemplateVariables: {
|
||||
description:
|
||||
"SnapOtter is a self-hosted, open-source image processing platform with 48 tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
|
||||
"SnapOtter is a self-hosted, open-source image processing platform with 49 tools including AI/ML. Runs in a single Docker container with GPU auto-detection.",
|
||||
details:
|
||||
"Resize, compress, convert, remove backgrounds, upscale, run OCR, and more - without sending images to external services.",
|
||||
},
|
||||
|
||||
@@ -131,6 +131,7 @@ curl -X POST http://localhost:1349/api/v1/tools/<toolId>/batch \
|
||||
| `adjust-colors` | Adjust Colors | `brightness`, `contrast`, `exposure`, `saturation`, `temperature`, `tint`, `hue`, `sharpness`, `red`, `green`, `blue`, `effect` (none/grayscale/sepia/invert) |
|
||||
| `sharpening` | Sharpening | `method` (adaptive/unsharp-mask/high-pass), `sigma`, `m1`, `m2`, `x1`, `y2`, `y3`, `amount`, `radius`, `threshold`, `strength`, `kernelSize` (3/5), `denoise` (off/light/medium/strong) |
|
||||
| `replace-color` | Replace Color | `sourceColor`, `targetColor` (replacement), `makeTransparent`, `tolerance` |
|
||||
| `color-blindness` | Color Blindness Simulation | `simulationType` (protanopia/deuteranopia/tritanopia/protanomaly/deuteranomaly/tritanomaly/achromatopsia/blueConeMonochromacy, default "deuteranomaly") |
|
||||
|
||||
### AI Tools
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ Shared TypeScript types, constants (like `APP_VERSION` and tool definitions), an
|
||||
|
||||
### API (`apps/api`)
|
||||
|
||||
A Fastify v5 server exposing 48 tool routes (33 standard image operations + 15 AI-powered) that handles:
|
||||
A Fastify v5 server exposing 49 tool routes (33 standard image operations + 15 AI-powered) that handles:
|
||||
- File uploads, temporary workspace management, and persistent file storage
|
||||
- User file library with version chains (`user_files` table) -- each processed result links back to its source file and records which tool was applied, with auto-generated thumbnails for the Files page
|
||||
- Tool execution (routes each tool request to the image engine or AI bridge)
|
||||
|
||||
@@ -64,7 +64,7 @@ pnpm dev
|
||||
|
||||
## What You Can Do
|
||||
|
||||
### Image Processing (48 Tools)
|
||||
### Image Processing (49 Tools)
|
||||
|
||||
| Category | Tools |
|
||||
|----------|-------|
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ layout: home
|
||||
hero:
|
||||
name: "SnapOtter"
|
||||
text: "A Self Hosted Image Manipulator"
|
||||
tagline: 48 tools. Local AI. No cloud. Your images never leave your home.
|
||||
tagline: 49 tools. Local AI. No cloud. Your images never leave your home.
|
||||
actions:
|
||||
- theme: brand
|
||||
text: Get started
|
||||
@@ -14,7 +14,7 @@ hero:
|
||||
link: /api/rest
|
||||
|
||||
features:
|
||||
- title: 48 Image Tools
|
||||
- title: 49 Image Tools
|
||||
details: Resize, crop, compress, convert, watermark, color adjust, vectorize, create GIFs, build collages, generate passport photos, find duplicates, and more.
|
||||
- title: Local AI
|
||||
details: 15 AI-powered tools - remove backgrounds, upscale, enhance images, restore and colorize old photos, erase objects, blur faces, enhance faces, extract text (OCR), fix fake transparency. All on your hardware, no internet required.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Download } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { ProgressCard } from "@/components/common/progress-card";
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
const SIMULATION_TYPES = [
|
||||
{
|
||||
group: "Red-Green",
|
||||
types: [
|
||||
{
|
||||
value: "protanopia",
|
||||
label: "Protanopia",
|
||||
description: "No red cones. Reds appear dark. ~1% of males.",
|
||||
},
|
||||
{
|
||||
value: "protanomaly",
|
||||
label: "Protanomaly",
|
||||
description: "Reduced red sensitivity. ~1% of males.",
|
||||
},
|
||||
{
|
||||
value: "deuteranopia",
|
||||
label: "Deuteranopia",
|
||||
description: "No green cones. Reds and greens look similar. ~1% of males.",
|
||||
},
|
||||
{
|
||||
value: "deuteranomaly",
|
||||
label: "Deuteranomaly",
|
||||
description: "Reduced green sensitivity. Most common type. ~5% of males.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Blue-Yellow",
|
||||
types: [
|
||||
{
|
||||
value: "tritanopia",
|
||||
label: "Tritanopia",
|
||||
description: "No blue cones. Blues and greens look similar. Very rare.",
|
||||
},
|
||||
{
|
||||
value: "tritanomaly",
|
||||
label: "Tritanomaly",
|
||||
description: "Reduced blue sensitivity. Very rare.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
group: "Monochromatic",
|
||||
types: [
|
||||
{
|
||||
value: "achromatopsia",
|
||||
label: "Achromatopsia",
|
||||
description: "Complete color blindness. Sees only luminance. Very rare.",
|
||||
},
|
||||
{
|
||||
value: "blueConeMonochromacy",
|
||||
label: "Blue Cone Monochromacy",
|
||||
description: "Only blue cones functional. Very limited color. Very rare.",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_MAP = new Map(SIMULATION_TYPES.flatMap((g) => g.types.map((t) => [t.value, t])));
|
||||
|
||||
export function ColorBlindnessSettings() {
|
||||
const { files } = useFileStore();
|
||||
const {
|
||||
processFiles,
|
||||
processAllFiles,
|
||||
processing,
|
||||
error,
|
||||
downloadUrl,
|
||||
originalSize,
|
||||
processedSize,
|
||||
progress,
|
||||
} = useToolProcessor("color-blindness");
|
||||
|
||||
const [simulationType, setSimulationType] = useState("deuteranomaly");
|
||||
const selectedInfo = TYPE_MAP.get(simulationType);
|
||||
|
||||
const handleProcess = () => {
|
||||
const settings = { simulationType };
|
||||
if (files.length > 1) {
|
||||
processAllFiles(files, settings);
|
||||
} else {
|
||||
processFiles(files, settings);
|
||||
}
|
||||
};
|
||||
|
||||
const hasFile = files.length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="cb-simulation-type" className="text-xs text-muted-foreground">
|
||||
Simulation Type
|
||||
</label>
|
||||
<select
|
||||
id="cb-simulation-type"
|
||||
value={simulationType}
|
||||
onChange={(e) => setSimulationType(e.target.value)}
|
||||
className="w-full mt-0.5 px-2 py-1.5 rounded border border-border bg-background text-sm text-foreground"
|
||||
>
|
||||
{SIMULATION_TYPES.map((group) => (
|
||||
<optgroup key={group.group} label={group.group}>
|
||||
{group.types.map((t) => (
|
||||
<option key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
{selectedInfo && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{selectedInfo.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-500">{error}</p>}
|
||||
|
||||
{originalSize != null && processedSize != null && (
|
||||
<div className="text-xs text-muted-foreground space-y-0.5">
|
||||
<p>Original: {(originalSize / 1024).toFixed(1)} KB</p>
|
||||
<p>Processed: {(processedSize / 1024).toFixed(1)} KB</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{processing ? (
|
||||
<ProgressCard
|
||||
active={processing}
|
||||
phase={progress.phase === "idle" ? "uploading" : progress.phase}
|
||||
label="Simulating color blindness"
|
||||
stage={progress.stage}
|
||||
percent={progress.percent}
|
||||
elapsed={progress.elapsed}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
data-testid="color-blindness-submit"
|
||||
onClick={handleProcess}
|
||||
disabled={!hasFile || processing}
|
||||
className="w-full py-2.5 rounded-lg bg-primary text-primary-foreground font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
|
||||
>
|
||||
{files.length > 1 ? `Simulate (${files.length} files)` : "Simulate"}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{downloadUrl && (
|
||||
<a
|
||||
href={downloadUrl}
|
||||
download
|
||||
data-testid="color-blindness-download"
|
||||
className="w-full py-2.5 rounded-lg border border-primary text-primary font-medium flex items-center justify-center gap-2 hover:bg-primary/5"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
Download
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -308,6 +308,11 @@ const TransparencyFixerSettings = lazy(() =>
|
||||
default: m.TransparencyFixerSettings,
|
||||
})),
|
||||
);
|
||||
const ColorBlindnessSettings = lazy(() =>
|
||||
import("@/components/tools/color-blindness-settings").then((m) => ({
|
||||
default: m.ColorBlindnessSettings,
|
||||
})),
|
||||
);
|
||||
|
||||
// ── Color tool wrapper ─────────────────────────────────────────────
|
||||
// Color tools share a single component but differ by toolId.
|
||||
@@ -426,6 +431,7 @@ export const toolRegistry = new Map<string, ToolRegistryEntry>([
|
||||
|
||||
// Adjustments extra
|
||||
["replace-color", { displayMode: "before-after", Settings: ReplaceColorSettings }],
|
||||
["color-blindness", { displayMode: "before-after", Settings: ColorBlindnessSettings }],
|
||||
|
||||
// AI Tools
|
||||
["remove-background", { displayMode: "before-after", Settings: RemoveBgSettings }],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import sharp from "sharp";
|
||||
import { brightness } from "./operations/brightness.js";
|
||||
import { colorBlindness } from "./operations/color-blindness.js";
|
||||
import { colorChannels } from "./operations/color-channels.js";
|
||||
import { compress } from "./operations/compress.js";
|
||||
import { contrast } from "./operations/contrast.js";
|
||||
@@ -17,6 +18,7 @@ import { sharpen, sharpenAdvanced } from "./operations/sharpen.js";
|
||||
import { stripMetadata } from "./operations/strip-metadata.js";
|
||||
import type {
|
||||
BrightnessOptions,
|
||||
ColorBlindnessOptions,
|
||||
ColorChannelOptions,
|
||||
CompressOptions,
|
||||
ContrastOptions,
|
||||
@@ -55,6 +57,7 @@ const OPERATION_MAP: Record<
|
||||
brightness: (img, opts) => brightness(img, opts as unknown as BrightnessOptions),
|
||||
contrast: (img, opts) => contrast(img, opts as unknown as ContrastOptions),
|
||||
saturation: (img, opts) => saturation(img, opts as unknown as SaturationOptions),
|
||||
"color-blindness": (img, opts) => colorBlindness(img, opts as unknown as ColorBlindnessOptions),
|
||||
"color-channels": (img, opts) => colorChannels(img, opts as unknown as ColorChannelOptions),
|
||||
grayscale: (img) => grayscale(img),
|
||||
sepia: (img) => sepia(img),
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from "./engine.js";
|
||||
export * from "./formats/detect.js";
|
||||
export { analyzeImage, applyCorrections, scaleCorrections } from "./operations/auto-enhance.js";
|
||||
export { brightness } from "./operations/brightness.js";
|
||||
export { COLOR_BLINDNESS_MATRICES, colorBlindness } from "./operations/color-blindness.js";
|
||||
export { colorChannels } from "./operations/color-channels.js";
|
||||
export { compress } from "./operations/compress.js";
|
||||
export { contrast } from "./operations/contrast.js";
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ColorBlindnessOptions, ColorBlindnessType, Sharp } from "../types.js";
|
||||
|
||||
type Matrix3x3 = [[number, number, number], [number, number, number], [number, number, number]];
|
||||
|
||||
export const COLOR_BLINDNESS_MATRICES: Record<ColorBlindnessType, Matrix3x3> = {
|
||||
protanopia: [
|
||||
[0.152286, 1.052583, -0.204868],
|
||||
[0.114503, 0.786281, 0.099216],
|
||||
[-0.003882, -0.048116, 1.051998],
|
||||
],
|
||||
deuteranopia: [
|
||||
[0.367322, 0.860646, -0.227968],
|
||||
[0.280085, 0.672501, 0.047413],
|
||||
[-0.01182, 0.04294, 0.968881],
|
||||
],
|
||||
tritanopia: [
|
||||
[1.255528, -0.076749, -0.178779],
|
||||
[-0.078411, 0.930809, 0.147602],
|
||||
[0.004733, 0.691367, 0.3039],
|
||||
],
|
||||
protanomaly: [
|
||||
[0.458064, 0.679578, -0.137642],
|
||||
[0.092785, 0.846313, 0.060902],
|
||||
[-0.007494, -0.016807, 1.024301],
|
||||
],
|
||||
deuteranomaly: [
|
||||
[0.547494, 0.607765, -0.155259],
|
||||
[0.181692, 0.781742, 0.036566],
|
||||
[-0.01041, 0.027275, 0.983136],
|
||||
],
|
||||
tritanomaly: [
|
||||
[1.017277, 0.027029, -0.044306],
|
||||
[-0.006113, 0.958479, 0.047634],
|
||||
[0.006379, 0.248708, 0.744913],
|
||||
],
|
||||
achromatopsia: [
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
[0.2126, 0.7152, 0.0722],
|
||||
],
|
||||
blueConeMonochromacy: [
|
||||
[0.01775, 0.10945, 0.87262],
|
||||
[0.01775, 0.10945, 0.87262],
|
||||
[0.01775, 0.10945, 0.87262],
|
||||
],
|
||||
};
|
||||
|
||||
export async function colorBlindness(image: Sharp, options: ColorBlindnessOptions): Promise<Sharp> {
|
||||
return image.recomb(COLOR_BLINDNESS_MATRICES[options.type]);
|
||||
}
|
||||
@@ -190,3 +190,17 @@ export interface OptimizeForWebOptions {
|
||||
progressive?: boolean;
|
||||
stripMetadata?: boolean;
|
||||
}
|
||||
|
||||
export type ColorBlindnessType =
|
||||
| "protanopia"
|
||||
| "deuteranopia"
|
||||
| "tritanopia"
|
||||
| "protanomaly"
|
||||
| "deuteranomaly"
|
||||
| "tritanomaly"
|
||||
| "achromatopsia"
|
||||
| "blueConeMonochromacy";
|
||||
|
||||
export interface ColorBlindnessOptions {
|
||||
type: ColorBlindnessType;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,14 @@ export const TOOLS: Tool[] = [
|
||||
icon: "Pipette",
|
||||
route: "/replace-color",
|
||||
},
|
||||
{
|
||||
id: "color-blindness",
|
||||
name: "Color Blindness Simulation",
|
||||
description: "Simulate how images appear with color vision deficiency",
|
||||
category: "adjustments",
|
||||
icon: "Eye",
|
||||
route: "/color-blindness",
|
||||
},
|
||||
// AI Tools
|
||||
{
|
||||
id: "remove-background",
|
||||
|
||||
@@ -72,6 +72,10 @@ export const en = {
|
||||
name: "Replace & Invert Color",
|
||||
description: "Replace specific colors or invert",
|
||||
},
|
||||
"color-blindness": {
|
||||
name: "Color Blindness Simulation",
|
||||
description: "Simulate how images appear with different types of color vision deficiency",
|
||||
},
|
||||
"remove-background": {
|
||||
name: "Remove Background",
|
||||
description: "AI-powered background removal",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { expect, test, uploadTestImage, waitForProcessing } from "./helpers";
|
||||
|
||||
test.describe("Color Blindness Simulation tool", () => {
|
||||
test("upload -> simulate -> download cycle", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/color-blindness");
|
||||
await expect(page.getByText("Color Blindness Simulation").first()).toBeVisible();
|
||||
|
||||
await uploadTestImage(page);
|
||||
await expect(page.getByText("Upload from computer")).not.toBeVisible();
|
||||
|
||||
const dropdown = page.locator("#cb-simulation-type");
|
||||
await expect(dropdown).toBeVisible();
|
||||
await expect(dropdown).toHaveValue("deuteranomaly");
|
||||
|
||||
await dropdown.selectOption("protanopia");
|
||||
await expect(dropdown).toHaveValue("protanopia");
|
||||
|
||||
await page.getByTestId("color-blindness-submit").click();
|
||||
await waitForProcessing(page);
|
||||
|
||||
const downloadBtn = page.getByTestId("color-blindness-download");
|
||||
await expect(downloadBtn).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await downloadBtn.click();
|
||||
const download = await downloadPromise;
|
||||
expect(download.suggestedFilename()).toBeTruthy();
|
||||
|
||||
const downloadPath = path.join(
|
||||
process.cwd(),
|
||||
"test-results",
|
||||
"download-color-blindness-result",
|
||||
);
|
||||
await download.saveAs(downloadPath);
|
||||
const stat = fs.statSync(downloadPath);
|
||||
expect(stat.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("shows type description when selection changes", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/color-blindness");
|
||||
await uploadTestImage(page);
|
||||
|
||||
const dropdown = page.locator("#cb-simulation-type");
|
||||
await dropdown.selectOption("achromatopsia");
|
||||
|
||||
await expect(page.getByText("Complete color blindness", { exact: false })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
||||
|
||||
const FIXTURES = join(__dirname, "..", "fixtures");
|
||||
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||
|
||||
const ALL_TYPES = [
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"protanomaly",
|
||||
"deuteranomaly",
|
||||
"tritanomaly",
|
||||
"achromatopsia",
|
||||
"blueConeMonochromacy",
|
||||
] as const;
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
function makePayload(
|
||||
settings: Record<string, unknown>,
|
||||
buffer: Buffer = PNG,
|
||||
filename = "test.png",
|
||||
contentType = "image/png",
|
||||
) {
|
||||
return createMultipartPayload([
|
||||
{ name: "file", filename, contentType, content: buffer },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
}
|
||||
|
||||
async function postTool(
|
||||
settings: Record<string, unknown>,
|
||||
buffer?: Buffer,
|
||||
filename?: string,
|
||||
ct?: string,
|
||||
) {
|
||||
const { body: payload, contentType } = makePayload(settings, buffer, filename, ct);
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/color-blindness",
|
||||
payload,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Default settings", () => {
|
||||
it("processes with default settings (deuteranomaly)", async () => {
|
||||
const res = await postTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("All 8 simulation types", () => {
|
||||
for (const type of ALL_TYPES) {
|
||||
it(`processes with simulationType=${type}`, async () => {
|
||||
const res = await postTool({ simulationType: type });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
}
|
||||
|
||||
it("different types produce different outputs", async () => {
|
||||
const buffers: Buffer[] = [];
|
||||
for (const type of ["protanopia", "tritanopia", "achromatopsia"] as const) {
|
||||
const res = await postTool({ simulationType: type });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
buffers.push(dlRes.rawPayload);
|
||||
}
|
||||
const pixelSets = await Promise.all(
|
||||
buffers.map(async (buf) => {
|
||||
const { data } = await sharp(buf).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
return `${data[0]},${data[1]},${data[2]}`;
|
||||
}),
|
||||
);
|
||||
const unique = new Set(pixelSets);
|
||||
expect(unique.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dimension preservation", () => {
|
||||
it("output has same dimensions as input", async () => {
|
||||
const res = await postTool({ simulationType: "protanopia" });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.width).toBe(200);
|
||||
expect(meta.height).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple input formats", () => {
|
||||
it("processes JPEG input", async () => {
|
||||
const res = await postTool({ simulationType: "deuteranopia" }, JPG, "test.jpg", "image/jpeg");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("processes WebP input", async () => {
|
||||
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
||||
const res = await postTool({ simulationType: "tritanopia" }, WEBP, "test.webp", "image/webp");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("processes HEIC input", { timeout: 120_000 }, async () => {
|
||||
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
|
||||
const res = await postTool({ simulationType: "protanomaly" }, HEIC, "photo.heic", "image/heic");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("processes SVG input", async () => {
|
||||
const SVG = readFileSync(join(FIXTURES, "test-100x100.svg"));
|
||||
const res = await postTool(
|
||||
{ simulationType: "achromatopsia" },
|
||||
SVG,
|
||||
"icon.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("processes animated GIF input", async () => {
|
||||
const GIF = readFileSync(join(FIXTURES, "animated.gif"));
|
||||
const res = await postTool({ simulationType: "deuteranomaly" }, GIF, "anim.gif", "image/gif");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("returns 400 when no file is provided", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "settings", content: JSON.stringify({ simulationType: "protanopia" }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/color-blindness",
|
||||
payload,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid simulationType value", async () => {
|
||||
const res = await postTool({ simulationType: "invalid-type" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("processes 1x1 pixel image", async () => {
|
||||
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
|
||||
const res = await postTool({ simulationType: "deuteranomaly" }, TINY, "tiny.png", "image/png");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("processes stress-large.jpg", async () => {
|
||||
const LARGE = readFileSync(join(FIXTURES, "content", "stress-large.jpg"));
|
||||
const res = await postTool({ simulationType: "protanopia" }, LARGE, "large.jpg", "image/jpeg");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("rejects unauthenticated request", async () => {
|
||||
const { body: payload, contentType } = makePayload({ simulationType: "protanopia" });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/color-blindness",
|
||||
payload,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { ColorBlindnessType } from "@snapotter/image-engine";
|
||||
import { COLOR_BLINDNESS_MATRICES, colorBlindness } from "@snapotter/image-engine";
|
||||
import sharp from "sharp";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ALL_TYPES: ColorBlindnessType[] = [
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"protanomaly",
|
||||
"deuteranomaly",
|
||||
"tritanomaly",
|
||||
"achromatopsia",
|
||||
"blueConeMonochromacy",
|
||||
];
|
||||
|
||||
function makeColorImage(r: number, g: number, b: number): sharp.Sharp {
|
||||
return sharp({
|
||||
create: { width: 10, height: 10, channels: 3, background: { r, g, b } },
|
||||
}).png();
|
||||
}
|
||||
|
||||
describe("Color blindness matrices", () => {
|
||||
it("all 8 types have a valid 3x3 matrix", () => {
|
||||
for (const type of ALL_TYPES) {
|
||||
const matrix = COLOR_BLINDNESS_MATRICES[type];
|
||||
expect(matrix).toBeDefined();
|
||||
expect(matrix).toHaveLength(3);
|
||||
for (const row of matrix) {
|
||||
expect(row).toHaveLength(3);
|
||||
for (const val of row) {
|
||||
expect(Number.isFinite(val)).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("matrix row sums are in a reasonable range (0 to 1.5)", () => {
|
||||
for (const type of ALL_TYPES) {
|
||||
const matrix = COLOR_BLINDNESS_MATRICES[type];
|
||||
for (const row of matrix) {
|
||||
const sum = row[0] + row[1] + row[2];
|
||||
expect(sum).toBeGreaterThanOrEqual(0);
|
||||
expect(sum).toBeLessThanOrEqual(1.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("colorBlindness operation", () => {
|
||||
it("returns a Sharp instance for each type", async () => {
|
||||
for (const type of ALL_TYPES) {
|
||||
const result = await colorBlindness(makeColorImage(255, 0, 0), { type });
|
||||
const buf = await result.toBuffer();
|
||||
expect(buf.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("different types produce different outputs on a red image", async () => {
|
||||
const outputs = new Map<string, Buffer>();
|
||||
for (const type of ALL_TYPES) {
|
||||
const result = await colorBlindness(makeColorImage(255, 0, 0), { type });
|
||||
const { data } = await result.removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
outputs.set(type, Buffer.from(data));
|
||||
}
|
||||
const uniqueOutputs = new Set([...outputs.values()].map((b) => `${b[0]},${b[1]},${b[2]}`));
|
||||
expect(uniqueOutputs.size).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("achromatopsia produces grayscale output (R === G === B)", async () => {
|
||||
const result = await colorBlindness(makeColorImage(255, 0, 0), {
|
||||
type: "achromatopsia",
|
||||
});
|
||||
const { data } = await result.removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
expect(data[0]).toBe(data[1]);
|
||||
expect(data[1]).toBe(data[2]);
|
||||
});
|
||||
|
||||
it("preserves image dimensions", async () => {
|
||||
const result = await colorBlindness(makeColorImage(100, 150, 200), {
|
||||
type: "deuteranomaly",
|
||||
});
|
||||
const meta = await result.metadata();
|
||||
expect(meta.width).toBe(10);
|
||||
expect(meta.height).toBe(10);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user