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 = {} ): QuantiseResult { return quantise(pixels, width, height, { colours: recommendedColours, algorithm: "wuquant", distance: "euclidean", dither: recommendDithering ? "floyd-steinberg" : "none", ...overrides, }); }