Initial Commit

This commit is contained in:
2026-05-27 23:47:53 +01:00
parent fbc70f96e6
commit 9b28db724f
18103 changed files with 1600554 additions and 0 deletions

View File

@@ -0,0 +1,191 @@
import type { Counter, Histogram } from "@opentelemetry/api";
import { metrics } from "@opentelemetry/api";
import type { ImageQuantization } from "image-q";
import { applyPaletteSync, buildPaletteSync, utils } from "image-q";
import {
DEFAULT_ALGORITHM,
DEFAULT_COLOURS,
DEFAULT_DISTANCE,
DEFAULT_DITHER,
} from "../constants/index.js";
import {
QUANTISATION_CODES,
QuantisationError,
} from "../errors/image-quantisation-error.js";
import { QuantiseOptionsSchema } from "../schemas/options.schema.js";
import type {
DitherAlgorithm,
QuantiseOptions,
QuantiseResult,
RGBA,
} from "../types/common.js";
/** OTel metric names. */
const METRIC_QUANTISE_TOTAL = "image_quantisation.quantise.total";
const METRIC_QUANTISE_DURATION_MS = "image_quantisation.quantise.duration_ms";
let quantiseCounter: Counter | undefined;
let quantiseDuration: Histogram | undefined;
function getCounter(): Counter {
if (!quantiseCounter) {
const meter = metrics.getMeter("@suaveplan/image-quantisation");
quantiseCounter = meter.createCounter(METRIC_QUANTISE_TOTAL, {
description: "Total number of quantise() invocations",
});
}
return quantiseCounter;
}
function getDuration(): Histogram {
if (!quantiseDuration) {
const meter = metrics.getMeter("@suaveplan/image-quantisation");
quantiseDuration = meter.createHistogram(METRIC_QUANTISE_DURATION_MS, {
description: "Duration of quantise() in milliseconds",
unit: "ms",
});
}
return quantiseDuration;
}
/** Map our dither names to image-q's imageQuantization strings. */
function mapDither(d: DitherAlgorithm): ImageQuantization {
switch (d) {
case "floyd-steinberg":
return "floyd-steinberg";
case "riemersma":
return "riemersma";
/* istanbul ignore next — DitherAlgorithm union is exhaustive */
default:
return "nearest";
}
}
/**
* Validate a pixel buffer and dimensions.
*/
function validateInput(
pixels: Uint8Array,
width: number,
height: number
): void {
if (width <= 0 || height <= 0) {
throw new QuantisationError(
QUANTISATION_CODES.INVALID_DIMENSIONS,
"quantisation.invalid_dimensions",
{ cause: new Error(`width=${width}, height=${height}`) }
);
}
if (pixels.length !== width * height * 4) {
throw new QuantisationError(
QUANTISATION_CODES.INVALID_PIXEL_BUFFER,
"quantisation.invalid_pixel_buffer",
{
cause: new Error(
`expected ${width * height * 4} bytes, got ${pixels.length}`
),
}
);
}
}
/**
* Quantise an RGBA pixel buffer down to a target number of colours.
* Wraps image-q's sync API with ergonomic options.
*/
export function quantise(
pixels: Uint8Array,
width: number,
height: number,
options: QuantiseOptions = {}
): QuantiseResult {
const start = performance.now();
validateInput(pixels, width, height);
const parsed = QuantiseOptionsSchema.safeParse(options);
if (!parsed.success) {
const issues = parsed.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join("; ");
throw new QuantisationError(
QUANTISATION_CODES.PROCESSING_FAILED,
`quantisation.invalid_options: ${issues}`
);
}
const opts = {
colours: parsed.data.colours ?? DEFAULT_COLOURS,
algorithm: parsed.data.algorithm ?? DEFAULT_ALGORITHM,
distance: parsed.data.distance ?? DEFAULT_DISTANCE,
dither: parsed.data.dither ?? DEFAULT_DITHER,
};
const inContainer = utils.PointContainer.fromUint8Array(
pixels,
width,
height
);
const palette = buildPaletteSync([inContainer], {
colorDistanceFormula: opts.distance,
paletteQuantization: opts.algorithm,
colors: opts.colours,
});
const outContainer = applyPaletteSync(inContainer, palette, {
colorDistanceFormula: opts.distance,
imageQuantization: mapDither(opts.dither),
});
const outPixels = outContainer.toUint8Array();
const palettePoints = palette.getPointContainer().getPointArray();
const paletteColours: RGBA[] = palettePoints.map((p) => ({
r: p.r,
g: p.g,
b: p.b,
a: p.a,
}));
const duration = performance.now() - start;
const counter = getCounter();
const histogram = getDuration();
counter.add(1, {
"quantisation.algorithm": opts.algorithm,
"quantisation.dither": opts.dither,
});
histogram.record(duration, {
"quantisation.algorithm": opts.algorithm,
"quantisation.dither": opts.dither,
});
return {
pixels: outPixels,
width,
height,
palette: paletteColours,
paletteSize: paletteColours.length,
options: opts,
};
}
/**
* Quantise with automatic options based on complexity tier.
* Uses the recommended colour count and dithering preference.
*/
export function quantiseAdaptive(
pixels: Uint8Array,
width: number,
height: number,
recommendedColours: number,
recommendDithering: boolean,
overrides: Partial<QuantiseOptions> = {}
): QuantiseResult {
return quantise(pixels, width, height, {
colours: recommendedColours,
algorithm: "wuquant",
distance: "euclidean",
dither: recommendDithering ? "floyd-steinberg" : "none",
...overrides,
});
}