From 77489c17141dbf7ae49fbc058f85e88adfe84d82 Mon Sep 17 00:00:00 2001 From: Nikita Dudin Date: Sat, 18 Jul 2026 16:56:57 +0300 Subject: [PATCH] =?UTF-8?q?feat(=F0=9F=96=BC=EF=B8=8F):=20add=20full=20con?= =?UTF-8?q?trol=20over=20image=20encoding=20quality?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/docs/docs/getting-started/web.mdx | 162 ++++++-- apps/docs/docs/image.md | 133 ++++++- apps/example/.gitignore | 1 + apps/example/index.html | 2 - apps/example/index.web.js | 13 +- apps/example/package.json | 2 +- apps/remotion/remotion.config.ts | 5 +- packages/skia/cpp/api/JsiSkImage.h | 91 ++++- packages/skia/dist/canvaskit/BUILD_INFO | 3 + packages/skia/dist/canvaskit/LICENSE | 29 ++ packages/skia/dist/canvaskit/canvaskit.js | 270 +++++++++++++ packages/skia/dist/canvaskit/canvaskit.wasm | Bin 0 -> 8092911 bytes packages/skia/jestEnv.js | 3 +- packages/skia/jestSetup.mjs | 2 +- packages/skia/package.json | 1 + packages/skia/scripts/build-canvaskit.ts | 122 ++++++ .../patches/canvaskit-image-encoding.patch | 360 ++++++++++++++++++ packages/skia/scripts/setup-canvaskit.js | 16 +- packages/skia/src/headless/index.ts | 7 +- .../__tests__/e2e/ImageEncoding.spec.tsx | 336 +++++++++------- packages/skia/src/skia/types/Image/Image.ts | 64 ++-- packages/skia/src/skia/web/JsiSkImage.ts | 43 ++- packages/skia/src/web/LoadSkiaWeb.tsx | 27 +- .../src/web/__tests__/LoadSkiaWeb.spec.ts | 30 ++ 24 files changed, 1459 insertions(+), 263 deletions(-) create mode 100644 packages/skia/dist/canvaskit/BUILD_INFO create mode 100644 packages/skia/dist/canvaskit/LICENSE create mode 100644 packages/skia/dist/canvaskit/canvaskit.js create mode 100755 packages/skia/dist/canvaskit/canvaskit.wasm create mode 100644 packages/skia/scripts/build-canvaskit.ts create mode 100644 packages/skia/scripts/patches/canvaskit-image-encoding.patch create mode 100644 packages/skia/src/web/__tests__/LoadSkiaWeb.spec.ts diff --git a/apps/docs/docs/getting-started/web.mdx b/apps/docs/docs/getting-started/web.mdx index 3c9e02dc3d..f9295776a8 100644 --- a/apps/docs/docs/getting-started/web.mdx +++ b/apps/docs/docs/getting-started/web.mdx @@ -5,12 +5,31 @@ sidebar_label: Web slug: /getting-started/web --- -import {Snack} from '@site/src/components/Snack'; +import { Snack } from "@site/src/components/Snack"; React Native Skia runs in the browser via [CanvasKit](https://skia.org/docs/user/modules/canvaskit/), a WebAssembly (WASM) build of Skia. -The CanvasKit WASM file, which is 2.9MB when gzipped, is loaded asynchronously. +The bundled full CanvasKit WASM file, which is approximately 3.3 MB when gzipped, is loaded asynchronously. Despite its considerable size, it offers flexibility in determining when and how Skia loads, giving you full control over the user experience. +:::warning CanvasKit JavaScript and WASM must match + +React Native Skia uses customized CanvasKit image-encoding bindings. The +CanvasKit JavaScript imported by React Native Skia and the +`dist/canvaskit/canvaskit.wasm` file are a versioned pair. The WASM file must +come from the **same installed version** of `@shopify/react-native-skia` as the +application JavaScript. + +Do not import, preload, or deploy the JavaScript or WASM runtime from +`canvaskit-wasm`. That dependency is still used for TypeScript declarations, +but its stock runtime is not compatible with these bindings. `LoadSkiaWeb` +checks compatibility and rejects a mismatched build. + +After every React Native Skia upgrade, rerun `setup-skia-web` and deploy the +application bundle and WASM file together. Invalidate any CDN, browser, or +service-worker cache that could retain the previous WASM file. + +::: + We support direct integration with [Expo](#expo) and [Remotion](#remotion). Additionally, you'll find manual installation steps for any webpack projects. @@ -31,13 +50,15 @@ npx create-expo-app my-app -e with-skia This template includes the proper configuration for web support out of the box, so you don't need to manually configure loading methods. ### Manual configuration Use the `setup-skia-web` script to ensure that the `canvaskit.wasm` file is accessible within your Expo project's public folder. -If you're [loading CanvasKit from a CDN](#using-a-cdn), running the `setup-skia-web` script is unnecessary. ```bash $ npx expo install @shopify/react-native-skia @@ -47,11 +68,20 @@ $ yarn setup-skia-web Run `yarn setup-skia-web` each time you upgrade the `@shopify/react-native-skia` package. Consider incorporating it into your `postinstall` script for convenience. +```json +{ + "scripts": { + "postinstall": "setup-skia-web" + } +} +``` + After setup, choose your method to [Load Skia](#loading-skia). For existing projects using Expo Router, you can use [code-splitting](#using-code-splitting) or [deferred component registration](#using-deferred-component-registration). If you wish to use deferred component registration with Expo Router, you need to create your own `main` property in `package.json`. For instance, if you've created `index.tsx` and `index.web.tsx` in your root directory, update your `package.json` accordingly: + ```patch - "main": "expo-router/entry", + "main": "index", @@ -60,11 +90,11 @@ For instance, if you've created `index.tsx` and `index.web.tsx` in your root dir Below is an example of `index.web.tsx`: ```tsx -import '@expo/metro-runtime'; -import { App } from 'expo-router/build/qualified-entry'; -import { renderRootComponent } from 'expo-router/build/renderRootComponent'; +import "@expo/metro-runtime"; +import { App } from "expo-router/build/qualified-entry"; +import { renderRootComponent } from "expo-router/build/renderRootComponent"; -import { LoadSkiaWeb } from '@shopify/react-native-skia/lib/module/web'; +import { LoadSkiaWeb } from "@shopify/react-native-skia/lib/module/web"; LoadSkiaWeb().then(async () => { renderRootComponent(App); @@ -75,14 +105,50 @@ For the `index.tsx` file, directly invoke `renderRootComponent(App)`. ## Remotion -Follow these [installation steps](https://remotion.dev/skia) to use React Native Skia with Remotion. +Follow the [Remotion installation steps](https://remotion.dev/skia) for the +React and webpack integration. In addition, ensure the final +`canvaskit.wasm` asset is copied from React Native Skia rather than from +`canvaskit-wasm`. + +If you use Remotion's `enableSkia()` helper, add a final copy rule for the +matched WASM file. This example uses `copy-webpack-plugin`; `force: true` +replaces a stock asset emitted by an existing Skia integration: + +```tsx +import { Config } from "@remotion/cli/config"; +import { enableSkia } from "@remotion/skia/enable"; +import CopyPlugin from "copy-webpack-plugin"; + +Config.overrideWebpackConfig((currentConfiguration) => { + const configuration = enableSkia(currentConfiguration); + configuration.plugins ??= []; + configuration.plugins.push( + new CopyPlugin({ + patterns: [ + { + from: require.resolve( + "@shopify/react-native-skia/dist/canvaskit/canvaskit.wasm" + ), + to: "canvaskit.wasm", + force: true, + }, + ], + }) + ); + return configuration; +}); +``` + +The entry point must still call `LoadSkia()` before importing and registering +components that use React Native Skia, as shown in the Remotion instructions. ## Loading Skia Ensure Skia is fully loaded and initialized before importing the Skia module. Two methods facilitate Skia's loading: -* `` for code-splitting, delaying the loading of Skia-importing components. -* `LoadSkiaWeb()` to defer root component registration until Skia loads. + +- `` for code-splitting, delaying the loading of Skia-importing components. +- `LoadSkiaWeb()` to defer root component registration until Skia loads. ### Using Code-Splitting @@ -90,7 +156,7 @@ The `` component utilizes [code splitting](https://reactjs.org/docs The following example demonstrates preloading Skia before rendering the `MySkiaComponent`: ```tsx -import React from 'react'; +import React from "react"; import { Text } from "react-native"; import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web"; @@ -104,6 +170,7 @@ export default function App() { ); } ``` + :::info When using expo router in dev mode you cannot load components that are inside the app directory, as they will get evaluated by the router before CanvasKit is loaded. @@ -127,17 +194,32 @@ LoadSkiaWeb().then(async () => { ## Using a CDN -Below, CanvasKit loads via code-splitting from a CDN. -It is critical that the CDN-hosted CanvasKit version aligns with React Native Skia's requirements. +React Native Skia bundles a matched CanvasKit JavaScript/WASM build. Do not load +`canvaskit-wasm` directly from a public CDN because a stock WASM binary is not +compatible with the bundled JavaScript bindings. + +To serve WASM from your own CDN, upload +`@shopify/react-native-skia/dist/canvaskit/canvaskit.wasm` from the installed +package (or the exact file copied by `setup-skia-web`). Use a versioned or +content-hashed URL so an old WASM file cannot survive a package upgrade in a +CDN or service-worker cache. + +Only the WASM file belongs on the CDN. The matching CanvasKit JavaScript is +already imported into the application bundle by React Native Skia. ```tsx import { WithSkiaWeb } from "@shopify/react-native-skia/lib/module/web"; -import { version } from 'canvaskit-wasm/package.json'; + +const canvasKitWasmUrl = + "https://cdn.example.com/react-native-skia/your-version/canvaskit.wasm"; export default function App() { return ( `https://cdn.jsdelivr.net/npm/canvaskit-wasm@${version}/bin/full/${file}` }} + opts={{ + locateFile: (file) => + file === "canvaskit.wasm" ? canvasKitWasmUrl : file, + }} getComponent={() => import("./MySkiaComponent")} /> ); @@ -148,16 +230,23 @@ Alternatively, use deferred component registration: ```tsx import { LoadSkiaWeb } from "@shopify/react-native-skia/lib/module/web"; -import { version } from 'canvaskit-wasm/package.json'; LoadSkiaWeb({ - locateFile: (file) => `https://cdn.jsdelivr.net/npm/canvaskit-wasm@${version}/bin/full/${file}` + locateFile: (file) => + file === "canvaskit.wasm" + ? "https://cdn.example.com/react-native-skia/your-version/canvaskit.wasm" + : file, }).then(async () => { const App = (await import("./src/App")).default; AppRegistry.registerComponent("Example", () => App); }); ``` +If loading fails with `The loaded CanvasKit build is incompatible with React +Native Skia`, the application JavaScript and WASM file are from different +builds. Recopy the WASM file from the currently installed package, redeploy both +assets, and clear any caches that can serve the old file. + ## WebGL Contextes Web browsers limit the number of WebGL contexts to 16 per webpage. @@ -166,11 +255,12 @@ Usually developers will see this error when they exceed this limit: ``` WARNING: Too many active WebGL contexts. Oldest context will be lost. ``` + If you canvas is static and doesn't contain animation values, you can use the `__destroyWebGLContextAfterRender={true}` prop on your Canvas components to destroy the WebGL context after rendering. This even works with animated canvases but it will come with a performance cost as the context will be recreated on each render. ```tsx twoslash -import { View } from 'react-native'; +import { View } from "react-native"; import { Canvas, Fill } from "@shopify/react-native-skia"; export default function App() { @@ -179,7 +269,11 @@ export default function App() { { // 20 Skia Canvases with __destroyWebGLContextAfterRender={true} new Array(20).fill(0).map((_, i) => ( - + )) @@ -196,10 +290,10 @@ To request these features, please submit [a feature request on GitHub](https://g **Unsupported** -* `PathEffectFactory.MakeSum()` -* `PathEffectFactory.MakeCompose()` -* `PathFactory.MakeFromText()` -* `ShaderFilter` +- `PathEffectFactory.MakeSum()` +- `PathEffectFactory.MakeCompose()` +- `PathFactory.MakeFromText()` +- `ShaderFilter` ## Manual webpack Installation @@ -227,12 +321,19 @@ const newConfiguration = { compilation.hooks.processAssets.tapPromise( { name: "copy-skia", - stage: compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, + stage: + compiler.webpack.Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL, }, async () => { - const src = require.resolve("canvaskit-wasm/bin/full/canvaskit.wasm"); - if (!compilation.getAsset(src)) { - compilation.emitAsset("/canvaskit.wasm", new sources.RawSource(await fs.promises.readFile(src))); + const src = require.resolve( + "@shopify/react-native-skia/dist/canvaskit/canvaskit.wasm" + ); + const assetName = "canvaskit.wasm"; + if (!compilation.getAsset(assetName)) { + compilation.emitAsset( + assetName, + new sources.RawSource(await fs.promises.readFile(src)) + ); } } ); @@ -241,8 +342,7 @@ const newConfiguration = { })(), // 2. Polyfill fs and path modules - - new NodePolyfillPlugin() + new NodePolyfillPlugin(), ], alias: { ...currentConfiguration.alias, @@ -254,7 +354,7 @@ const newConfiguration = { "react-native-reanimated": require.resolve("react-native-reanimated"), "react-native/Libraries/Image/AssetRegistry": false, }, -} +}; ``` Finally, proceed to [load Skia](#loading-skia). diff --git a/apps/docs/docs/image.md b/apps/docs/docs/image.md index c4d34ce8ae..40d6a2ba71 100644 --- a/apps/docs/docs/image.md +++ b/apps/docs/docs/image.md @@ -35,7 +35,9 @@ You can also create image instances manually using `MakeImageFromEncoded`. import { Skia } from "@shopify/react-native-skia"; // A sample base64-encoded pixel -const data = Skia.Data.fromBase64("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg=="); +const data = Skia.Data.fromBase64( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==" +); const image = Skia.Image.MakeImageFromEncoded(data); ``` @@ -71,21 +73,21 @@ const img = Skia.Image.MakeImage( ### useImage -`useImage` is simply a helper function to load image data. +`useImage` is simply a helper function to load image data. ## Image Component Images can be drawn by specifying the output rectangle and how the image should fit into that rectangle. -| Name | Type | Description | -| :----- | :-------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| image | `SkImage` | An instance of the image. | -| x | `number` | The left position of the destination image. | -| y | `number` | The top position of the destination image. | -| width | `number` | The width of the destination image. | -| height | `number` | The height of the destination image. | -| fit? | `Fit` | The method used to fit the image into the rectangle. Values can be `contain`, `fill`, `cover`, `fitHeight`, `fitWidth`, `scaleDown`, or `none` (the default is `contain`). | -| sampling? | `Sampling` | The method used to sample the image. see ([sampling options](/docs/images#sampling-options)). | +| Name | Type | Description | +| :-------- | :--------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| image | `SkImage` | An instance of the image. | +| x | `number` | The left position of the destination image. | +| y | `number` | The top position of the destination image. | +| width | `number` | The width of the destination image. | +| height | `number` | The height of the destination image. | +| fit? | `Fit` | The method used to fit the image into the rectangle. Values can be `contain`, `fill`, `cover`, `fitHeight`, `fitWidth`, `scaleDown`, or `none` (the default is `contain`). | +| sampling? | `Sampling` | The method used to sample the image. see ([sampling options](/docs/images#sampling-options)). | ### Example @@ -110,7 +112,14 @@ Use cubic sampling for best quality: you can use the default `sampling={CubicSam You can also use filter modes (`nearest` or `linear`) and mimap modes (`none`, `nearest`, or `linear`). Default is `nearest`. ```tsx twoslash -import { Canvas, Image, useImage, CubicSampling, FilterMode, MipmapMode } from "@shopify/react-native-skia"; +import { + Canvas, + Image, + useImage, + CubicSampling, + FilterMode, + MipmapMode, +} from "@shopify/react-native-skia"; const ImageDemo = () => { const image = useImage(require("./assets/oslo.jpg")); @@ -169,11 +178,95 @@ const ImageDemo = () => { ## Instance Methods -| Name | Description | -| :-------------- | :-------------------------------------------------------------------- | -| `height` | Returns the possibly scaled height of the image. | -| `width` | Returns the possibly scaled width of the image. | -| `getImageInfo` | Returns the image info for the image. | -| `encodeToBytes` | Encodes the image pixels, returning the result as a `UInt8Array`. | -| `encodeToBase64`| Encodes the image pixels, returning the result as a base64-encoded string. | -| `readPixels` | Reads the image pixels, returning result as UInt8Array or Float32Array | +| Name | Description | +| :--------------- | :------------------------------------------------------------------------- | +| `height` | Returns the possibly scaled height of the image. | +| `width` | Returns the possibly scaled width of the image. | +| `getImageInfo` | Returns the image info for the image. | +| `encodeToBytes` | Encodes the image pixels, returning the result as a `UInt8Array`. | +| `encodeToBase64` | Encodes the image pixels, returning the result as a base64-encoded string. | +| `readPixels` | Reads the image pixels, returning result as UInt8Array or Float32Array | + +### Encoding options + +Both encoding methods have the same arguments: + +```ts +image.encodeToBytes(format?, quality?, lossless?); +image.encodeToBase64(format?, quality?, lossless?); +``` + +The default format is `ImageFormat.PNG`. The supported formats are +`ImageFormat.JPEG`, `ImageFormat.PNG`, and `ImageFormat.WEBP`. + +#### Quality normalization + +- Values below 0, including `-Infinity`, are normalized to 0. +- Values above 100, including `Infinity`, are normalized to 100. +- `undefined` and `NaN` select the format-specific default. +- `lossless` only applies to WebP. It is ignored for JPEG and PNG. + +| Format | Default | Meaning of `quality` | Encoding mode | +| :----- | :------ | :------------------- | :------------ | +| JPEG | Quality 100 | Visual quality from 0 to 100 | Always lossy | +| PNG | zlib level 6 | Compression level `round(9 × (1 - quality / 100))` | Always lossless | +| WebP | Lossless, effort 75 | Visual quality in lossy mode; encoder effort in lossless mode | Selected by `lossless`, or inferred when omitted | + +PNG does not define a standard `quality` parameter. When quality is omitted, +React Native Skia leaves Skia's PNG options unchanged, which uses the default +zlib compression level 6. An explicit quality of 0 maps to zlib level 9 +(maximum compression), while 100 maps to level 0 (no zlib compression). This +changes encoding time and file size, but never the decoded pixels. + +#### WebP lossy and lossless modes + +**WebP defaults to lossless when both `quality` and `lossless` are omitted.** +This preserves the historical React Native Skia and CanvasKit behavior. + +| Arguments after `ImageFormat.WEBP` | Result | +| :--------------------------------- | :----- | +| none, `undefined`, or `NaN` | Lossless WebP with effort 75 | +| `quality` from 0 through 99, no `lossless` flag | Lossy WebP at that visual quality | +| `quality` 100, no `lossless` flag | Lossless WebP with effort 75 | +| `undefined, false` | Lossy WebP at quality 100 | +| `quality, false` | Lossy WebP at the normalized visual quality | +| `undefined, true` | Lossless WebP with effort 100 | +| `quality, true` | Lossless WebP at the normalized encoder effort | + +In lossless WebP mode, quality does not affect pixels. It controls encoder +effort: lower values encode faster into larger files, while higher values encode +slower into smaller files. Consequently, automatic WebP at quality 100 uses the +legacy effort 75, while explicitly passing `true` at quality 100 uses effort +100 and may produce different bytes. + +#### Examples + +```tsx twoslash +import { ImageFormat, Skia } from "@shopify/react-native-skia"; + +const surface = Skia.Surface.MakeOffscreen(64, 64)!; +const image = surface.makeImageSnapshot(); + +// PNG +image.encodeToBytes(); // Default format: lossless PNG, zlib level 6 +image.encodeToBytes(ImageFormat.PNG, 0); // Lossless PNG, zlib level 9 +image.encodeToBytes(ImageFormat.PNG, 100); // Lossless PNG, zlib level 0 + +// JPEG +image.encodeToBytes(ImageFormat.JPEG); // Lossy JPEG, quality 100 +image.encodeToBytes(ImageFormat.JPEG, 80); // Lossy JPEG, quality 80 + +// WebP with the legacy automatic mode +image.encodeToBytes(ImageFormat.WEBP); // Lossless, effort 75 +image.encodeToBytes(ImageFormat.WEBP, 80); // Lossy, quality 80 +image.encodeToBytes(ImageFormat.WEBP, 100); // Lossless, effort 75 + +// WebP with an explicit mode +image.encodeToBytes(ImageFormat.WEBP, 80, true); // Lossless, effort 80 +image.encodeToBytes(ImageFormat.WEBP, 100, false); // Lossy, quality 100 +image.encodeToBytes(ImageFormat.WEBP, undefined, true); // Lossless, effort 100 +image.encodeToBytes(ImageFormat.WEBP, undefined, false); // Lossy, quality 100 + +// The same options are available for base64 output +image.encodeToBase64(ImageFormat.WEBP, 85, false); +``` diff --git a/apps/example/.gitignore b/apps/example/.gitignore index 865ec87ce0..91b71520f1 100644 --- a/apps/example/.gitignore +++ b/apps/example/.gitignore @@ -17,3 +17,4 @@ node_modules/ e2e/screenshots/ test-results/ playwright-report/ +canvaskit.wasm diff --git a/apps/example/index.html b/apps/example/index.html index 9fd39120a7..374cc8d20b 100644 --- a/apps/example/index.html +++ b/apps/example/index.html @@ -8,8 +8,6 @@ content="width=device-width, initial-scale=1, shrink-to-fit=no" /> - - Example