diff options
| author | Leo Kettmeir <crowlkats@toaxl.com> | 2024-01-22 12:08:01 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2024-01-22 12:08:01 +0100 |
| commit | 8f767627938ef10802864419061e58a8a75db567 (patch) | |
| tree | 81f61ba0f8c14fb820a72500840eb0c619d54362 /ext/canvas | |
| parent | b4990d1aa233db662cf22d7f872d45b3a947e0f6 (diff) | |
feat(web): ImageBitmap (#21898)
Diffstat (limited to 'ext/canvas')
| -rw-r--r-- | ext/canvas/01_image.js | 552 | ||||
| -rw-r--r-- | ext/canvas/Cargo.toml | 21 | ||||
| -rw-r--r-- | ext/canvas/README.md | 3 | ||||
| -rw-r--r-- | ext/canvas/lib.deno_canvas.d.ts | 87 | ||||
| -rw-r--r-- | ext/canvas/lib.rs | 153 |
5 files changed, 816 insertions, 0 deletions
diff --git a/ext/canvas/01_image.js b/ext/canvas/01_image.js new file mode 100644 index 000000000..f87b227b3 --- /dev/null +++ b/ext/canvas/01_image.js @@ -0,0 +1,552 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +import { core, internals, primordials } from "ext:core/mod.js"; +const ops = core.ops; +import * as webidl from "ext:deno_webidl/00_webidl.js"; +import { DOMException } from "ext:deno_web/01_dom_exception.js"; +import { createFilteredInspectProxy } from "ext:deno_console/01_console.js"; +import { BlobPrototype } from "ext:deno_web/09_file.js"; +import { sniffImage } from "ext:deno_web/01_mimesniff.js"; +const { + ObjectPrototypeIsPrototypeOf, + Symbol, + SymbolFor, + TypeError, + TypedArrayPrototypeGetBuffer, + TypedArrayPrototypeGetLength, + TypedArrayPrototypeGetSymbolToStringTag, + Uint8Array, + Uint8ClampedArray, + MathCeil, + PromiseResolve, + PromiseReject, + RangeError, +} = primordials; + +webidl.converters["PredefinedColorSpace"] = webidl.createEnumConverter( + "PredefinedColorSpace", + [ + "srgb", + "display-p3", + ], +); + +webidl.converters["ImageDataSettings"] = webidl.createDictionaryConverter( + "ImageDataSettings", + [ + { key: "colorSpace", converter: webidl.converters["PredefinedColorSpace"] }, + ], +); + +webidl.converters["ImageOrientation"] = webidl.createEnumConverter( + "ImageOrientation", + [ + "from-image", + "flipY", + ], +); + +webidl.converters["PremultiplyAlpha"] = webidl.createEnumConverter( + "PremultiplyAlpha", + [ + "none", + "premultiply", + "default", + ], +); + +webidl.converters["ColorSpaceConversion"] = webidl.createEnumConverter( + "ColorSpaceConversion", + [ + "none", + "default", + ], +); + +webidl.converters["ResizeQuality"] = webidl.createEnumConverter( + "ResizeQuality", + [ + "pixelated", + "low", + "medium", + "high", + ], +); + +webidl.converters["ImageBitmapOptions"] = webidl.createDictionaryConverter( + "ImageBitmapOptions", + [ + { + key: "imageOrientation", + converter: webidl.converters["ImageOrientation"], + defaultValue: "from-image", + }, + { + key: "premultiplyAlpha", + converter: webidl.converters["PremultiplyAlpha"], + defaultValue: "default", + }, + { + key: "colorSpaceConversion", + converter: webidl.converters["ColorSpaceConversion"], + defaultValue: "default", + }, + { + key: "resizeWidth", + converter: (v, prefix, context, opts) => + webidl.converters["unsigned long"](v, prefix, context, { + ...opts, + enforceRange: true, + }), + }, + { + key: "resizeHeight", + converter: (v, prefix, context, opts) => + webidl.converters["unsigned long"](v, prefix, context, { + ...opts, + enforceRange: true, + }), + }, + { + key: "resizeQuality", + converter: webidl.converters["ResizeQuality"], + defaultValue: "low", + }, + ], +); + +const _data = Symbol("[[data]]"); +const _width = Symbol("[[width]]"); +const _height = Symbol("[[height]]"); +class ImageData { + /** @type {number} */ + [_width]; + /** @type {height} */ + [_height]; + /** @type {Uint8Array} */ + [_data]; + /** @type {'srgb' | 'display-p3'} */ + #colorSpace; + + constructor(arg0, arg1, arg2 = undefined, arg3 = undefined) { + webidl.requiredArguments( + arguments.length, + 2, + 'Failed to construct "ImageData"', + ); + this[webidl.brand] = webidl.brand; + + let sourceWidth; + let sourceHeight; + let data; + let settings; + const prefix = "Failed to construct 'ImageData'"; + + // Overload: new ImageData(data, sw [, sh [, settings ] ]) + if ( + arguments.length > 3 || + TypedArrayPrototypeGetSymbolToStringTag(arg0) === "Uint8ClampedArray" + ) { + data = webidl.converters.Uint8ClampedArray(arg0, prefix, "Argument 1"); + sourceWidth = webidl.converters["unsigned long"]( + arg1, + prefix, + "Argument 2", + ); + const dataLength = TypedArrayPrototypeGetLength(data); + + if (webidl.type(arg2) !== "Undefined") { + sourceHeight = webidl.converters["unsigned long"]( + arg2, + prefix, + "Argument 3", + ); + } + + settings = webidl.converters["ImageDataSettings"]( + arg3, + prefix, + "Argument 4", + ); + + if (dataLength === 0) { + throw new DOMException( + "Failed to construct 'ImageData': The input data has zero elements.", + "InvalidStateError", + ); + } + + if (dataLength % 4 !== 0) { + throw new DOMException( + "Failed to construct 'ImageData': The input data length is not a multiple of 4.", + "InvalidStateError", + ); + } + + if (sourceWidth < 1) { + throw new DOMException( + "Failed to construct 'ImageData': The source width is zero or not a number.", + "IndexSizeError", + ); + } + + if (webidl.type(sourceHeight) !== "Undefined" && sourceHeight < 1) { + throw new DOMException( + "Failed to construct 'ImageData': The source height is zero or not a number.", + "IndexSizeError", + ); + } + + if (dataLength / 4 % sourceWidth !== 0) { + throw new DOMException( + "Failed to construct 'ImageData': The input data length is not a multiple of (4 * width).", + "IndexSizeError", + ); + } + + if ( + webidl.type(sourceHeight) !== "Undefined" && + (sourceWidth * sourceHeight * 4 !== dataLength) + ) { + throw new DOMException( + "Failed to construct 'ImageData': The input data length is not equal to (4 * width * height).", + "IndexSizeError", + ); + } + + if (webidl.type(sourceHeight) === "Undefined") { + this[_height] = dataLength / 4 / sourceWidth; + } else { + this[_height] = sourceHeight; + } + + this.#colorSpace = settings.colorSpace ?? "srgb"; + this[_width] = sourceWidth; + this[_data] = data; + return; + } + + // Overload: new ImageData(sw, sh [, settings]) + sourceWidth = webidl.converters["unsigned long"]( + arg0, + prefix, + "Argument 1", + ); + sourceHeight = webidl.converters["unsigned long"]( + arg1, + prefix, + "Argument 2", + ); + + settings = webidl.converters["ImageDataSettings"]( + arg2, + prefix, + "Argument 3", + ); + + if (sourceWidth < 1) { + throw new DOMException( + "Failed to construct 'ImageData': The source width is zero or not a number.", + "IndexSizeError", + ); + } + + if (sourceHeight < 1) { + throw new DOMException( + "Failed to construct 'ImageData': The source height is zero or not a number.", + "IndexSizeError", + ); + } + + this.#colorSpace = settings.colorSpace ?? "srgb"; + this[_width] = sourceWidth; + this[_height] = sourceHeight; + this[_data] = new Uint8ClampedArray(sourceWidth * sourceHeight * 4); + } + + get width() { + webidl.assertBranded(this, ImageDataPrototype); + return this[_width]; + } + + get height() { + webidl.assertBranded(this, ImageDataPrototype); + return this[_height]; + } + + get data() { + webidl.assertBranded(this, ImageDataPrototype); + return this[_data]; + } + + get colorSpace() { + webidl.assertBranded(this, ImageDataPrototype); + return this.#colorSpace; + } + + [SymbolFor("Deno.privateCustomInspect")](inspect, inspectOptions) { + return inspect( + createFilteredInspectProxy({ + object: this, + evaluate: ObjectPrototypeIsPrototypeOf(ImageDataPrototype, this), + keys: [ + "data", + "width", + "height", + "colorSpace", + ], + }), + inspectOptions, + ); + } +} + +const ImageDataPrototype = ImageData.prototype; + +const _bitmapData = Symbol("[[bitmapData]]"); +const _detached = Symbol("[[detached]]"); +class ImageBitmap { + [_width]; + [_height]; + [_bitmapData]; + [_detached]; + + constructor() { + webidl.illegalConstructor(); + } + + get width() { + webidl.assertBranded(this, ImageBitmapPrototype); + if (this[_detached]) { + return 0; + } + + return this[_width]; + } + + get height() { + webidl.assertBranded(this, ImageBitmapPrototype); + if (this[_detached]) { + return 0; + } + + return this[_height]; + } + + close() { + webidl.assertBranded(this, ImageBitmapPrototype); + this[_detached] = true; + this[_bitmapData] = null; + } + + [SymbolFor("Deno.privateCustomInspect")](inspect, inspectOptions) { + return inspect( + createFilteredInspectProxy({ + object: this, + evaluate: ObjectPrototypeIsPrototypeOf(ImageBitmapPrototype, this), + keys: [ + "width", + "height", + ], + }), + inspectOptions, + ); + } +} +const ImageBitmapPrototype = ImageBitmap.prototype; + +function createImageBitmap( + image, + sxOrOptions = undefined, + sy = undefined, + sw = undefined, + sh = undefined, + options = undefined, +) { + const prefix = "Failed to call 'createImageBitmap'"; + + // Overload: createImageBitmap(image [, options ]) + if (arguments.length < 3) { + options = webidl.converters["ImageBitmapOptions"]( + sxOrOptions, + prefix, + "Argument 2", + ); + } else { + // Overload: createImageBitmap(image, sx, sy, sw, sh [, options ]) + sxOrOptions = webidl.converters["long"](sxOrOptions, prefix, "Argument 2"); + sy = webidl.converters["long"](sy, prefix, "Argument 3"); + sw = webidl.converters["long"](sw, prefix, "Argument 4"); + sh = webidl.converters["long"](sh, prefix, "Argument 5"); + options = webidl.converters["ImageBitmapOptions"]( + options, + prefix, + "Argument 6", + ); + + if (sw === 0) { + return PromiseReject(new RangeError("sw has to be greater than 0")); + } + + if (sh === 0) { + return PromiseReject(new RangeError("sh has to be greater than 0")); + } + } + + if (options.resizeWidth === 0) { + return PromiseReject( + new DOMException( + "options.resizeWidth has to be greater than 0", + "InvalidStateError", + ), + ); + } + if (options.resizeHeight === 0) { + return PromiseReject( + new DOMException( + "options.resizeWidth has to be greater than 0", + "InvalidStateError", + ), + ); + } + + const imageBitmap = webidl.createBranded(ImageBitmap); + + if (ObjectPrototypeIsPrototypeOf(ImageDataPrototype, image)) { + const processedImage = processImage( + image[_data], + image[_width], + image[_height], + sxOrOptions, + sy, + sw, + sh, + options, + ); + imageBitmap[_bitmapData] = processedImage.data; + imageBitmap[_width] = processedImage.outputWidth; + imageBitmap[_height] = processedImage.outputHeight; + return PromiseResolve(imageBitmap); + } + if (ObjectPrototypeIsPrototypeOf(BlobPrototype, image)) { + return (async () => { + const data = await image.arrayBuffer(); + const mimetype = sniffImage(image.type); + if (mimetype !== "image/png") { + throw new DOMException( + `Unsupported type '${image.type}'`, + "InvalidStateError", + ); + } + const { data: imageData, width, height } = ops.op_image_decode_png(data); + const processedImage = processImage( + imageData, + width, + height, + sxOrOptions, + sy, + sw, + sh, + options, + ); + imageBitmap[_bitmapData] = processedImage.data; + imageBitmap[_width] = processedImage.outputWidth; + imageBitmap[_height] = processedImage.outputHeight; + return imageBitmap; + })(); + } else { + return PromiseReject(new TypeError("Invalid or unsupported image value")); + } +} + +function processImage(input, width, height, sx, sy, sw, sh, options) { + let sourceRectangle; + + if ( + sx !== undefined && sy !== undefined && sw !== undefined && sh !== undefined + ) { + sourceRectangle = [ + [sx, sy], + [sx + sw, sy], + [sx + sw, sy + sh], + [sx, sy + sh], + ]; + } else { + sourceRectangle = [ + [0, 0], + [width, 0], + [width, height], + [0, height], + ]; + } + const widthOfSourceRect = sourceRectangle[1][0] - sourceRectangle[0][0]; + const heightOfSourceRect = sourceRectangle[3][1] - sourceRectangle[0][1]; + + let outputWidth; + if (options.resizeWidth !== undefined) { + outputWidth = options.resizeWidth; + } else if (options.resizeHeight !== undefined) { + outputWidth = MathCeil( + (widthOfSourceRect * options.resizeHeight) / heightOfSourceRect, + ); + } else { + outputWidth = widthOfSourceRect; + } + + let outputHeight; + if (options.resizeHeight !== undefined) { + outputHeight = options.resizeHeight; + } else if (options.resizeWidth !== undefined) { + outputHeight = MathCeil( + (heightOfSourceRect * options.resizeWidth) / widthOfSourceRect, + ); + } else { + outputHeight = heightOfSourceRect; + } + + if (options.colorSpaceConversion === "none") { + throw new TypeError("options.colorSpaceConversion 'none' is not supported"); + } + + /* + * The cropping works differently than the spec specifies: + * The spec states to create an infinite surface and place the top-left corner + * of the image a 0,0 and crop based on sourceRectangle. + * + * We instead create a surface the size of sourceRectangle, and position + * the image at the correct location, which is the inverse of the x & y of + * sourceRectangle's top-left corner. + */ + const data = ops.op_image_process( + new Uint8Array(TypedArrayPrototypeGetBuffer(input)), + { + width, + height, + surfaceWidth: widthOfSourceRect, + surfaceHeight: heightOfSourceRect, + inputX: sourceRectangle[0][0] * -1, // input_x + inputY: sourceRectangle[0][1] * -1, // input_y + outputWidth, + outputHeight, + resizeQuality: options.resizeQuality, + flipY: options.imageOrientation === "flipY", + premultiply: options.premultiplyAlpha === "default" + ? null + : (options.premultiplyAlpha === "premultiply"), + }, + ); + + return { + data, + outputWidth, + outputHeight, + }; +} + +function getBitmapData(imageBitmap) { + return imageBitmap[_bitmapData]; +} + +internals.getBitmapData = getBitmapData; + +export { _bitmapData, _detached, createImageBitmap, ImageBitmap, ImageData }; diff --git a/ext/canvas/Cargo.toml b/ext/canvas/Cargo.toml new file mode 100644 index 000000000..de5372d0c --- /dev/null +++ b/ext/canvas/Cargo.toml @@ -0,0 +1,21 @@ +# Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +[package] +name = "deno_canvas" +version = "0.1.0" +authors.workspace = true +edition.workspace = true +license.workspace = true +readme = "README.md" +repository.workspace = true +description = "OffscreenCanvas implementation for Deno" + +[lib] +path = "lib.rs" + +[dependencies] +deno_core.workspace = true +deno_webgpu.workspace = true +image = { version = "0.24.7", default-features = false, features = ["png"] } +serde = { workspace = true, features = ["derive"] } +tokio = { workspace = true, features = ["full"] } diff --git a/ext/canvas/README.md b/ext/canvas/README.md new file mode 100644 index 000000000..cf013677e --- /dev/null +++ b/ext/canvas/README.md @@ -0,0 +1,3 @@ +# deno_canvas + +Extension that implements various OffscreenCanvas related APIs. diff --git a/ext/canvas/lib.deno_canvas.d.ts b/ext/canvas/lib.deno_canvas.d.ts new file mode 100644 index 000000000..28d57d583 --- /dev/null +++ b/ext/canvas/lib.deno_canvas.d.ts @@ -0,0 +1,87 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +// deno-lint-ignore-file no-var + +/// <reference no-default-lib="true" /> +/// <reference lib="esnext" /> + +/** @category Web APIs */ +declare type PredefinedColorSpace = "srgb" | "display-p3"; + +/** @category Web APIs */ +declare interface ImageDataSettings { + readonly colorSpace?: PredefinedColorSpace; +} + +/** @category Web APIs */ +declare interface ImageData { + readonly colorSpace: PredefinedColorSpace; + readonly data: Uint8ClampedArray; + readonly height: number; + readonly width: number; +} + +/** @category Web APIs */ +declare var ImageData: { + prototype: ImageData; + new (sw: number, sh: number, settings?: ImageDataSettings): ImageData; + new ( + data: Uint8ClampedArray, + sw: number, + sh?: number, + settings?: ImageDataSettings, + ): ImageData; +}; + +/** @category Web APIs */ +declare type ColorSpaceConversion = "default" | "none"; + +/** @category Web APIs */ +declare type ImageOrientation = "flipY" | "from-image" | "none"; + +/** @category Web APIs */ +declare type PremultiplyAlpha = "default" | "none" | "premultiply"; + +/** @category Web APIs */ +declare type ResizeQuality = "high" | "low" | "medium" | "pixelated"; + +/** @category Web APIs */ +declare type ImageBitmapSource = Blob | ImageData; + +/** @category Web APIs */ +interface ImageBitmapOptions { + colorSpaceConversion?: ColorSpaceConversion; + imageOrientation?: ImageOrientation; + premultiplyAlpha?: PremultiplyAlpha; + resizeHeight?: number; + resizeQuality?: ResizeQuality; + resizeWidth?: number; +} + +/** @category Web APIs */ +declare function createImageBitmap( + image: ImageBitmapSource, + options?: ImageBitmapOptions, +): Promise<ImageBitmap>; +/** @category Web APIs */ +declare function createImageBitmap( + image: ImageBitmapSource, + sx: number, + sy: number, + sw: number, + sh: number, + options?: ImageBitmapOptions, +): Promise<ImageBitmap>; + +/** @category Web APIs */ +interface ImageBitmap { + readonly height: number; + readonly width: number; + close(): void; +} + +/** @category Web APIs */ +declare var ImageBitmap: { + prototype: ImageBitmap; + new (): ImageBitmap; +}; diff --git a/ext/canvas/lib.rs b/ext/canvas/lib.rs new file mode 100644 index 000000000..b05332c3f --- /dev/null +++ b/ext/canvas/lib.rs @@ -0,0 +1,153 @@ +// Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. + +use deno_core::error::type_error; +use deno_core::error::AnyError; +use deno_core::op2; +use deno_core::ToJsBuffer; +use image::imageops::FilterType; +use image::ColorType; +use image::ImageDecoder; +use image::Pixel; +use image::RgbaImage; +use serde::Deserialize; +use serde::Serialize; +use std::path::PathBuf; + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +enum ImageResizeQuality { + Pixelated, + Low, + Medium, + High, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct ImageProcessArgs { + width: u32, + height: u32, + surface_width: u32, + surface_height: u32, + input_x: i64, + input_y: i64, + output_width: u32, + output_height: u32, + resize_quality: ImageResizeQuality, + flip_y: bool, + premultiply: Option<bool>, +} + +#[op2] +#[serde] +fn op_image_process( + #[buffer] buf: &[u8], + #[serde] args: ImageProcessArgs, +) -> Result<ToJsBuffer, AnyError> { + let view = + RgbaImage::from_vec(args.width, args.height, buf.to_vec()).unwrap(); + + let surface = if !(args.width == args.surface_width + && args.height == args.surface_height + && args.input_x == 0 + && args.input_y == 0) + { + let mut surface = RgbaImage::new(args.surface_width, args.surface_height); + + image::imageops::overlay(&mut surface, &view, args.input_x, args.input_y); + + surface + } else { + view + }; + + let filter_type = match args.resize_quality { + ImageResizeQuality::Pixelated => FilterType::Nearest, + ImageResizeQuality::Low => FilterType::Triangle, + ImageResizeQuality::Medium => FilterType::CatmullRom, + ImageResizeQuality::High => FilterType::Lanczos3, + }; + + let mut image_out = image::imageops::resize( + &surface, + args.output_width, + args.output_height, + filter_type, + ); + + if args.flip_y { + image::imageops::flip_vertical_in_place(&mut image_out); + } + + // ignore 9. + + if let Some(premultiply) = args.premultiply { + let is_not_premultiplied = image_out.pixels().any(|pixel| { + (pixel.0[0].max(pixel.0[1]).max(pixel.0[2])) > (255 * pixel.0[3]) + }); + + if premultiply { + if is_not_premultiplied { + for pixel in image_out.pixels_mut() { + let alpha = pixel.0[3]; + pixel.apply_without_alpha(|channel| { + (channel as f32 * (alpha as f32 / 255.0)) as u8 + }) + } + } + } else if !is_not_premultiplied { + for pixel in image_out.pixels_mut() { + let alpha = pixel.0[3]; + pixel.apply_without_alpha(|channel| { + (channel as f32 / (alpha as f32 / 255.0)) as u8 + }) + } + } + } + + Ok(image_out.to_vec().into()) +} + +#[derive(Debug, Serialize)] +struct DecodedPng { + data: ToJsBuffer, + width: u32, + height: u32, +} + +#[op2] +#[serde] +fn op_image_decode_png(#[buffer] buf: &[u8]) -> Result<DecodedPng, AnyError> { + let png = image::codecs::png::PngDecoder::new(buf)?; + + let (width, height) = png.dimensions(); + + // TODO(@crowlKats): maybe use DynamicImage https://docs.rs/image/0.24.7/image/enum.DynamicImage.html ? + if png.color_type() != ColorType::Rgba8 { + return Err(type_error(format!( + "Color type '{:?}' not supported", + png.color_type() + ))); + } + + let mut png_data = Vec::with_capacity(png.total_bytes() as usize); + + png.read_image(&mut png_data)?; + + Ok(DecodedPng { + data: png_data.into(), + width, + height, + }) +} + +deno_core::extension!( + deno_canvas, + deps = [deno_webidl, deno_web, deno_webgpu], + ops = [op_image_process, op_image_decode_png], + lazy_loaded_esm = ["01_image.js"], +); + +pub fn get_declaration() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("lib.deno_canvas.d.ts") +} |
