2026-04-13 17:48:53 +08:00
|
|
|
import { readFileSync } from "node:fs";
|
|
|
|
|
import { join } from "node:path";
|
2026-04-24 18:02:21 +08:00
|
|
|
import { analyzeImage, applyCorrections, scaleCorrections } from "@snapotter/image-engine";
|
2026-04-13 17:48:53 +08:00
|
|
|
import sharp from "sharp";
|
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
|
|
|
|
|
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
|
|
|
|
const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|
|
|
|
|
|
|
|
|
describe("analyzeImage", () => {
|
|
|
|
|
it("returns scores, corrections, issues, and suggestedMode", async () => {
|
|
|
|
|
const result = await analyzeImage(PNG_200x150);
|
|
|
|
|
expect(result.scores).toBeDefined();
|
|
|
|
|
expect(result.corrections).toBeDefined();
|
|
|
|
|
expect(result.issues).toBeInstanceOf(Array);
|
|
|
|
|
expect(result.suggestedMode).toBeDefined();
|
|
|
|
|
|
|
|
|
|
for (const key of Object.keys(result.scores) as (keyof typeof result.scores)[]) {
|
|
|
|
|
expect(result.scores[key]).toBeGreaterThanOrEqual(0);
|
|
|
|
|
expect(result.scores[key]).toBeLessThanOrEqual(100);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("detects underexposure on a dark image", async () => {
|
|
|
|
|
const darkBuffer = await sharp({
|
|
|
|
|
create: { width: 100, height: 100, channels: 3, background: { r: 20, g: 20, b: 20 } },
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(darkBuffer);
|
|
|
|
|
expect(result.scores.exposure).toBeLessThan(30);
|
|
|
|
|
expect(result.issues).toContain("underexposed");
|
|
|
|
|
expect(result.corrections.brightness).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("detects overexposure on a bright image", async () => {
|
|
|
|
|
const brightBuffer = await sharp({
|
|
|
|
|
create: { width: 100, height: 100, channels: 3, background: { r: 240, g: 240, b: 240 } },
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(brightBuffer);
|
|
|
|
|
expect(result.scores.exposure).toBeGreaterThan(70);
|
|
|
|
|
expect(result.issues).toContain("overexposed");
|
|
|
|
|
expect(result.corrections.brightness).toBeLessThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("detects low contrast on a flat image", async () => {
|
|
|
|
|
const flatBuffer = await sharp({
|
|
|
|
|
create: { width: 100, height: 100, channels: 3, background: { r: 128, g: 128, b: 128 } },
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(flatBuffer);
|
|
|
|
|
expect(result.scores.contrast).toBeLessThan(40);
|
|
|
|
|
expect(result.corrections.contrast).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("handles grayscale images without white balance issues", async () => {
|
|
|
|
|
const grayBuffer = await sharp({
|
|
|
|
|
create: { width: 100, height: 100, channels: 3, background: { r: 128, g: 128, b: 128 } },
|
|
|
|
|
})
|
|
|
|
|
.grayscale()
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(grayBuffer);
|
|
|
|
|
expect(result.scores.whiteBalance).toBe(50);
|
|
|
|
|
// Grayscale PNG from .grayscale() retains 3 channels with zero spread,
|
|
|
|
|
// so saturation formula yields channelSpread * 1.2 + 20 = 20
|
|
|
|
|
expect(result.scores.saturation).toBe(20);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("suggests low-light mode for very dark images", async () => {
|
|
|
|
|
const darkBuffer = await sharp({
|
|
|
|
|
create: { width: 100, height: 100, channels: 3, background: { r: 15, g: 15, b: 15 } },
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(darkBuffer);
|
|
|
|
|
expect(result.suggestedMode).toBe("low-light");
|
|
|
|
|
});
|
2026-05-09 11:07:36 +08:00
|
|
|
|
|
|
|
|
it("produces contrast score near 50 for a typical well-exposed image", async () => {
|
|
|
|
|
// A gradient image has stdev ~60, which should score ~50
|
|
|
|
|
const gradientBuffer = await sharp(
|
|
|
|
|
Buffer.from(
|
|
|
|
|
Array.from({ length: 100 * 100 * 3 }, (_, i) => Math.floor(((i % 300) * 255) / 300)),
|
|
|
|
|
),
|
|
|
|
|
{ raw: { width: 100, height: 100, channels: 3 } },
|
|
|
|
|
)
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(gradientBuffer);
|
|
|
|
|
expect(result.scores.contrast).toBeGreaterThanOrEqual(35);
|
|
|
|
|
expect(result.scores.contrast).toBeLessThanOrEqual(65);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("produces near-zero corrections for well-exposed images (dead zone)", async () => {
|
|
|
|
|
// Wide spread centered at 128 gives stdev ~60 (contrast ~50) and mean ~128 (exposure ~50).
|
|
|
|
|
// Both scores land inside the [40,60] dead zone, so corrections should be zero.
|
|
|
|
|
const midGrayBuffer = await sharp(
|
2026-05-09 11:13:04 +08:00
|
|
|
Buffer.from(Array.from({ length: 100 * 100 * 3 }, (_, i) => 24 + ((i * 97) % 208))),
|
2026-05-09 11:07:36 +08:00
|
|
|
{ raw: { width: 100, height: 100, channels: 3 } },
|
|
|
|
|
)
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
|
|
|
|
|
const result = await analyzeImage(midGrayBuffer);
|
|
|
|
|
// Corrections should be zero or near-zero in the dead zone
|
|
|
|
|
expect(Math.abs(result.corrections.brightness)).toBeLessThanOrEqual(5);
|
|
|
|
|
expect(Math.abs(result.corrections.contrast)).toBeLessThanOrEqual(5);
|
|
|
|
|
});
|
2026-04-13 17:48:53 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("scaleCorrections", () => {
|
|
|
|
|
it("scales corrections by intensity 50 (1x) without change", () => {
|
|
|
|
|
const base = {
|
|
|
|
|
brightness: 20,
|
|
|
|
|
contrast: 10,
|
|
|
|
|
temperature: 5,
|
|
|
|
|
saturation: 15,
|
|
|
|
|
sharpness: 30,
|
|
|
|
|
denoise: 3,
|
|
|
|
|
};
|
|
|
|
|
const scaled = scaleCorrections(base, "auto", 50);
|
|
|
|
|
expect(scaled.brightness).toBe(20);
|
|
|
|
|
expect(scaled.contrast).toBe(10);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("scales corrections to zero at intensity 0", () => {
|
|
|
|
|
const base = {
|
|
|
|
|
brightness: 20,
|
|
|
|
|
contrast: 10,
|
|
|
|
|
temperature: 5,
|
|
|
|
|
saturation: 15,
|
|
|
|
|
sharpness: 30,
|
|
|
|
|
denoise: 3,
|
|
|
|
|
};
|
|
|
|
|
const scaled = scaleCorrections(base, "auto", 0);
|
|
|
|
|
expect(scaled.brightness).toBe(0);
|
|
|
|
|
expect(scaled.contrast).toBe(0);
|
|
|
|
|
expect(scaled.sharpness).toBe(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("applies preset multipliers for portrait mode", () => {
|
|
|
|
|
const base = {
|
|
|
|
|
brightness: 20,
|
|
|
|
|
contrast: 10,
|
|
|
|
|
temperature: 5,
|
|
|
|
|
saturation: 15,
|
|
|
|
|
sharpness: 30,
|
|
|
|
|
denoise: 3,
|
|
|
|
|
};
|
|
|
|
|
const scaled = scaleCorrections(base, "portrait", 50);
|
|
|
|
|
expect(scaled.brightness).toBe(16);
|
|
|
|
|
expect(scaled.contrast).toBe(7);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
describe("applyCorrections", () => {
|
|
|
|
|
it("produces a valid output buffer", async () => {
|
|
|
|
|
const corrections = {
|
|
|
|
|
brightness: -20,
|
|
|
|
|
contrast: 10,
|
|
|
|
|
temperature: 0,
|
|
|
|
|
saturation: 10,
|
|
|
|
|
sharpness: 20,
|
|
|
|
|
denoise: 0,
|
|
|
|
|
};
|
|
|
|
|
const image = sharp(PNG_200x150);
|
|
|
|
|
const enhanced = applyCorrections(image, corrections, "auto", 50, {});
|
|
|
|
|
const buffer = await enhanced.toBuffer();
|
|
|
|
|
expect(buffer.length).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("respects toggle overrides", async () => {
|
|
|
|
|
const corrections = {
|
|
|
|
|
brightness: 40,
|
|
|
|
|
contrast: 30,
|
|
|
|
|
temperature: 20,
|
|
|
|
|
saturation: 20,
|
|
|
|
|
sharpness: 30,
|
|
|
|
|
denoise: 3,
|
|
|
|
|
};
|
|
|
|
|
const toggles = {
|
|
|
|
|
exposure: false,
|
|
|
|
|
contrast: false,
|
|
|
|
|
whiteBalance: false,
|
|
|
|
|
saturation: false,
|
|
|
|
|
sharpness: false,
|
|
|
|
|
denoise: false,
|
|
|
|
|
};
|
|
|
|
|
const image = sharp(PNG_200x150);
|
|
|
|
|
const enhanced = applyCorrections(image, corrections, "auto", 50, toggles);
|
|
|
|
|
const enhancedBuf = await enhanced.toBuffer();
|
|
|
|
|
const originalMeta = await sharp(PNG_200x150).metadata();
|
|
|
|
|
const enhancedMeta = await sharp(enhancedBuf).metadata();
|
|
|
|
|
expect(enhancedMeta.width).toBe(originalMeta.width);
|
|
|
|
|
expect(enhancedMeta.height).toBe(originalMeta.height);
|
|
|
|
|
});
|
|
|
|
|
});
|