Skip to content

feat(🖼️): add full control over image encoding quality#3952

Open
NikitaDudin wants to merge 1 commit into
Shopify:mainfrom
NikitaDudin:feat/image-encoding-parity
Open

feat(🖼️): add full control over image encoding quality#3952
NikitaDudin wants to merge 1 commit into
Shopify:mainfrom
NikitaDudin:feat/image-encoding-parity

Conversation

@NikitaDudin

@NikitaDudin NikitaDudin commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Why

Image encoding options were not handled consistently across Native and Web.

In particular:

  • quality={0} could be lost on Web because it was treated as a falsy value;
  • PNG always used the default compression level and did not expose compression control;
  • WebP lossless mode could only be selected implicitly through quality={100};
  • quality values outside 0...100, NaN, and undefined were not normalized consistently;
  • stock CanvasKit does not expose the PNG compression and explicit WebP lossless options required to match the native Skia encoders.

As a result, applications could not reliably control encoded image size, encoding effort, or lossy/lossless output across platforms.

What changed

This PR extends both encodeToBytes() and encodeToBase64() with an optional lossless argument:

image.encodeToBytes(format?, quality?, lossless?);
image.encodeToBase64(format?, quality?, lossless?);

Encoding behavior

  • Quality values are clamped to 0...100.
  • NaN is treated as undefined.
  • JPEG:
    • defaults to quality 100;
    • always uses lossy encoding;
    • ignores the lossless argument.
  • PNG:
    • always remains pixel-lossless;
    • an omitted quality preserves Skia's standard zlib level 6;
    • explicit quality maps 0 to maximum compression and 100 to no zlib compression;
    • ignores the lossless argument.
  • WebP:
    • lossless={false} always produces lossy VP8;
    • lossless={true} always produces lossless VP8L;
    • in lossless mode, quality controls encoder effort rather than visual fidelity;
    • omitting lossless preserves the existing behavior: quality 100 or omitted quality selects lossless WebP with effort 75, while lower values select lossy WebP.

Existing two-argument calls remain backward-compatible.

Web / CanvasKit

The required encoder options are implemented in a custom CanvasKit build because stock CanvasKit does not expose them.

The package now includes a matching CanvasKit JavaScript/WASM pair and validates their encoding ABI during initialization. Loading a stock or mismatched WASM binary fails with an actionable error instead of invoking an incompatible binding.

The custom build is reproducible with:

yarn build-canvaskit

The CanvasKit patch, build metadata, bundled runtime, setup script, example application, Remotion configuration, Jest environment, headless loader, and Web documentation have been updated accordingly.

Web applications must copy and deploy the canvaskit.wasm bundled with the same version of @shopify/react-native-skia. The JavaScript and WASM files must be upgraded and cached together.

Test plan

  • Built the patched full CanvasKit JS/WASM runtime.
  • Web encoding and CanvasKit compatibility tests:
    • 2 test suites passed;
    • 11 tests passed.
  • Native iOS E2E encoding suite passed on iPhone 15 Pro, iOS 17, Fabric/Ganesh:
    • 7 tests passed.
  • Verified boundary handling for:
    • -Infinity, values below 0, 0;
    • intermediate quality values;
    • 100, values above 100, Infinity;
    • NaN and undefined.
  • Compared Web and Native encoding using the same deterministic 64×64 RGBA source across 30 PNG, JPEG, and WebP variants.

Web / Native parity results

  • Web and Native selected the same WebP payload mode (VP8 or VP8L) for every tested option.
  • Decoded Web and Native pixels matched exactly in all 30 variants:
    • maximum channel difference: 0;
    • cross-platform PSNR: Infinity.
  • PNG and lossless WebP outputs matched the original source pixels exactly.
  • After removing only color-profile metadata, encoded Web and Native payloads were byte-identical.

Raw container sizes differ because Web currently tags images as sRGB while Native raw ImageInfo does not carry a color space:

  • PNG Web output: +13 bytes;
  • JPEG Web output: +474 bytes;
  • WebP Web output: +482 bytes.

This is an existing color-space metadata difference and does not affect encoded pixels, quality, or lossless behavior. Color-space propagation is intentionally outside the scope of this PR.

@wcandillon

Copy link
Copy Markdown
Contributor

Thank You for submitting this. It looks really cool.
Is the CanvasKit path submitted against Skia?

For image encoding on the Web, I think we can use Web APIs directly for this. Or is your use-case headless?

@NikitaDudin

Copy link
Copy Markdown
Contributor Author

Thanks William!

No, the CanvasKit patch has not been submitted upstream yet. I wanted to validate the API and encoding semantics here first. If keeping the CanvasKit encoder is the preferred direction, I can extract the patch and submit it to Skia upstream instead of maintaining a permanent React Native Skia-specific fork.

The goal is not limited to a browser-only use case. It also includes the existing DOM-free headless/Jest path and preserving the synchronous SkImage.encodeToBytes() API.

Browser APIs can cover lossy JPEG/WebP quality, but they do not expose the complete set of controls provided by this PR:

  • toBlob() and OffscreenCanvas.convertToBlob() are asynchronous. toDataURL() is synchronous, but only returns base64.
  • The quality option applies to formats with variable lossy quality and is ignored for PNG, so it cannot control the zlib compression level.
  • There is no explicit WebP lossless/lossy switch or lossless encoder-effort option.
  • The encoder and supported output formats are user-agent dependent; unsupported formats may fall back to PNG.
  • These browser Canvas APIs are not generally available in the existing DOM-free headless runtime.

Using Web APIs would therefore require different asynchronous Web semantics, reduced PNG/WebP controls, or separate browser and headless implementations.

That said, I agree that bundling and maintaining a custom CanvasKit build has a significant maintenance and distribution cost. I am happy to adapt the PR in either direction:

  1. submit the CanvasKit API extension upstream and make this PR depend on the upstream implementation; or
  2. use browser APIs and reduce the Web scope if exact PNG compression, explicit WebP lossless mode, and headless parity are not requirements React Native Skia wants to guarantee.

Which direction would you prefer?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants