diff --git a/package/cpp/api/JsiSkImage.h b/package/cpp/api/JsiSkImage.h index 3d6d00e7b4..8e90af45f7 100644 --- a/package/cpp/api/JsiSkImage.h +++ b/package/cpp/api/JsiSkImage.h @@ -20,6 +20,7 @@ #include "include/codec/SkEncodedImageFormat.h" #include "include/encode/SkJpegEncoder.h" #include "include/encode/SkPngEncoder.h" +#include "include/encode/SkWebpEncoder.h" #pragma clang diagnostic pop @@ -78,20 +79,34 @@ class JsiSkImage : public JsiSkWrappingSkPtrHostObject { count >= 1 ? static_cast(arguments[0].asNumber()) : SkEncodedImageFormat::kPNG; - auto quality = count == 2 ? arguments[1].asNumber() : 100.0; + auto quality = (count >= 2 && arguments[1].isNumber()) + ? arguments[1].asNumber() + : 100.0; auto image = getObject(); if (image->isTextureBacked()) { image = image->makeNonTextureImage(); } sk_sp data; + if (format == SkEncodedImageFormat::kJPEG) { SkJpegEncoder::Options options; options.fQuality = quality; data = SkJpegEncoder::Encode(nullptr, image.get(), options); + } else if (format == SkEncodedImageFormat::kWEBP) { + SkWebpEncoder::Options options; + if (quality >= 100) { + options.fCompression = SkWebpEncoder::Compression::kLossless; + options.fQuality = 75; // This is effort to compress + } else { + options.fCompression = SkWebpEncoder::Compression::kLossy; + options.fQuality = quality; + } + data = SkWebpEncoder::Encode(nullptr, image.get(), options); } else { SkPngEncoder::Options options; data = SkPngEncoder::Encode(nullptr, image.get(), options); } + return data; } diff --git a/package/src/renderer/__tests__/e2e/ImageEncoding.spec.tsx b/package/src/renderer/__tests__/e2e/ImageEncoding.spec.tsx new file mode 100644 index 0000000000..1785f6239b --- /dev/null +++ b/package/src/renderer/__tests__/e2e/ImageEncoding.spec.tsx @@ -0,0 +1,255 @@ +import { surface } from "../setup"; + +const MAGIC_BYTES = { + JPEG: "/9j/4", + PNG: "iVBORw0KGgo", + WEBP: "UklGR", +}; + +const IMAGE_FORMAT = { + JPEG: 3, // ImageFormat.JPEG, + PNG: 4, // ImageFormat.PNG, + WEBP: 6, // ImageFormat.WEBP, +}; + +const IMAGE_INFO_BASE = { + alphaType: 1, // AlphaType.Opaque, + colorType: 4, // ColorType.RGBA_8888, +}; + +describe("Image Encoding", () => { + it("SkImage.encodeToBase64: check WEBP format by magic bytes sequence UklGR...", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const width = 1; + const height = 1; + const bytesPerPixel = 4; + const bytes = new Uint8Array(width * height * bytesPerPixel); + bytes.fill(255); + const data = Skia.Data.fromBytes(bytes); + const imageInfo = { + ...ctx.imageInfoBase, + width, + height, + }; + + return Skia.Image.MakeImage(imageInfo, data, width * bytesPerPixel)! + .encodeToBase64(ctx.format) + .slice(0, ctx.cutIndex); + }, + { + cutIndex: MAGIC_BYTES.WEBP.length, + imageInfoBase: IMAGE_INFO_BASE, + format: IMAGE_FORMAT.WEBP, + } + ); + + expect(result).toEqual(MAGIC_BYTES.WEBP); + }); + + it("SkImage.encodeToBase64: check PNG format by magic bytes sequence iVBORw0KGgo...", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const width = 1; + const height = 1; + const bytesPerPixel = 4; + const bytes = new Uint8Array(width * height * bytesPerPixel); + bytes.fill(255); + const data = Skia.Data.fromBytes(bytes); + const imageInfo = { + ...ctx.imageInfoBase, + width, + height, + }; + + return Skia.Image.MakeImage(imageInfo, data, width * bytesPerPixel)! + .encodeToBase64(ctx.format) + .slice(0, ctx.cutIndex); + }, + { + cutIndex: MAGIC_BYTES.PNG.length, + imageInfoBase: IMAGE_INFO_BASE, + format: IMAGE_FORMAT.PNG, + } + ); + + expect(result).toEqual(MAGIC_BYTES.PNG); + }); + + it("SkImage.encodeToBase64: check JPEG format by magic bytes sequence /9j/4...", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const width = 1; + const height = 1; + const bytesPerPixel = 4; + const bytes = new Uint8Array(width * height * bytesPerPixel); + bytes.fill(255); + const data = Skia.Data.fromBytes(bytes); + const imageInfo = { + ...ctx.imageInfoBase, + width, + height, + }; + + return Skia.Image.MakeImage(imageInfo, data, width * bytesPerPixel)! + .encodeToBase64(ctx.format) + .slice(0, ctx.cutIndex); + }, + { + cutIndex: MAGIC_BYTES.JPEG.length, + imageInfoBase: IMAGE_INFO_BASE, + format: IMAGE_FORMAT.JPEG, + } + ); + + expect(result).toEqual(MAGIC_BYTES.JPEG); + }); + + it("SkImage.encodeToBase64: JPEG checking of the quality argument work", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const width = 1024; + const height = 1024; + const bytesPerPixel = 4; + const bytes = new Uint8Array(width * height * bytesPerPixel); + bytes.fill(255); + let i = 0; + for (let x = 0; x < width * bytesPerPixel; x++) { + for (let y = 0; y < height; y++) { + bytes[i++] = (x * y) % 255; + } + } + const data = Skia.Data.fromBytes(bytes); + const imageInfo = { + ...ctx.imageInfoBase, + width, + height, + }; + const image = Skia.Image.MakeImage( + imageInfo, + data, + width * bytesPerPixel + )!; + const minQuality = image.encodeToBase64(ctx.format, 1e-8).length; + const midQuality = image.encodeToBase64(ctx.format, 50).length; + const defaultQuality = image.encodeToBase64(ctx.format).length; + const maxQuality = image.encodeToBase64(ctx.format, 100).length; + + return { + minQuality, + midQuality, + defaultQuality, + maxQuality, + }; + }, + { imageInfoBase: IMAGE_INFO_BASE, format: IMAGE_FORMAT.JPEG } + ); + + expect(result.minQuality).toBeLessThan(result.maxQuality); + expect(result.minQuality).toBeLessThan(result.defaultQuality); + expect(result.minQuality).toBeLessThan(result.midQuality); + expect(result.midQuality).toBeLessThan(result.maxQuality); + expect(result.defaultQuality).toEqual(result.maxQuality); + }); + + it("SkImage.encodeToBase64: PNG checking. The quality argument doesn't work", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const width = 1024; + const height = 1024; + const bytesPerPixel = 4; + const bytes = new Uint8Array(width * height * bytesPerPixel); + bytes.fill(255); + let i = 0; + for (let x = 0; x < width * bytesPerPixel; x++) { + for (let y = 0; y < height; y++) { + bytes[i++] = (x * y) % 255; + } + } + const data = Skia.Data.fromBytes(bytes); + const imageInfo = { + ...ctx.imageInfoBase, + width, + height, + }; + const image = Skia.Image.MakeImage( + imageInfo, + data, + width * bytesPerPixel + )!; + const minQuality = image.encodeToBase64(ctx.format, 1e-8).length; + const midQuality = image.encodeToBase64(ctx.format, 50).length; + const defaultQuality = image.encodeToBase64(ctx.format).length; + const maxQuality = image.encodeToBase64(ctx.format, 100).length; + + return { + minQuality, + midQuality, + defaultQuality, + maxQuality, + }; + }, + { imageInfoBase: IMAGE_INFO_BASE, format: IMAGE_FORMAT.PNG } + ); + + expect(result.minQuality).toEqual(result.midQuality); + expect(result.minQuality).toEqual(result.defaultQuality); + expect(result.minQuality).toEqual(result.maxQuality); + }); + + it("SkImage.encodeToBase64: WEBP checking of the quality argument work", async () => { + const result = await surface.eval( + (Skia, ctx) => { + const width = 1024; + const height = 1024; + const bytesPerPixel = 4; + const bytes = new Uint8Array(width * height * bytesPerPixel); + let i = 0; + for (let x = 0; x < width * bytesPerPixel; x++) { + for (let y = 0; y < height; y++) { + bytes[i++] = (x * y) % 255; + } + } + const data = Skia.Data.fromBytes(bytes); + const imageInfo = { + ...ctx.imageInfoBase, + width, + height, + }; + const image = Skia.Image.MakeImage( + imageInfo, + data, + width * bytesPerPixel + )!; + const minQualityLossy = image.encodeToBase64(ctx.format, 1e-8).length; + const midQualityLossy = image.encodeToBase64(ctx.format, 50).length; + const maxQualityLossy = image.encodeToBase64( + ctx.format, + 100 - 1e-8 + ).length; + const defaultQualityLossless = image.encodeToBase64( + ctx.format, + undefined + ).length; + const maxQualityLossless = image.encodeToBase64(ctx.format, 100).length; + + return { + minQualityLossy, + midQualityLossy, + maxQualityLossy, + defaultQualityLossless, + maxQualityLossless, + }; + }, + { imageInfoBase: IMAGE_INFO_BASE, format: IMAGE_FORMAT.WEBP } + ); + + expect(result.minQualityLossy).toBeLessThan(result.midQualityLossy); + expect(result.minQualityLossy).toBeLessThan(result.maxQualityLossy); + expect(result.midQualityLossy).toBeLessThan(result.maxQualityLossy); + expect(result.minQualityLossy).not.toEqual(result.maxQualityLossless); + expect(result.midQualityLossy).not.toEqual(result.maxQualityLossless); + expect(result.maxQualityLossy).not.toEqual(result.maxQualityLossless); + expect(result.defaultQualityLossless).toEqual(result.maxQualityLossless); + }); +}); diff --git a/package/src/skia/web/JsiSkImage.ts b/package/src/skia/web/JsiSkImage.ts index a06b6fde28..8f61ef04cc 100644 --- a/package/src/skia/web/JsiSkImage.ts +++ b/package/src/skia/web/JsiSkImage.ts @@ -1,18 +1,18 @@ import type { CanvasKit, - Image, ImageInfo as CKImageInfo, + Image, } from "canvaskit-wasm"; -import { - type ImageFormat, - type ImageInfo, - type FilterMode, - type MipmapMode, - type SkImage, - type SkMatrix, - type SkShader, - type TileMode, +import type { + FilterMode, + MipmapMode, + SkImage, + SkMatrix, + SkShader, + TileMode, + ImageFormat, + ImageInfo, } from "../types"; import { ckEnum, getCkEnum, HostObject } from "./Host";