// CanvasKit-backed stand-in for `@shopify/react-native-skia`. // // The web build aliases the package name to THIS module (vite `resolve.alias`, // tsconfig `paths`), so every renderer we share with the app — `frameUtils.ts`, // `skiaImage.ts`, the ported `exportEngine.ts` — runs unchanged on CanvasKit. // // W0 probe results that shaped this file (measured in Node, canvaskit-wasm 0.42): // - the default build CANNOT encode JPEG (`encodeToBytes(JPEG)` → null), the // `canvaskit-wasm/full` build can → always import the full build (+0.9MB wasm); // - `Skia.ImageFilter.MakeMatrixConvolution` DOES NOT EXIST in CanvasKit → the // two convolution steps (CLARITY, output sharpen) are re-implemented as an // SkSL runtime effect, see convolve.ts; // - `Surface.MakeOffscreen` does not exist either → one shared GrDirectContext // + `MakeRenderTarget` for GPU, raster `MakeSurface` as the no-GL fallback // (see gpuSurface below for why the context must be shared); // - `Skia.Font`/`Typeface.MakeFreeTypeFaceFromData`/`Canvas.drawText` all work, // and `assets/NotoEmoji-GPS.ttf` really paints 📍/📷 (1731 lit pixels in the // probe) → GPS watermark needs no Canvas2D fallback. import CanvasKitInit from 'canvaskit-wasm/full'; // Live binding: modules import `{ Skia }` once, `initSkia()` fills it in before // the first render. ESM live bindings make the late assignment visible. export let Skia: any = null; export let CK: any = null; // `frameUtils.ts` imports `BlendMode` as a value, so it has to be a live // binding too — filled in once CanvasKit is up, long before any draw. export let BlendMode: any = null; export type SkCanvas = any; export type SkPaint = any; export type SkImage = any; export type SkShader = any; export type SkSurface = any; export type SkTypeface = any; export type SkFont = any; // CanvasKit's EMBind objects are released with `delete()`; the RN Skia API (and // therefore the shared renderers) calls `dispose()`. const DISPOSABLE = [ 'Image', 'Surface', 'Paint', 'Shader', 'ImageFilter', 'ColorFilter', 'Typeface', 'Font', 'RuntimeEffect', 'Path', 'Canvas', 'BlendMode', ]; function patchPrototypes(canvasKit: any): void { for (const name of DISPOSABLE) { const proto = canvasKit[name]?.prototype; if (proto && typeof proto.delete === 'function' && typeof proto.dispose !== 'function') { proto.dispose = function dispose() { this.delete(); }; } } // CanvasKit 0.42's `Font.getGlyphWidths` is wrong: it passes its output // pointer where the wasm export wants the *bounds* pointer, so every glyph in // a run comes back holding one identical, rounded width. Probed at 64px on the // merged Inter face, 'H' and 'i' both answered 42, while hmtx says 0.743em and // 0.242em (47.6px and 15.5px) — and any box measured off it was 27% too wide // ("Hà Nội 09/23" 510px against a true 403px). Rebind it with the pointers in // the order `_getGlyphWidthBounds` actually reads them (verified against // fontTools hmtx on both bundled faces). canvasKit.Font.prototype.getGlyphWidths = function (glyphIDs: any, paint: any) { const ids = typeof glyphIDs === 'string' ? this.getGlyphIDs(glyphIDs) : glyphIDs; if (!ids || !ids.length) return new Float32Array(0); const idPtr = canvasKit._malloc(2 * ids.length); const outPtr = canvasKit._malloc(4 * ids.length); try { canvasKit.HEAPU16.set(ids, idPtr >> 1); this._getGlyphWidthBounds(idPtr, ids.length, outPtr, 0, paint ?? null); return Float32Array.from( canvasKit.HEAPF32.subarray(outPtr >> 2, (outPtr >> 2) + ids.length) ); } finally { canvasKit._free(idPtr); canvasKit._free(outPtr); } }; // frameUtils draws strokes with the numeric RN style (0 = fill, 1 = stroke). const setStyle = canvasKit.Paint.prototype.setStyle; canvasKit.Paint.prototype.setStyle = function (style: any) { return setStyle.call( this, typeof style === 'number' ? (style === 1 ? canvasKit.PaintStyle.Stroke : canvasKit.PaintStyle.Fill) : style ); }; } export interface InitSkiaOptions { // Browser: path of canvaskit.wasm (Vite serves it as an asset). Node: omitted. locateFile?: (file: string) => string; } function rasterSurface(w: number, h: number) { return CK.MakeSurface(w, h); } // ONE GrDirectContext for every surface. `MakeWebGLCanvasSurface` makes its own // WebGL context per call, and a texture from one context cannot be sampled by a // surface on another: the snapshot of surface A drawn onto surface B came back // solid black (measured — 12MP JPEG + output sharpen, preview and export alike, // GL 0.0 vs raster 107.0 mean). Sharing the context fixes it. let grContext: any = null; function ensureGrContext(): any { if (grContext) return grContext; if (typeof OffscreenCanvas === 'undefined') return null; try { grContext = CK.MakeWebGLContext(CK.GetWebGLContext(new OffscreenCanvas(8, 8))); } catch { grContext = null; } return grContext; } function gpuSurface(w: number, h: number) { const ctx = ensureGrContext(); if (!ctx) return null; try { return CK.MakeRenderTarget(ctx, w, h) ?? null; } catch { return null; // no GL context available — caller falls back to raster } } // Callers (the app's engine banner) ask the same context the renderer will use, // so "GPU ok" cannot disagree with what the pipeline actually gets. export function hasGpu(): boolean { return !!ensureGrContext(); } export async function initSkia(options: InitSkiaOptions = {}): Promise { if (Skia) return Skia; const canvasKit = await (CanvasKitInit as any)( options.locateFile ? { locateFile: options.locateFile } : undefined ); CK = canvasKit; BlendMode = canvasKit.BlendMode; patchPrototypes(canvasKit); Skia = { // --- geometry / color --------------------------------------------------- XYWHRect: (x: number, y: number, w: number, h: number) => canvasKit.XYWHRect(x, y, w, h), Point: (x: number, y: number) => [x, y], Color: (value: string) => value === 'transparent' ? canvasKit.Color(0, 0, 0, 0) : canvasKit.parseColorString(value), Color4f: (r: number, g: number, b: number, a: number) => canvasKit.Color4f(r, g, b, a), // --- factories ---------------------------------------------------------- Paint: () => new canvasKit.Paint(), Font: (typeface: any, size: number) => new canvasKit.Font(typeface, size), Data: { // CanvasKit exposes no Data class; callers pass bytes straight through. fromBytes: (bytes: Uint8Array) => bytes, }, Image: { MakeImageFromEncoded: (data: Uint8Array) => canvasKit.MakeImageFromEncoded( data instanceof Uint8Array ? data : new Uint8Array(data as ArrayBuffer) ), // An image straight off a canvas source — what the camera's own