import { HDRImage } from "./HDRImage";
import { isSafari } from "./browser";
import { WASM_MIN_VALUES, isWasmEnabled, tryApplyGainWasm } from "./wasm";
import { f16round } from "@petamoriken/float16";
import type { HDRPredefinedColorSpace, HDRImageData, HDRImagePixelCallback } from "./types/HDRCanvas.d.ts";
/**
* Represents an image using a `Float16Array` for its pixel data,
* providing support for high dynamic range (HDR) color spaces.
*/
export class Float16Image extends HDRImage {
/** The raw pixel data stored as a `Float16Array`. */
data: Float16Array;
/** The default pixel format for new images, set to "rgba-float16". */
static DEFAULT_PIXELFORMAT: ImageDataPixelFormat = "rgba-float16";
/** The color space of the image. */
colorSpace: HDRPredefinedColorSpace;
/** The pixel format of the image - usualy 'rgba-float16'. */
pixelFormat: ImageDataPixelFormat;
/**
* Creates a new `Float16Image` instance.
*
* @param {number} width - The width of the image in pixels.
* @param {number} height - The height of the image in pixels.
* @param {string} [colorspace] - The color space to use for the image. Defaults to `HDRImage.DEFAULT_COLORSPACE`.
* @param {string} [pixelFormat] - The pixel format to use for the image. Defaults to `DEFAULT_PIXELFORMAT`.
*/
constructor(width: number, height: number, colorspace?: string, pixelFormat?: string) {
super(width, height);
if (colorspace === undefined || colorspace === null) {
this.colorSpace = Float16Image.DEFAULT_COLORSPACE;
} else {
this.colorSpace = colorspace as HDRPredefinedColorSpace;
}
if (pixelFormat === undefined || pixelFormat === null || (pixelFormat !== "rgba-unorm8" && pixelFormat !== "rgba-float16")) {
pixelFormat = Float16Image.DEFAULT_PIXELFORMAT;
}
this.pixelFormat = pixelFormat as ImageDataPixelFormat;
this.data = new Float16Array(height * width * 4);
}
/**
* Fills the entire image with a single color.
*
* @param {number[]} color - An array of four numbers representing the R, G, B, and A channels
* as float values. `0-1` is the SDR range, values `>1` push into the HDR headroom
* (requires an HDR canvas with `extended` tone mapping to become visible).
* @returns {Float16Image | undefined} The `Float16Image` instance for method chaining, or `undefined` if the color array is invalid.
*/
fill(color: number[]): this | undefined {
if (color.length != 4) {
return;
}
for (let i = 0; i < this.data.length; i += 4) {
this.data[i] = color[0];
this.data[i + 1] = color[1];
this.data[i + 2] = color[2];
this.data[i + 3] = color[3];
}
return this;
}
// Only use this for alpha, since it doesn't to color space conversions
/**
* Scales an 8-bit value to a 16-bit value. This is typically used for the alpha channel.
*
* @param {number} val - The 8-bit value to scale (0-255).
* @returns {number} The corresponding 16-bit value.
*/
static scaleUint8ToFloat16(val: number): number {
return (val << 8) | val;
}
/**
* Scales RGB channels into the HDR range by a gain factor.
* This is the lightweight replacement for the removed `colorjs.io` based
* `convertPixelToRec2100_hlg`: no gamut conversion, just a linear boost.
* Values `>1` only become visible as HDR on a canvas configured with
* `toneMapping: { mode: "extended" }` (see `getHdrOptions()`), otherwise
* they clip to SDR white.
*
* Alpha is preserved by default.
*
* When WASM acceleration is enabled via `setWasmEnabled(true)` (experimental,
* disabled by default) and the image is large enough, the loop runs in the
* inlined WASM kernel with transparent fallback to JS.
*
* @param {number|Array<number>} gain - Uniform gain or per-channel `[r, g, b]` gain.
* E.g. `2` doubles brightness into HDR headroom, `0.5` dims to half.
* @param {object} [options] - Options.
* @param {boolean} [options.preserveAlpha=true] - Leave alpha untouched when `true`.
* @param {boolean} [options.useWasm] - Force the WASM (`true`) or JS (`false`) path for this call.
* Defaults to the global flag from `setWasmEnabled()`.
* @returns {this} The `Float16Image` instance for method chaining.
*/
applyGain(gain: number | [number, number, number], options?: { preserveAlpha?: boolean; useWasm?: boolean }): this {
const preserveAlpha = options?.preserveAlpha ?? true;
let gr: number, gg: number, gb: number;
if (typeof gain === "number") {
gr = gg = gb = gain;
} else {
[gr, gg, gb] = gain;
}
const ga = !preserveAlpha && typeof gain === "number" ? gain : 1;
const useWasm = options?.useWasm ?? isWasmEnabled();
if (useWasm && this.data.length >= WASM_MIN_VALUES && tryApplyGainWasm(this.data, gr, gg, gb, ga)) {
return this;
}
for (let i = 0; i < this.data.length; i += 4) {
this.data[i] = this.data[i] * gr;
this.data[i + 1] = this.data[i + 1] * gg;
this.data[i + 2] = this.data[i + 2] * gb;
if (ga !== 1) {
this.data[i + 3] = this.data[i + 3] * ga;
}
}
return this;
}
/**
* Returns a copy of the image with `applyGain()` applied, leaving the original untouched.
*
* @param {number|Array<number>} gain - Uniform gain or per-channel `[r, g, b]` gain.
* @param {object} [options] - Options, see `applyGain()`.
* @returns {Float16Image} A new `Float16Image` with scaled RGB values.
*/
scaled(gain: number | [number, number, number], options?: { preserveAlpha?: boolean; useWasm?: boolean }): Float16Image {
return this.clone().applyGain(gain, options) as Float16Image;
}
// See https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/core/html/canvas/image_data.idl
/**
* Creates a standard `ImageData` object from the `Float16Image` data.
*
* @returns {ImageData | null} An `ImageData` object, or `null` if the data is undefined.
*/
getImageData(): ImageData | null {
if (this.data === undefined || this.data === null) {
return null;
}
try {
const imageDataSettings = {
colorSpace: this.colorSpace as PredefinedColorSpace,
pixelFormat: this.pixelFormat as ImageDataPixelFormat
};
if (isSafari()) {
imageDataSettings["colorSpace"] = "display-p3";
}
return new ImageData(this.data as unknown as ImageDataArray, this.width, this.height, {
...imageDataSettings
} as ImageDataSettings) as ImageData;
} catch (e) {
console.error("Can't create Float16Array Image data, not supported by the browser", e);
}
return null;
}
/**
* Converts a `Uint8ClampedArray` of sRGB pixel data to a `Float16Array`
* of pixels in the `rec2100-hlg` color space.
*
* @param {Uint8ClampedArray} data - The array of 8-bit pixel data.
* @returns {Float16Array} The converted 16-bit pixel data.
*/
static convertArrayToRec2100_hlg(data: Uint8ClampedArray): Float16Array {
const float16Array = new Float16Array(data.length);
for (let i = 0; i < data.length; i++) {
const normalizedFloat = data[i] / 255.0;
float16Array[i] = f16round(normalizedFloat);
}
return float16Array;
}
/**
* Iterates through each pixel of the image and applies a callback function to its data.
*
* @param {HDRPixelCallback} fn - The callback function to apply to each pixel.
*/
pixelCallback(fn: HDRImagePixelCallback) {
for (let i = 0; i < this.data.length; i += 4) {
const pixel = fn(this.data[i], this.data[i + 1], this.data[i + 2], this.data[i + 3]);
//TODO: Check if we should implicitly operate on int pixel values
/*
for (let i = 0; i < pixel.length; i++) {
const normalizedFloat = pixel[i] / 255.0;
pixel[i] = f16round(normalizedFloat);
}
*/
this.data.set(pixel, i);
}
}
/**
* Creates a `Float16Image` instance from an `HDRImageData` object.
*
* @param {HDRImageData} imageData - The image data to use.
* @returns {Float16Image} The new `Float16Image` instance.
* @throws {Error} If the color space of the `HDRImageData` is not supported.
*/
static fromImageData(imageData: HDRImageData): Float16Image {
const i = new Float16Image(imageData.width, imageData.height);
if (imageData.colorSpace == "srgb") {
i.data = Float16Image.convertArrayToRec2100_hlg(<Uint8ClampedArray>imageData.data);
} else if (imageData.colorSpace == HDRImage.DEFAULT_COLORSPACE) {
i.data = <Float16Array>imageData.data;
} else {
throw new Error(`ColorSpace ${imageData.colorSpace} isn't supported!`);
}
return i;
}
/**
* Creates a `Float16Image` instance from an `Uint8ClampedArray` object.
*
* @param {number} width - The width of the image.
* @param {number} height - The height of the image.
* @param {HDRImageData} imageData - The image data to use.
* @returns {Float16Image} The new `Float16Image` instance.
* @throws {Error} If the color space of the `HDRImageData` is not supported.
*/
static fromImageDataArray(
width: number,
height: number,
imageDataArray: Uint8ClampedArray | Uint8ClampedArray<ArrayBufferLike>
): Float16Image {
//const colorSpace == "srgb";
const i = new Float16Image(width, height);
//if (imageData.colorSpace == "srgb") {
i.data = Float16Image.convertArrayToRec2100_hlg(<Uint8ClampedArray>imageDataArray);
// } else if (imageData.colorSpace == HDRImage.DEFAULT_COLORSPACE) {
// i.data = <Float16Array>imageData.data;
// } else {
// throw new Error(`ColorSpace ${imageData.colorSpace} isn't supported!`);
// }
return i;
}
/**
* Loads an image from a URL and creates a `Float16Image` instance from it.
*
* @param {URL} url - The URL of the image to load.
* @returns {Promise<Float16Image | undefined>} A promise that resolves with a `Float16Image` instance, or `undefined` if the image could not be loaded.
*/
static async fromURL(url: URL): Promise<Float16Image | undefined> {
return Float16Image.loadSDRImageData(url).then((data: HDRImageData | undefined) => {
if (data !== undefined) {
return Float16Image.fromImageData(data);
}
});
}
/**
* Sets the image data of the current `Float16Image` instance.
*
* @param {HDRImageData} imageData - The image data to set.
* @throws {Error} If the color space of the `HDRImageData` is not supported.
*/
setImageData(imageData: HDRImageData): void {
this.width = imageData.width;
this.height = imageData.height;
if (imageData.colorSpace == "srgb") {
this.data = Float16Image.convertArrayToRec2100_hlg(<Uint8ClampedArray>imageData.data);
} else if (imageData.colorSpace == HDRImage.DEFAULT_COLORSPACE) {
this.data = <Float16Array>imageData.data;
} else {
throw new Error(`ColorSpace ${imageData.colorSpace} isn't supported!`);
}
this.colorSpace = HDRImage.DEFAULT_COLORSPACE;
}
/**
* Creates a deep clone of the current `Float16Image` instance.
*
* @returns {Float16Image} A new `Float16Image` instance with a copy of the data.
* @private
*/
clone(): this {
const c = new Float16Image(this.width, this.height, this.colorSpace, this.pixelFormat);
c.data = this.data.slice();
return c as this;
}
}
Source