diff --git a/apps/docs/docs/image.md b/apps/docs/docs/image.md index c4d34ce8ae..4602068634 100644 --- a/apps/docs/docs/image.md +++ b/apps/docs/docs/image.md @@ -39,6 +39,30 @@ const data = Skia.Data.fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJA const image = Skia.Image.MakeImageFromEncoded(data); ``` +### MakeImageFromEncodedScaled + +When an encoded image is only needed at a smaller size, +`MakeImageFromEncodedScaled` can avoid a full-resolution native decode. The +requested width and optional height form an aspect-preserving bounding box: + +```tsx twoslash +import { Skia } from "@shopify/react-native-skia"; + +declare const data: ReturnType; +const thumbnail = Skia.Image.MakeImageFromEncodedScaled(data, 400, 300); +``` + +Native codecs decode at the closest scale they support, so the returned image +can be slightly larger or smaller than the requested box. A codec without +scaled decoding support returns the source resolution. The method never +upscales beyond that resolution and returns `null` for invalid data or +non-positive dimensions. + +CanvasKit does not expose codec-level scaled decoding. On Web, the method +preserves the output semantics by decoding at full resolution and resizing +afterwards, so it does not provide the same CPU and peak-memory benefit as the +native implementation. + ### MakeImage `MakeImage` allows you to create an image by providing pixel data and specifying the format. diff --git a/packages/skia/cpp/api/JsiSkImageFactory.h b/packages/skia/cpp/api/JsiSkImageFactory.h index e6382c749b..be976c883c 100644 --- a/packages/skia/cpp/api/JsiSkImageFactory.h +++ b/packages/skia/cpp/api/JsiSkImageFactory.h @@ -1,5 +1,7 @@ #pragma once +#include +#include #include #include @@ -11,6 +13,9 @@ #include "JsiSkImage.h" #include "JsiSkImageInfo.h" +#include "include/codec/SkCodec.h" +#include "include/codec/SkEncodedOrigin.h" + #ifdef SK_GRAPHITE #include "rnskia/RNDawnContext.h" #include "rnwgpu/api/GPUTexture.h" @@ -41,6 +46,80 @@ class JsiSkImageFactory : public JsiSkHostObject { runtime, hostObjectInstance, getContext()); } + JSI_HOST_FUNCTION(MakeImageFromEncodedScaled) { + if (count < 2 || !arguments[1].isNumber()) { + return jsi::Value::null(); + } + + const auto targetWidth = arguments[1].asNumber(); + if (!std::isfinite(targetWidth) || targetWidth <= 0) { + return jsi::Value::null(); + } + + double targetHeight = 0; + if (count > 2 && !arguments[2].isUndefined()) { + if (!arguments[2].isNumber()) { + return jsi::Value::null(); + } + targetHeight = arguments[2].asNumber(); + if (!std::isfinite(targetHeight) || targetHeight <= 0) { + return jsi::Value::null(); + } + } + + auto data = JsiSkData::fromValue(runtime, arguments[0]); + auto codec = SkCodec::MakeFromData(data); + if (codec == nullptr) { + return jsi::Value::null(); + } + + const auto encodedDimensions = codec->dimensions(); + if (encodedDimensions.isEmpty()) { + return jsi::Value::null(); + } + + const bool swapsWidthHeight = + SkEncodedOriginSwapsWidthHeight(codec->getOrigin()); + const auto imageWidth = swapsWidthHeight ? encodedDimensions.height() + : encodedDimensions.width(); + const auto imageHeight = swapsWidthHeight ? encodedDimensions.width() + : encodedDimensions.height(); + + auto scale = targetWidth / static_cast(imageWidth); + if (targetHeight > 0) { + scale = std::min( + scale, targetHeight / static_cast(imageHeight)); + } + scale = std::min(scale, 1.0); + + const auto desiredScale = static_cast(scale); + if (desiredScale <= 0) { + return jsi::Value::null(); + } + const auto scaledDimensions = + codec->getScaledDimensions(desiredScale); + if (scaledDimensions.isEmpty()) { + return jsi::Value::null(); + } + + const auto outputDimensions = + swapsWidthHeight + ? SkISize::Make(scaledDimensions.height(), + scaledDimensions.width()) + : scaledDimensions; + const auto scaledInfo = + codec->getInfo().makeDimensions(outputDimensions); + auto [image, result] = codec->getImage(scaledInfo); + if (image == nullptr || result != SkCodec::kSuccess) { + return jsi::Value::null(); + } + + auto hostObjectInstance = + std::make_shared(getContext(), std::move(image)); + return JSI_CREATE_HOST_OBJECT_WITH_MEMORY_PRESSURE( + runtime, hostObjectInstance, getContext()); + } + JSI_HOST_FUNCTION(MakeImageFromNativeBuffer) { jsi::BigInt pointer = arguments[0].asBigInt(runtime); const uintptr_t nativeBufferPointer = pointer.asUint64(runtime); @@ -184,6 +263,8 @@ class JsiSkImageFactory : public JsiSkHostObject { std::string getObjectType() const override { return "JsiSkImageFactory"; } JSI_EXPORT_FUNCTIONS(JSI_EXPORT_FUNC(JsiSkImageFactory, MakeImageFromEncoded), + JSI_EXPORT_FUNC(JsiSkImageFactory, + MakeImageFromEncodedScaled), JSI_EXPORT_FUNC(JsiSkImageFactory, MakeImageFromViewTag), JSI_EXPORT_FUNC(JsiSkImageFactory, MakeImageFromNativeBuffer), diff --git a/packages/skia/src/renderer/__tests__/e2e/Image.spec.tsx b/packages/skia/src/renderer/__tests__/e2e/Image.spec.tsx index 34b3e16088..86a59c1e13 100644 --- a/packages/skia/src/renderer/__tests__/e2e/Image.spec.tsx +++ b/packages/skia/src/renderer/__tests__/e2e/Image.spec.tsx @@ -1,7 +1,7 @@ import React from "react"; import { checkImage } from "../../../__tests__/setup"; -import { images, loadImage, surface } from "../setup"; +import { dataAssets, images, loadImage, surface } from "../setup"; import { Fill, Image as SkiaImage } from "../../components"; import { AlphaType, ColorType } from "../../../skia/types"; @@ -54,6 +54,50 @@ describe("Image loading from bundles", () => { ]); }); + it("should decode an encoded image at a reduced resolution", async () => { + const dimensions = await surface.eval( + (Skia, { data }) => { + const encoded = Skia.Data.fromBytes(new Uint8Array(data)); + const fullSize = Skia.Image.MakeImageFromEncoded(encoded)!; + const scaled = Skia.Image.MakeImageFromEncodedScaled( + encoded, + fullSize.width() / 2, + fullSize.height() / 2 + )!; + return { + fullWidth: fullSize.width(), + fullHeight: fullSize.height(), + scaledWidth: scaled.width(), + scaledHeight: scaled.height(), + }; + }, + { data: Array.from(dataAssets.img_0) } + ); + + expect(dimensions.scaledWidth).toBeLessThan(dimensions.fullWidth); + expect(dimensions.scaledHeight).toBeLessThan(dimensions.fullHeight); + expect(dimensions.scaledWidth / dimensions.scaledHeight).toBeCloseTo( + dimensions.fullWidth / dimensions.fullHeight, + 1 + ); + }); + + it("should reject non-positive scaled decode dimensions", async () => { + const result = await surface.eval( + (Skia, { data }) => { + const encoded = Skia.Data.fromBytes(new Uint8Array(data)); + return { + width: Skia.Image.MakeImageFromEncodedScaled(encoded, 0) === null, + height: + Skia.Image.MakeImageFromEncodedScaled(encoded, 100, -1) === null, + }; + }, + { data: Array.from(dataAssets.img_0) } + ); + + expect(result).toEqual({ width: true, height: true }); + }); + it("should read pixels from a canvas", async () => { const pixels = await surface.eval( (Skia, { colorType, alphaType }) => { diff --git a/packages/skia/src/skia/types/Image/ImageFactory.ts b/packages/skia/src/skia/types/Image/ImageFactory.ts index d4e0ced489..02e375db8d 100644 --- a/packages/skia/src/skia/types/Image/ImageFactory.ts +++ b/packages/skia/src/skia/types/Image/ImageFactory.ts @@ -34,6 +34,29 @@ export interface ImageFactory { */ MakeImageFromEncoded: (encoded: SkData) => SkImage | null; + /** + * Decode an encoded image at a reduced resolution. + * + * The requested dimensions define an aspect-preserving bounding box. Native + * codecs decode at their closest supported scale, which may be slightly + * larger or smaller than the requested box. Codecs without scaled decoding + * support, and requests larger than the source, return a native-resolution + * image. + * + * On Web, CanvasKit does not expose codec-level scaled decoding, so the image + * is decoded at full resolution and then resized to fit the requested box. + * + * @param encoded - Data object containing encoded image bytes. + * @param targetWidth - Positive target width in pixels. + * @param targetHeight - Optional positive target height in pixels. + * @returns A decoded image, or null if the input or dimensions are invalid. + */ + MakeImageFromEncodedScaled: ( + encoded: SkData, + targetWidth: number, + targetHeight?: number + ) => SkImage | null; + /** * Return an Image backed by a given native buffer. * The native buffer must be a valid owning reference. diff --git a/packages/skia/src/skia/web/JsiSkImageFactory.ts b/packages/skia/src/skia/web/JsiSkImageFactory.ts index cfedc81682..2f853275ca 100644 --- a/packages/skia/src/skia/web/JsiSkImageFactory.ts +++ b/packages/skia/src/skia/web/JsiSkImageFactory.ts @@ -79,6 +79,66 @@ export class JsiSkImageFactory extends Host implements ImageFactory { return new JsiSkImage(this.CanvasKit, image); } + MakeImageFromEncodedScaled( + encoded: SkData, + targetWidth: number, + targetHeight?: number + ) { + if ( + !Number.isFinite(targetWidth) || + targetWidth <= 0 || + (targetHeight !== undefined && + (!Number.isFinite(targetHeight) || targetHeight <= 0)) + ) { + return null; + } + + const image = this.CanvasKit.MakeImageFromEncoded( + JsiSkData.fromValue(encoded) + ); + if (image === null) { + return null; + } + + const width = image.width(); + const height = image.height(); + let scale = targetWidth / width; + if (targetHeight !== undefined) { + scale = Math.min(scale, targetHeight / height); + } + scale = Math.min(scale, 1); + if (scale === 1) { + return new JsiSkImage(this.CanvasKit, image); + } + + const scaledWidth = Math.max(1, Math.round(width * scale)); + const scaledHeight = Math.max(1, Math.round(height * scale)); + const surface = this.CanvasKit.MakeSurface(scaledWidth, scaledHeight); + if (surface === null) { + image.delete(); + return null; + } + + const canvas = surface.getCanvas(); + canvas.drawImageRectOptions( + image, + [0, 0, width, height], + [0, 0, scaledWidth, scaledHeight], + this.CanvasKit.FilterMode.Linear, + this.CanvasKit.MipmapMode.None + ); + surface.flush(); + const scaledImage = surface.makeImageSnapshot(); + surface.delete(); + + if (scaledImage === null) { + image.delete(); + return null; + } + image.delete(); + return new JsiSkImage(this.CanvasKit, scaledImage); + } + MakeImageFromNativeTextureUnstable() { return throwNotImplementedOnRNWeb(); }