192 lines
5.5 KiB
TypeScript
192 lines
5.5 KiB
TypeScript
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,
|
|
});
|
|
}
|