From dbcb7e60bd7c9a9550d63856981935b4bf5d3540 Mon Sep 17 00:00:00 2001 From: Lovell Fuller Date: Mon, 22 Dec 2025 15:05:33 +0000 Subject: [PATCH] Add toUint8Array for output backed by transferable ArrayBuffer #4355 --- docs/src/content/docs/api-output.md | 32 +++++++++++++++++++ docs/src/content/docs/changelog/v0.35.0.md | 3 ++ lib/constructor.js | 1 + lib/index.d.ts | 7 +++++ lib/output.js | 36 ++++++++++++++++++++++ src/pipeline.cc | 20 +++++++++--- src/pipeline.h | 2 ++ test/bench/perf.js | 26 ++++++++++++++++ test/types/sharp.test-d.ts | 3 ++ test/unit/io.js | 36 ++++++++++++++++++++++ 10 files changed, 161 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/api-output.md b/docs/src/content/docs/api-output.md index f070c980..5902912e 100644 --- a/docs/src/content/docs/api-output.md +++ b/docs/src/content/docs/api-output.md @@ -117,6 +117,38 @@ await sharp(pixelArray, { raw: { width, height, channels } }) ``` +## toUint8Array +> toUint8Array() ⇒ Promise.<{data: Uint8Array, info: Object}> + +Write output to a `Uint8Array` backed by a transferable `ArrayBuffer`. +JPEG, PNG, WebP, AVIF, TIFF, GIF and raw pixel data output are supported. + +Use [toFormat](#toformat) or one of the format-specific functions such as [jpeg](#jpeg), [png](#png) etc. to set the output format. + +If no explicit format is set, the output format will match the input image, except SVG input which becomes PNG output. + +By default all metadata will be removed, which includes EXIF-based orientation. +See [keepExif](#keepexif) and similar methods for control over this. + +Resolves with an `Object` containing: +- `data` is the output image as a `Uint8Array` backed by a transferable `ArrayBuffer`. +- `info` contains properties relating to the output image such as `width` and `height`. + + +**Since**: v0.35.0 +**Example** +```js +const { data, info } = await sharp(input).toUint8Array(); +``` +**Example** +```js +const { data } = await sharp(input) + .avif() + .toUint8Array(); +const base64String = data.toBase64(); +``` + + ## keepExif > keepExif() ⇒ Sharp diff --git a/docs/src/content/docs/changelog/v0.35.0.md b/docs/src/content/docs/changelog/v0.35.0.md index 03fb139b..29b43f6b 100644 --- a/docs/src/content/docs/changelog/v0.35.0.md +++ b/docs/src/content/docs/changelog/v0.35.0.md @@ -12,3 +12,6 @@ slug: changelog/v0.35.0 * Add `withGainMap` to process HDR JPEG images with embedded gain maps. [#4314](https://github.com/lovell/sharp/issues/4314) + +* Add `toUint8Array` for output image as a `TypedArray` backed by a transferable `ArrayBuffer`. + [#4355](https://github.com/lovell/sharp/issues/4355) diff --git a/lib/constructor.js b/lib/constructor.js index d4a1263a..915e02df 100644 --- a/lib/constructor.js +++ b/lib/constructor.js @@ -306,6 +306,7 @@ const Sharp = function (input, options) { fileOut: '', formatOut: 'input', streamOut: false, + typedArrayOut: false, keepMetadata: 0, withMetadataOrientation: -1, withMetadataDensity: 0, diff --git a/lib/index.d.ts b/lib/index.d.ts index 4952b532..3d6f840c 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -693,6 +693,13 @@ declare namespace sharp { */ toBuffer(options: { resolveWithObject: true }): Promise<{ data: Buffer; info: OutputInfo }>; + /** + * Write output to a Uint8Array backed by a transferable ArrayBuffer. JPEG, PNG, WebP, AVIF, TIFF, GIF and RAW output are supported. + * By default, the format will match the input image, except SVG input which becomes PNG output. + * @returns A promise that resolves with an object containing the Uint8Array data and an info object containing the output image format, size (bytes), width, height and channels + */ + toUint8Array(): Promise<{ data: Uint8Array; info: OutputInfo }>; + /** * Keep all EXIF metadata from the input image in the output image. * EXIF metadata is unsupported for TIFF output. diff --git a/lib/output.js b/lib/output.js index 8700847e..d1383f19 100644 --- a/lib/output.js +++ b/lib/output.js @@ -164,6 +164,41 @@ function toBuffer (options, callback) { return this._pipeline(is.fn(options) ? options : callback, stack); } +/** + * Write output to a `Uint8Array` backed by a transferable `ArrayBuffer`. + * JPEG, PNG, WebP, AVIF, TIFF, GIF and raw pixel data output are supported. + * + * Use {@link #toformat toFormat} or one of the format-specific functions such as {@link #jpeg jpeg}, {@link #png png} etc. to set the output format. + * + * If no explicit format is set, the output format will match the input image, except SVG input which becomes PNG output. + * + * By default all metadata will be removed, which includes EXIF-based orientation. + * See {@link #keepexif keepExif} and similar methods for control over this. + * + * Resolves with an `Object` containing: + * - `data` is the output image as a `Uint8Array` backed by a transferable `ArrayBuffer`. + * - `info` contains properties relating to the output image such as `width` and `height`. + * + * @since v0.35.0 + * + * @example + * const { data, info } = await sharp(input).toUint8Array(); + * + * @example + * const { data } = await sharp(input) + * .avif() + * .toUint8Array(); + * const base64String = data.toBase64(); + * + * @returns {Promise<{ data: Uint8Array, info: Object }>} + */ +function toUint8Array () { + this.options.resolveWithObject = true; + this.options.typedArrayOut = true; + const stack = Error(); + return this._pipeline(null, stack); +} + /** * Keep all EXIF metadata from the input image in the output image. * @@ -1659,6 +1694,7 @@ module.exports = (Sharp) => { // Public toFile, toBuffer, + toUint8Array, keepExif, withExif, withExifMerge, diff --git a/src/pipeline.cc b/src/pipeline.cc index f3cf2743..66ceb6b5 100644 --- a/src/pipeline.cc +++ b/src/pipeline.cc @@ -1345,12 +1345,21 @@ class PipelineWorker : public Napi::AsyncWorker { } if (baton->bufferOutLength > 0) { - // Add buffer size to info info.Set("size", static_cast(baton->bufferOutLength)); - // Pass ownership of output data to Buffer instance - Napi::Buffer data = Napi::Buffer::NewOrCopy(env, static_cast(baton->bufferOut), - baton->bufferOutLength, sharp::FreeCallback); - Callback().Call(Receiver().Value(), { env.Null(), data, info }); + if (baton->typedArrayOut) { + // ECMAScript ArrayBuffer with Uint8Array view + Napi::ArrayBuffer ab = Napi::ArrayBuffer::New(env, baton->bufferOutLength); + memcpy(ab.Data(), baton->bufferOut, baton->bufferOutLength); + sharp::FreeCallback(static_cast(baton->bufferOut), nullptr); + Napi::TypedArrayOf data = Napi::TypedArrayOf::New(env, + baton->bufferOutLength, ab, 0, napi_uint8_array); + Callback().Call(Receiver().Value(), { env.Null(), data, info }); + } else { + // Node.js Buffer + Napi::Buffer data = Napi::Buffer::NewOrCopy(env, static_cast(baton->bufferOut), + baton->bufferOutLength, sharp::FreeCallback); + Callback().Call(Receiver().Value(), { env.Null(), data, info }); + } } else { // Add file size to info if (baton->formatOut != "dz" || sharp::IsDzZip(baton->fileOut)) { @@ -1700,6 +1709,7 @@ Napi::Value pipeline(const Napi::CallbackInfo& info) { // Output baton->formatOut = sharp::AttrAsStr(options, "formatOut"); baton->fileOut = sharp::AttrAsStr(options, "fileOut"); + baton->typedArrayOut = sharp::AttrAsBool(options, "typedArrayOut"); baton->keepMetadata = sharp::AttrAsUint32(options, "keepMetadata"); baton->withMetadataOrientation = sharp::AttrAsUint32(options, "withMetadataOrientation"); baton->withMetadataDensity = sharp::AttrAsDouble(options, "withMetadataDensity"); diff --git a/src/pipeline.h b/src/pipeline.h index fdbdd84e..c9ff7102 100644 --- a/src/pipeline.h +++ b/src/pipeline.h @@ -48,6 +48,7 @@ struct PipelineBaton { size_t bufferOutLength; int pageHeightOut; int pagesOut; + bool typedArrayOut; std::vector composite; std::vector joinChannelIn; int topOffsetPre; @@ -243,6 +244,7 @@ struct PipelineBaton { bufferOutLength(0), pageHeightOut(0), pagesOut(0), + typedArrayOut(false), topOffsetPre(-1), topOffsetPost(-1), channels(0), diff --git a/test/bench/perf.js b/test/bench/perf.js index 375006ef..bb1ebc5b 100644 --- a/test/bench/perf.js +++ b/test/bench/perf.js @@ -228,6 +228,19 @@ async.series({ } }); } + }).add('sharp-buffer-uint8array', { + defer: true, + fn: (deferred) => { + sharp(inputJpgBuffer) + .resize(width, height) + .toUint8Array() + .then(() => { + deferred.resolve(); + }) + .catch((err) => { + throw err; + }); + } }).add('sharp-file-file', { defer: true, fn: (deferred) => { @@ -266,6 +279,19 @@ async.series({ } }); } + }).add('sharp-file-uint8array', { + defer: true, + fn: (deferred) => { + sharp(fixtures.inputJpg) + .resize(width, height) + .toUint8Array() + .then(() => { + deferred.resolve(); + }) + .catch((err) => { + throw err; + }); + } }).add('sharp-promise', { defer: true, fn: (deferred) => { diff --git a/test/types/sharp.test-d.ts b/test/types/sharp.test-d.ts index 9364c08c..f854e3d5 100644 --- a/test/types/sharp.test-d.ts +++ b/test/types/sharp.test-d.ts @@ -86,6 +86,9 @@ let transformer = sharp() }); readableStream.pipe(transformer).pipe(writableStream); +sharp().toUint8Array(); +sharp().toUint8Array().then(({ data }) => data.byteLength); + console.log(sharp.format); console.log(sharp.versions); diff --git a/test/unit/io.js b/test/unit/io.js index ad13f48b..874e9bf2 100644 --- a/test/unit/io.js +++ b/test/unit/io.js @@ -7,6 +7,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { afterEach, beforeEach, describe, it } = require('node:test'); const assert = require('node:assert'); +const { isMarkedAsUntransferable } = require('node:worker_threads'); const sharp = require('../../'); const fixtures = require('../fixtures'); @@ -1092,4 +1093,39 @@ describe('Input/output', () => { assert.strictEqual(channels, 3); assert.strictEqual(format, 'jpeg'); }); + + it('toBuffer resolves with an untransferable Buffer', async () => { + const data = await sharp(fixtures.inputJpg) + .resize({ width: 8, height: 8 }) + .toBuffer(); + + if (isMarkedAsUntransferable) { + assert.strictEqual(isMarkedAsUntransferable(data.buffer), true); + } + assert.strictEqual(ArrayBuffer.isView(data), true); + assert.strictEqual(ArrayBuffer.isView(data.buffer), false); + }); + + it('toUint8Array resolves with a transferable Uint8Array', async () => { + const { data, info } = await sharp(fixtures.inputJpg) + .resize({ width: 8, height: 8 }) + .toUint8Array(); + + assert.strictEqual(data instanceof Uint8Array, true); + if (isMarkedAsUntransferable) { + assert.strictEqual(isMarkedAsUntransferable(data.buffer), false); + } + assert.strictEqual(ArrayBuffer.isView(data), true); + assert.strictEqual(info.format, 'jpeg'); + assert.strictEqual(info.width, 8); + assert.strictEqual(info.height, 8); + assert.strictEqual(data.byteLength, info.size); + assert.strictEqual(data[0], 0xFF); + assert.strictEqual(data[1], 0xD8); + + const metadata = await sharp(data).metadata(); + assert.strictEqual(metadata.format, 'jpeg'); + assert.strictEqual(metadata.width, 8); + assert.strictEqual(metadata.height, 8); + }); });