cb1839e36b
The one path where the look is chosen before the picture exists: OPEN CAMERA grades the camera's own feed with the recipe in force, many times a second, and the shutter hands the studio the sensor's still under that same recipe. Preview and file differ in resolution only — the still is `takePhoto`'s own frame, not a copy of the small preview video, with `grabFrame` and a 2d copy of the element behind it for the browsers that ship no ImageCapture. The renderer gains two inputs for it: `sourceImage`, a picture the caller already decoded (re-encoding the camera's frame to JPEG only to decode it again would cost more than the whole render), and `drawTo`, which paints the finished picture instead of encoding it. One render is in flight at a time; a frame that arrives during one is dropped, so a slow device shows a lower frame rate rather than a queue of moments that have passed. The view flashed black on a phone. Setting width/height on a canvas resets its bitmap: measured on the preview, a resize leaves mean 0 until the next render lands, which on this box is 0.5s and on a phone more. The buffer was sized from every incoming frame, and a capture that renegotiates its resolution — which Chromium does when the page is too slow to consume its frames, and this pipeline runs ~2 fps at 720p under software GL — strobed black/picture at every switch. The buffer is now sized on the first frame and after that only when the frame's aspect changes: a same-aspect frame is scaled into it. Swapping a 1280x720 stream for a 640x360 one mid-view now leaves the buffer at 1280x720 with no black frame, and 640x360 renders at 6-13 fps instead of 2. The frames are read from a <video>, which is now IN the document (1px, behind the black backdrop) rather than detached: Safari draws blank frames from a detached video, which is the same black-between-pictures. It leaves the document with the view, and the tracks are stopped, so the camera light goes out. Probes: cam-smoke (feed painted, resolution, frame rate, a monochrome sim reaching the live frames, shutter into the studio, close, console clean), cam-renegotiate (no resize, no blank frame, status line on the frames), cam-close-flip (flip returns a picture; video gone on close).
252 lines
11 KiB
TypeScript
252 lines
11 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(); };
|
|
}
|
|
}
|
|
// 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<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)
|
|
),
|
|
// An image straight off a canvas source — what the camera's own <video>
|
|
// is. CanvasKit draws it to a canvas and reads it back; the live view
|
|
// needs no JPEG round trip to hand a frame to the renderer.
|
|
MakeImageFromCanvasImageSource: (src: CanvasImageSource) =>
|
|
canvasKit.MakeImageFromCanvasImageSource(src),
|
|
// An image from bytes rather than from a file — the tone curve's 256x1
|
|
// lookup table (see toneCurve.ts). RN Skia takes the factory here as
|
|
// `Image.MakeImage(info, bytes, bytesPerRow)`; CanvasKit keeps that one
|
|
// top-level and only puts the encoded/lazy factories under `Image.`, so the
|
|
// call site has to be translated. CanvasKit's ImageInfo also insists on an
|
|
// explicit colorSpace where RN Skia's does not: everything this pipeline
|
|
// builds is sRGB, so it is filled in rather than repeated at every call.
|
|
MakeImage: (info: any, bytes: Uint8Array, bytesPerRow: number) =>
|
|
canvasKit.MakeImage({ colorSpace: canvasKit.ColorSpace.SRGB, ...info }, bytes, bytesPerRow),
|
|
},
|
|
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 watermarks' FONT option. Only faces the bundle
|
|
// ships can be matched on web; anything else falls back to the caller's own
|
|
// typeface — the honest behaviour when there is no OS font service.
|
|
//
|
|
// The registry is ours, not CanvasKit's TypefaceFontProvider: that one answers
|
|
// `null` for every style shape this wasm binding accepts (probed with
|
|
// `{weight:400,width:5,slant:0}`, with the enum objects, and with `.value`
|
|
// unwrapped — all null), so a name registered with it never resolves.
|
|
let fontMgr: any = null;
|
|
|
|
function makeFontMgr(_canvasKit: any) {
|
|
const faces: Record<string, any> = Object.create(null);
|
|
return {
|
|
registerFace(typeface: any, familyName: string) {
|
|
if (typeface && familyName) faces[familyName.trim().toLowerCase()] = typeface;
|
|
},
|
|
matchFamilyStyle(family: string) {
|
|
return (family && faces[family.trim().toLowerCase()]) || null;
|
|
},
|
|
};
|
|
}
|