Add CanvasKit web engine spike + W0 findings (recipes-web)
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
// 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 → raster `MakeSurface` only
|
||||
// (workers have no GPU context, which is fine: the render is CPU anyway);
|
||||
// - `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;
|
||||
|
||||
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(); };
|
||||
}
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
// ponytail: one OffscreenCanvas per surface. The pipeline holds at most ~3 at
|
||||
// once, far under the browser's ~16 live WebGL contexts; add a size-keyed pool
|
||||
// if batch rendering ever keeps more alive.
|
||||
function gpuSurface(w: number, h: number) {
|
||||
if (typeof OffscreenCanvas === 'undefined') return null;
|
||||
try {
|
||||
const canvas = new OffscreenCanvas(w, h);
|
||||
const surf = CK.MakeWebGLCanvasSurface(canvas);
|
||||
if (!surf) return null;
|
||||
const dispose = surf.dispose.bind(surf);
|
||||
surf.dispose = () => {
|
||||
try {
|
||||
dispose();
|
||||
} finally {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
};
|
||||
return surf;
|
||||
} catch {
|
||||
return null; // no GL context available — caller falls back to raster
|
||||
}
|
||||
}
|
||||
|
||||
export async function initSkia(options: InitSkiaOptions = {}): Promise<any> {
|
||||
if (Skia) return Skia;
|
||||
const canvasKit = await (CanvasKitInit as any)(
|
||||
options.locateFile ? { locateFile: options.locateFile } : undefined
|
||||
);
|
||||
CK = canvasKit;
|
||||
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)
|
||||
),
|
||||
},
|
||||
Typeface: {
|
||||
MakeFreeTypeFaceFromData: (data: ArrayBuffer) =>
|
||||
canvasKit.Typeface.MakeFreeTypeFaceFromData(data),
|
||||
},
|
||||
ColorFilter: {
|
||||
MakeMatrix: (m: number[]) => canvasKit.ColorFilter.MakeMatrix(m),
|
||||
MakeCompose: (outer: any, inner: any) => canvasKit.ColorFilter.MakeCompose(outer, inner),
|
||||
},
|
||||
ImageFilter: {
|
||||
MakeBlur: (sx: number, sy: number, tile: any, input: any) =>
|
||||
canvasKit.ImageFilter.MakeBlur(sx, sy, tile, input ?? null),
|
||||
MakeCompose: (outer: any, inner: any) =>
|
||||
canvasKit.ImageFilter.MakeCompose(outer ?? null, inner ?? null),
|
||||
MakeColorFilter: (cf: any, input: any) =>
|
||||
canvasKit.ImageFilter.MakeColorFilter(cf, input ?? null),
|
||||
},
|
||||
Shader: {
|
||||
MakeRadialGradient: (
|
||||
center: number[], radius: number, colors: any[], pos: number[], mode: any
|
||||
) => canvasKit.Shader.MakeRadialGradient(center, radius, colors, pos, mode),
|
||||
},
|
||||
RuntimeEffect: {
|
||||
Make: (sksl: string) => canvasKit.RuntimeEffect.Make(sksl),
|
||||
},
|
||||
Surface: {
|
||||
// A WebGL-backed surface is not a nicety here, it is the difference
|
||||
// between usable and unusable: the same 12MP pipeline that finishes in
|
||||
// well under a second on a GL surface took 3.2s for ONE tone pass and
|
||||
// 13.7s for the CLARITY tap in the Node CPU raster benchmark. Raster is
|
||||
// the fallback for a context-less environment (Node spikes, tests).
|
||||
Make: (w: number, h: number) => rasterSurface(w, h),
|
||||
MakeOffscreen: (w: number, h: number) => gpuSurface(w, h) ?? rasterSurface(w, h),
|
||||
},
|
||||
FontMgr: {
|
||||
System: () => fontMgr,
|
||||
},
|
||||
|
||||
// --- enums --------------------------------------------------------------
|
||||
BlendMode: canvasKit.BlendMode,
|
||||
TileMode: canvasKit.TileMode,
|
||||
FilterMode: canvasKit.FilterMode,
|
||||
MipmapMode: canvasKit.MipmapMode,
|
||||
FontStyle: canvasKit.FontStyle,
|
||||
ImageFormat: canvasKit.ImageFormat,
|
||||
ColorType: canvasKit.ColorType,
|
||||
ColorSpace: canvasKit.ColorSpace,
|
||||
AlphaType: canvasKit.AlphaType,
|
||||
PaintStyle: canvasKit.PaintStyle,
|
||||
};
|
||||
|
||||
fontMgr = makeFontMgr(canvasKit);
|
||||
return Skia;
|
||||
}
|
||||
|
||||
// Named-family lookup for the custom watermark's FONT option. Only faces the
|
||||
// bundle ships can be matched on web; anything else falls back to the caller's
|
||||
// default typeface — the honest behaviour when there is no OS font service.
|
||||
let fontMgr: any = null;
|
||||
|
||||
function makeFontMgr(canvasKit: any) {
|
||||
const provider = canvasKit.TypefaceFontProvider.Make();
|
||||
return {
|
||||
registerFace(typeface: any, familyName: string) {
|
||||
provider.registerFont(typeface, familyName);
|
||||
},
|
||||
matchFamilyStyle(family: string) {
|
||||
if (!family) return null;
|
||||
try {
|
||||
return provider.matchFamilyStyle?.(family, canvasKit.FontStyle.Normal) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user