Files
RecipesCam/docker/frontend/src/engine/skiaShim.ts
T
3dtours 8c6e7930db Add self-contained docker/ stack for the web UI
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:

    cd docker && cp .env.example .env && docker compose up -d --build

Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.

The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.

Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.

Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
2026-09-17 17:43:03 +07:00

214 lines
8.3 KiB
TypeScript

// 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(); };
}
}
// 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<any> {
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)
),
},
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;
}
},
};
}