Add CanvasKit web engine spike + W0 findings (recipes-web)
This commit is contained in:
@@ -0,0 +1,690 @@
|
||||
// Full-resolution render pipeline for the web build.
|
||||
//
|
||||
// Port of `src/utils/exportEngine.ts` (React Native / Skia JSI) onto CanvasKit,
|
||||
// step for step, so the file a browser downloads matches the file the phone
|
||||
// writes. What changed and why (W0 probe, measured — see skiaShim.ts header):
|
||||
// - `Skia.Data.fromURI` + expo file APIs → the caller hands in the JPEG bytes
|
||||
// and gets JPEG bytes back; the file save / gallery / MediaLibrary tail is
|
||||
// gone (the browser downloads a Blob instead).
|
||||
// - `MakeMatrixConvolution` does not exist in CanvasKit. The two convolution
|
||||
// steps (CLARITY, output sharpen) run through `CLARITY_SKSL` from
|
||||
// `toneShader.ts` — the same kernel `[0,-a,0,-a,1+4a,-a,0,-a,0]`, evaluated
|
||||
// as a shader pass. `convolve` replaces the pixels outright (the shader's
|
||||
// output is opaque and covers the same rect), so no clearing pass is needed.
|
||||
// - The negative-side blurs stay on `ImageFilter.MakeBlur`, but a blur cannot
|
||||
// be composited over the un-blurred content, so those passes snapshot, clear
|
||||
// and redraw. Order matches RN's nested filter chain: denoise → clarity →
|
||||
// mist → soften.
|
||||
// - EXIF rewrite (`writeJpegExif`) is not ported yet; `patchJpegDpi(300)`
|
||||
// alone keeps the print size metadata.
|
||||
// ponytail: EXIF says "W4 if the web export needs the camera/lens tags".
|
||||
|
||||
import { Skia } from './skiaShim';
|
||||
import type { Recipe, GPSInfo, FrameId, AspectRatio, CropRect } from '../../../src/types';
|
||||
import { ASPECT_RATIO_W_H } from '../../../src/types';
|
||||
import { getSkiaColorMatrix, applyExposureGain } from '../../../src/utils/colorUtils';
|
||||
import {
|
||||
TONE_SKSL,
|
||||
GLOW_SKSL,
|
||||
CLARITY_SKSL,
|
||||
getToneUniforms,
|
||||
toneIsActive,
|
||||
toneUniformArray,
|
||||
glowUniformArray,
|
||||
} from '../../../src/utils/toneShader';
|
||||
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from '../../../src/utils/cinemaShader';
|
||||
import {
|
||||
drawFrameOnCanvas,
|
||||
polaroidLayout,
|
||||
POLAROID_CARD,
|
||||
POLAROID_WIN_W,
|
||||
wallframeLayout,
|
||||
WALLFRAME_W,
|
||||
WALLFRAME_H,
|
||||
WALLFRAME_LAND_W,
|
||||
WALLFRAME_LAND_H,
|
||||
} from '../../../src/utils/frameUtils';
|
||||
import { patchJpegDpi } from '../../../src/utils/jpegDpi';
|
||||
import { applyPhotoRotation } from '../../../src/utils/skiaImage';
|
||||
|
||||
export interface RenderAssets {
|
||||
textFont: ArrayBuffer; // assets/Cousine-Regular.ttf
|
||||
emojiFont: ArrayBuffer; // assets/NotoEmoji-GPS.ttf
|
||||
wallframe: Uint8Array; // wallframe.png
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
dpi?: number;
|
||||
sharpen?: boolean;
|
||||
evFromCamera?: boolean;
|
||||
aspect?: AspectRatio;
|
||||
cropWH?: number;
|
||||
cropRect?: CropRect | null;
|
||||
watermark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
||||
liteMark?: { text: string; x: number; y: number; color?: string; size?: number; font?: string | null } | null;
|
||||
watermarkRotation?: number;
|
||||
gpsWatermark?: { x: number; y: number; size: number; color?: string; showName?: boolean; showTime?: boolean } | null;
|
||||
wallframeLandscape?: boolean;
|
||||
frameWindowZoom?: { s: number; u: number; v: number } | null;
|
||||
photoRotation?: 0 | 90 | 180 | 270;
|
||||
photoStraighten?: number;
|
||||
}
|
||||
|
||||
export interface RenderInput {
|
||||
source: Uint8Array;
|
||||
recipe: Recipe;
|
||||
frameId: FrameId;
|
||||
useGeotag?: boolean;
|
||||
gpsInfo?: GPSInfo | null;
|
||||
options?: RenderOptions;
|
||||
assets: RenderAssets;
|
||||
}
|
||||
|
||||
export interface RenderResult {
|
||||
bytes: Uint8Array;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type SkDisposable = { dispose(): void };
|
||||
|
||||
function disposeAll(items: SkDisposable[]): void {
|
||||
for (let i = items.length - 1; i >= 0; i--) {
|
||||
try {
|
||||
items[i].dispose();
|
||||
} catch {
|
||||
/* already released */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function release(owned: SkDisposable[], item: SkDisposable | null): void {
|
||||
if (!item) return;
|
||||
const i = owned.indexOf(item);
|
||||
if (i >= 0) owned.splice(i, 1);
|
||||
disposeAll([item]);
|
||||
}
|
||||
|
||||
function createSurface(width: number, height: number) {
|
||||
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
|
||||
}
|
||||
|
||||
// A WebGL surface batches draws; the readback paths (snapshot, encode) must see
|
||||
// them submitted first. A no-op on the raster fallback.
|
||||
function flush(surface: any): void {
|
||||
if (typeof surface?.flush === 'function') surface.flush();
|
||||
}
|
||||
|
||||
// --- shared runtime effects -------------------------------------------------
|
||||
|
||||
let sharpenEffect: any = null;
|
||||
let toneEffect: any = null;
|
||||
let cinemaEffect: any = null;
|
||||
let glowEffect: any = null;
|
||||
|
||||
function effects() {
|
||||
if (!sharpenEffect) sharpenEffect = Skia.RuntimeEffect.Make(CLARITY_SKSL);
|
||||
if (!toneEffect) toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
|
||||
if (!cinemaEffect) cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
|
||||
if (!glowEffect) glowEffect = Skia.RuntimeEffect.Make(GLOW_SKSL);
|
||||
return { sharpenEffect, toneEffect, cinemaEffect, glowEffect };
|
||||
}
|
||||
|
||||
// CLARITY_SKSL uniforms are (a, px.x, px.y); px = one source pixel = 1 unit on
|
||||
// a 1:1 export canvas, so the radius matches what the preview tuned.
|
||||
function convolvePaint(srcImage: any, amount: number): any {
|
||||
const { sharpenEffect: effect } = effects();
|
||||
if (!effect) return null;
|
||||
const paint = Skia.Paint();
|
||||
const child = srcImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
);
|
||||
const shader = effect.makeShaderWithChildren([amount, 1, 1], [child]);
|
||||
if (!shader) {
|
||||
paint.dispose();
|
||||
child.dispose();
|
||||
return null;
|
||||
}
|
||||
paint.setShader(shader);
|
||||
return { paint, shader, child };
|
||||
}
|
||||
|
||||
// The convolution must sample the ALREADY GRADED pixels (RN runs it as the
|
||||
// outermost filter of the draw), so the pass reads a snapshot of the surface
|
||||
// and writes the sharpened result back over the same rect.
|
||||
function convPass(canvas: any, surface: any, w: number, h: number, amount: number): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
if (!snap) return;
|
||||
drawConvolved(canvas, snap, w, h, amount);
|
||||
snap.dispose();
|
||||
}
|
||||
|
||||
// Replaces the pixels under the rect with the convolved result.
|
||||
function drawConvolved(canvas: any, srcImage: any, w: number, h: number, amount: number): void {
|
||||
const built = convolvePaint(srcImage, amount);
|
||||
if (!built) return;
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, w, h), built.paint);
|
||||
disposeAll([built.paint, built.shader, built.child]);
|
||||
}
|
||||
|
||||
function convolveImage(image: any, amount: number): any {
|
||||
const w = image.width();
|
||||
const h = image.height();
|
||||
const surf = createSurface(w, h);
|
||||
if (!surf) return image;
|
||||
try {
|
||||
drawConvolved(surf.getCanvas(), image, w, h, amount);
|
||||
return surf.makeImageSnapshot() ?? image;
|
||||
} finally {
|
||||
surf.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
function screenSharpenImage(image: any, amount = 0.5): any {
|
||||
return convolveImage(image, amount);
|
||||
}
|
||||
|
||||
// Snapshot → clear → blur redraw: the blur must not sit on top of the sharp
|
||||
// pixels it is meant to replace.
|
||||
function drawBlurred(canvas: any, surface: any, w: number, h: number, sigma: number): void {
|
||||
flush(surface);
|
||||
const snap = surface.makeImageSnapshot();
|
||||
const paint = Skia.Paint();
|
||||
const filter = Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null);
|
||||
paint.setImageFilter(filter);
|
||||
canvas.clear(Skia.Color('transparent'));
|
||||
canvas.drawImage(snap, 0, 0, paint);
|
||||
disposeAll([paint, filter, snap]);
|
||||
}
|
||||
|
||||
// --- fonts / artwork --------------------------------------------------------
|
||||
|
||||
let faces: { typeface: any; iconTypeface: any } | null = null;
|
||||
let wallframeImage: any = null;
|
||||
|
||||
function loadFaces(assets: RenderAssets) {
|
||||
if (faces) return faces;
|
||||
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.textFont);
|
||||
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(assets.emojiFont);
|
||||
if (!typeface || !iconTypeface) return null;
|
||||
faces = { typeface, iconTypeface };
|
||||
// Named-family lookup for the custom watermark's FONT option: register the
|
||||
// bundled face so at least that one name resolves on web.
|
||||
try {
|
||||
Skia.FontMgr.System().registerFace(typeface, 'Cousine');
|
||||
} catch {
|
||||
/* provider optional */
|
||||
}
|
||||
return faces;
|
||||
}
|
||||
|
||||
function loadWallframe(assets: RenderAssets) {
|
||||
if (wallframeImage) return wallframeImage;
|
||||
wallframeImage = Skia.Image.MakeImageFromEncoded(assets.wallframe);
|
||||
return wallframeImage;
|
||||
}
|
||||
|
||||
export async function renderPhoto(input: RenderInput): Promise<RenderResult | null> {
|
||||
const { recipe, frameId, options } = input;
|
||||
const owned: SkDisposable[] = [];
|
||||
const own = <T,>(obj: T | null | undefined): T => {
|
||||
if (obj) owned.push(obj as unknown as SkDisposable);
|
||||
return obj as T;
|
||||
};
|
||||
try {
|
||||
const skImage0 = Skia.Image.MakeImageFromEncoded(input.source);
|
||||
if (!skImage0) {
|
||||
console.error('Failed to parse source image');
|
||||
return null;
|
||||
}
|
||||
own(skImage0);
|
||||
let skImage = skImage0;
|
||||
|
||||
const rotated = applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten);
|
||||
if (rotated && rotated !== skImage) {
|
||||
own(rotated);
|
||||
release(owned, skImage);
|
||||
skImage = rotated;
|
||||
}
|
||||
let width = skImage.width();
|
||||
let height = skImage.height();
|
||||
|
||||
// 1b. Composition / FRAME-tab crop — identical maths to the RN engine.
|
||||
const aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
|
||||
const cropRect = options?.cropRect ?? null;
|
||||
const ratioWH = cropRect ? null : options?.cropWH ?? aspectWH;
|
||||
if ((cropRect || ratioWH) && frameId !== 'wallframe') {
|
||||
const curAspect = width / height;
|
||||
let cropW = width;
|
||||
let cropH = height;
|
||||
let srcX = 0;
|
||||
let srcY = 0;
|
||||
if (cropRect) {
|
||||
srcX = Math.max(0, Math.min(width - 1, Math.round(cropRect.x * width)));
|
||||
srcY = Math.max(0, Math.min(height - 1, Math.round(cropRect.y * height)));
|
||||
cropW = Math.max(1, Math.min(width - srcX, Math.round(cropRect.w * width)));
|
||||
cropH = Math.max(1, Math.min(height - srcY, Math.round(cropRect.h * height)));
|
||||
} else if (curAspect > (ratioWH as number)) {
|
||||
cropW = Math.round(height * (ratioWH as number));
|
||||
srcX = Math.round((width - cropW) / 2);
|
||||
} else {
|
||||
cropH = Math.round(width / (ratioWH as number));
|
||||
srcY = Math.round((height - cropH) / 2);
|
||||
}
|
||||
const cropSurface = createSurface(cropW, cropH);
|
||||
if (!cropSurface) return null;
|
||||
own(cropSurface);
|
||||
cropSurface.getCanvas().drawImageRect(
|
||||
skImage,
|
||||
Skia.XYWHRect(srcX, srcY, cropW, cropH),
|
||||
Skia.XYWHRect(0, 0, cropW, cropH),
|
||||
own(Skia.Paint())
|
||||
);
|
||||
const cropped = cropSurface.makeImageSnapshot();
|
||||
if (!cropped) return null;
|
||||
own(cropped);
|
||||
release(owned, skImage);
|
||||
release(owned, cropSurface);
|
||||
skImage = cropped;
|
||||
width = cropW;
|
||||
height = cropH;
|
||||
}
|
||||
|
||||
// 2. Render surface.
|
||||
const surface = createSurface(width, height);
|
||||
if (!surface) return null;
|
||||
own(surface);
|
||||
const canvas = surface.getCanvas();
|
||||
const paint = own(Skia.Paint());
|
||||
|
||||
const adjustments = recipe.adjustments;
|
||||
|
||||
// 3. Colour matrix (+2^EV gain unless the camera already biased the shot).
|
||||
const evStops = adjustments.exposureCompensation ?? 0;
|
||||
const evMatrix = options?.evFromCamera
|
||||
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
|
||||
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
|
||||
const colorFilter = own(Skia.ColorFilter.MakeMatrix(evMatrix));
|
||||
paint.setColorFilter(colorFilter);
|
||||
|
||||
// 3b. Tone shader.
|
||||
const tone = getToneUniforms(adjustments, recipe.baseFilter);
|
||||
let toneShader: any = null;
|
||||
if (toneIsActive(tone)) {
|
||||
const { toneEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const imageShader = own(
|
||||
skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
)
|
||||
);
|
||||
toneShader = own(effect.makeShaderWithChildren(toneUniformArray(tone), [imageShader]));
|
||||
if (toneShader) paint.setShader(toneShader);
|
||||
}
|
||||
}
|
||||
|
||||
// 3c. Cinema seasonal grade (cinema → tone → image).
|
||||
const cinema = getCinemaUniforms(recipe.cinema);
|
||||
let paintShader = toneShader;
|
||||
if (cinema && cinemaIsActive(cinema.flat)) {
|
||||
const { cinemaEffect: effect } = effects();
|
||||
if (effect) {
|
||||
const child =
|
||||
toneShader ??
|
||||
own(skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
));
|
||||
const cs = own(effect.makeShaderWithChildren(cinema.flat, [child]));
|
||||
if (cs) {
|
||||
paintShader = cs;
|
||||
paint.setShader(paintShader);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Denoise (blur only — CLARITY moved to its own pass below, see header).
|
||||
if (adjustments.denoise > 0) {
|
||||
const sigma = (adjustments.denoise / 10) * 0.6;
|
||||
paint.setImageFilter(own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)));
|
||||
}
|
||||
|
||||
// 5. Draw the graded photo.
|
||||
if (paintShader) {
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, paint);
|
||||
}
|
||||
|
||||
// 4b. CLARITY (positive) — convolution outside the denoise blur, the same
|
||||
// nesting RN builds with MakeMatrixConvolution(input = previous filter).
|
||||
if (adjustments.clarity > 0) {
|
||||
convPass(canvas, surface, width, height, (adjustments.clarity / 10) * 0.8);
|
||||
} else if (adjustments.clarity < 0) {
|
||||
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
|
||||
drawBlurred(canvas, surface, width, height, mistSigma);
|
||||
}
|
||||
// Negative SHARPENING takes edge enhancement back out — after the conv, so
|
||||
// it cannot blur away what the CLARITY pass just added.
|
||||
const sharpKnob = adjustments.sharpening ?? 0;
|
||||
if (sharpKnob < 0) {
|
||||
drawBlurred(canvas, surface, width, height, (Math.abs(sharpKnob) / 10) * 3);
|
||||
}
|
||||
|
||||
// 5b. HDF bright-pass bloom.
|
||||
if ((adjustments.hdf ?? 0) > 0) {
|
||||
const strength = (adjustments.hdf ?? 0) / 10;
|
||||
const sigma = width * (0.004 + 0.015 * strength);
|
||||
const { glowEffect: effect } = effects();
|
||||
const srcShader =
|
||||
paintShader ??
|
||||
own(skImage.makeShaderOptions(
|
||||
Skia.TileMode.Clamp, Skia.TileMode.Clamp, Skia.FilterMode.Linear, Skia.MipmapMode.None
|
||||
));
|
||||
const glowShader =
|
||||
effect != null ? own(effect.makeShaderWithChildren(glowUniformArray(), [srcShader])) : null;
|
||||
const glowPaint = own(Skia.Paint());
|
||||
glowPaint.setBlendMode(Skia.BlendMode.Screen);
|
||||
glowPaint.setAlphaf(0.15 + 0.35 * strength);
|
||||
if (glowShader != null) {
|
||||
glowPaint.setShader(glowShader);
|
||||
glowPaint.setImageFilter(
|
||||
own(Skia.ImageFilter.MakeCompose(
|
||||
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
|
||||
own(Skia.ImageFilter.MakeColorFilter(colorFilter, null))
|
||||
))
|
||||
);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
const hiMatrix = [
|
||||
2.5, 0, 0, 0, -1.5,
|
||||
0, 2.5, 0, 0, -1.5,
|
||||
0, 0, 2.5, 0, -1.5,
|
||||
0, 0, 0, 1, 0,
|
||||
];
|
||||
glowPaint.setImageFilter(
|
||||
own(Skia.ImageFilter.MakeCompose(
|
||||
own(Skia.ImageFilter.MakeBlur(sigma, sigma, Skia.TileMode.Clamp, null)),
|
||||
own(Skia.ImageFilter.MakeColorFilter(
|
||||
own(Skia.ColorFilter.MakeCompose(
|
||||
own(Skia.ColorFilter.MakeMatrix(hiMatrix)),
|
||||
colorFilter
|
||||
)),
|
||||
null
|
||||
))
|
||||
))
|
||||
);
|
||||
if (paintShader) {
|
||||
glowPaint.setShader(paintShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
|
||||
} else {
|
||||
canvas.drawImage(skImage, 0, 0, glowPaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Monochrome grain overlay (negative NR re-grains, same ceiling as GRAIN).
|
||||
const grainAmount = Math.min(
|
||||
10,
|
||||
Math.max(0, adjustments.grain) + Math.max(0, -(adjustments.denoise ?? 0))
|
||||
);
|
||||
if (grainAmount > 0) {
|
||||
const grainPaint = own(Skia.Paint());
|
||||
grainPaint.setBlendMode(Skia.BlendMode.Overlay);
|
||||
grainPaint.setAlphaf(grainAmount / 20);
|
||||
const noiseEffect = own(Skia.RuntimeEffect.Make(`
|
||||
uniform float u;
|
||||
float grainHash(vec2 q) {
|
||||
return fract(sin(dot(q, vec2(12.9898, 78.233))) * 43758.5453);
|
||||
}
|
||||
vec4 main(vec2 pos) {
|
||||
vec2 p = pos.xy / max(u, 1.0);
|
||||
vec2 i = floor(p);
|
||||
vec2 f = p - i;
|
||||
f = f * f * (3.0 - 2.0 * f);
|
||||
float g00 = grainHash(i);
|
||||
float g10 = grainHash(i + vec2(1.0, 0.0));
|
||||
float g01 = grainHash(i + vec2(0.0, 1.0));
|
||||
float g11 = grainHash(i + vec2(1.0, 1.0));
|
||||
return vec4(vec3(mix(mix(g00, g10, f.x), mix(g01, g11, f.x), f.y)), 1.0);
|
||||
}
|
||||
`));
|
||||
const noiseShader = noiseEffect ? own(noiseEffect.makeShader([width / 1080])) : null;
|
||||
if (noiseShader) {
|
||||
grainPaint.setShader(noiseShader);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
|
||||
}
|
||||
}
|
||||
|
||||
// 6b. Vignette.
|
||||
if ((adjustments.vignette ?? 0) > 0) {
|
||||
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
|
||||
const vignetteShader = own(Skia.Shader.MakeRadialGradient(
|
||||
Skia.Point(width / 2, height / 2),
|
||||
Math.hypot(width, height) / 2,
|
||||
[Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,0)'), Skia.Color('rgba(0,0,0,1)')],
|
||||
[0, 0.45, 1],
|
||||
Skia.TileMode.Clamp
|
||||
));
|
||||
if (vignetteShader) {
|
||||
const vignettePaint = own(Skia.Paint());
|
||||
vignettePaint.setShader(vignetteShader);
|
||||
vignettePaint.setAlphaf(0.9 * v);
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
|
||||
}
|
||||
}
|
||||
|
||||
release(owned, skImage);
|
||||
|
||||
// 7. Frame.
|
||||
let outputSurface = surface;
|
||||
let stampCanvas = canvas;
|
||||
let stampW = width;
|
||||
let stampH = height;
|
||||
let cardSource: any = null;
|
||||
if (frameId === 'polaroid') {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const outW = width;
|
||||
const frameAspect =
|
||||
ratioWH ??
|
||||
(options?.photoRotation === 90 || options?.photoRotation === 270
|
||||
? height / width
|
||||
: width / height);
|
||||
const outH = Math.round(
|
||||
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) +
|
||||
(width * POLAROID_WIN_W) / frameAspect
|
||||
);
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const bgPaint = own(Skia.Paint());
|
||||
bgPaint.setColor(Skia.Color('#faf9f6'));
|
||||
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
|
||||
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
|
||||
const win = pl.window;
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const pz = options?.frameWindowZoom ?? null;
|
||||
const pwz = pz && pz.s > 1.001 ? pz : null;
|
||||
const pVisW = pwz ? width / pwz.s : width;
|
||||
const pVisH = pwz ? height / pwz.s : height;
|
||||
const pSx = pwz ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) : 0;
|
||||
const pSy = pwz ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) : 0;
|
||||
const srcAspect = width / height;
|
||||
let dstW = win.w;
|
||||
let dstH = dstW / srcAspect;
|
||||
if (dstH > win.h) {
|
||||
dstH = win.h;
|
||||
dstW = dstH * srcAspect;
|
||||
}
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(pSx, pSy, pVisW, pVisH),
|
||||
Skia.XYWHRect(win.x + (win.w - dstW) / 2, win.y + (win.h - dstH) / 2, dstW, dstH),
|
||||
plainPaint
|
||||
);
|
||||
const seamPaint = own(Skia.Paint());
|
||||
seamPaint.setColor(Skia.Color('#e5e5e5'));
|
||||
seamPaint.setStyle(1);
|
||||
seamPaint.setStrokeWidth(1);
|
||||
cardCanvas.drawRect(Skia.XYWHRect(win.x + 0.5, win.y + 0.5, win.w - 1, win.h - 1), seamPaint);
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else if (frameId === 'wallframe') {
|
||||
flush(surface);
|
||||
const photoImg = own(surface.makeImageSnapshot());
|
||||
cardSource = photoImg;
|
||||
const pw = photoImg.width();
|
||||
const ph = photoImg.height();
|
||||
const wallLand = options?.wallframeLandscape === true;
|
||||
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W;
|
||||
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H;
|
||||
const cardSurface = createSurface(outW, outH);
|
||||
if (!cardSurface) return null;
|
||||
own(cardSurface);
|
||||
const cardCanvas = cardSurface.getCanvas();
|
||||
const wfImage = loadWallframe(input.assets);
|
||||
if (!wfImage) {
|
||||
console.error('Failed to decode wallframe.png');
|
||||
return null;
|
||||
}
|
||||
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
|
||||
const win = wl.window;
|
||||
const plainPaint = own(Skia.Paint());
|
||||
const scale = Math.max(win.w / pw, win.h / ph);
|
||||
const srcW = win.w / scale;
|
||||
const srcH = win.h / scale;
|
||||
const planeX = (pw - srcW) / 2;
|
||||
const planeY = (ph - srcH) / 2;
|
||||
const wz = options?.frameWindowZoom ?? null;
|
||||
const wZoom = wz && wz.s > 1.001 ? wz : null;
|
||||
const wVisW = wZoom ? srcW / wZoom.s : srcW;
|
||||
const wVisH = wZoom ? srcH / wZoom.s : srcH;
|
||||
const wSx = wZoom
|
||||
? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW)
|
||||
: planeX;
|
||||
const wSy = wZoom
|
||||
? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH)
|
||||
: planeY;
|
||||
cardCanvas.drawImageRect(
|
||||
photoImg,
|
||||
Skia.XYWHRect(wSx, wSy, wVisW, wVisH),
|
||||
Skia.XYWHRect(win.x, win.y, win.w, win.h),
|
||||
plainPaint
|
||||
);
|
||||
cardCanvas.save();
|
||||
if (wallLand) {
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
} else {
|
||||
cardCanvas.translate(outW, 0);
|
||||
cardCanvas.rotate(90, 0, 0);
|
||||
cardCanvas.drawImage(wfImage, 0, 0, plainPaint);
|
||||
}
|
||||
cardCanvas.restore();
|
||||
outputSurface = cardSurface;
|
||||
stampCanvas = cardCanvas;
|
||||
stampW = outW;
|
||||
stampH = outH;
|
||||
} else {
|
||||
drawFrameOnCanvas(canvas, width, height, frameId);
|
||||
}
|
||||
if (cardSource) {
|
||||
release(owned, cardSource);
|
||||
release(owned, surface);
|
||||
}
|
||||
|
||||
// 8. GPS + custom watermark.
|
||||
const customWm = options?.watermark ?? null;
|
||||
const liteWm = options?.liteMark ?? null;
|
||||
if ((input.useGeotag && input.gpsInfo) || customWm || liteWm) {
|
||||
const loaded = loadFaces(input.assets);
|
||||
if (loaded) {
|
||||
const { typeface, iconTypeface } = loaded;
|
||||
const fontSize = Math.round(stampW * 0.032);
|
||||
const textPaint = own(Skia.Paint());
|
||||
textPaint.setColor(Skia.Color('#f59e0b'));
|
||||
const iconPaint = own(Skia.Paint());
|
||||
iconPaint.setColor(Skia.Color('#ffffff'));
|
||||
|
||||
if (input.useGeotag && input.gpsInfo) {
|
||||
const locationName = input.gpsInfo.locality || 'STREET VIEW';
|
||||
const d = new Date(input.gpsInfo.timestamp);
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
const gpsPos = options?.gpsWatermark ?? null;
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const gpsSize = Math.round(fontSize * (gpsPos?.size ?? 1));
|
||||
const gpsFont = own(Skia.Font(typeface, gpsSize));
|
||||
const gpsIconFont = own(Skia.Font(iconTypeface, gpsSize));
|
||||
const gpsGap = Math.round(gpsSize * 1.6);
|
||||
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
|
||||
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
|
||||
const lines: { icon: string; text: string }[] = [];
|
||||
if (gpsPos?.showName !== false) lines.push({ icon: '📍', text: locationName });
|
||||
if (gpsPos?.showTime !== false) lines.push({ icon: '📷', text: timestampStr });
|
||||
textPaint.setColor(Skia.Color(gpsPos?.color ?? '#f59e0b'));
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
|
||||
lines.forEach((line, i) => {
|
||||
const rowY = baseY + Math.round(gpsSize * (1.1 + i * 1.2));
|
||||
stampCanvas.drawText(line.icon, baseX, rowY, iconPaint, gpsIconFont);
|
||||
stampCanvas.drawText(line.text, baseX + gpsGap, rowY, textPaint, gpsFont);
|
||||
});
|
||||
stampCanvas.restore();
|
||||
}
|
||||
|
||||
if (customWm || liteWm) {
|
||||
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
|
||||
const wmRot = options?.watermarkRotation ?? 0;
|
||||
for (const mark of [customWm, liteWm]) {
|
||||
if (!mark) continue;
|
||||
const wmSize = Math.round(fontSize * (mark.size ?? 1));
|
||||
const wmFace = mark.font ? Skia.FontMgr.System().matchFamilyStyle(mark.font) : null;
|
||||
const wmFont = own(Skia.Font(wmFace ?? typeface, wmSize));
|
||||
const wmPaint = own(Skia.Paint());
|
||||
wmPaint.setColor(Skia.Color(mark.color ?? '#f59e0b'));
|
||||
const tx = clamp01(mark.x) * stampW;
|
||||
const ty = clamp01(mark.y) * stampH + Math.round(wmSize * 0.35);
|
||||
stampCanvas.save();
|
||||
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
|
||||
stampCanvas.drawText(mark.text, tx, ty, wmPaint, wmFont);
|
||||
stampCanvas.restore();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 9. Snapshot, optional output sharpen, JPEG encode + DPI patch.
|
||||
flush(outputSurface);
|
||||
let resultImage = own(outputSurface.makeImageSnapshot());
|
||||
const sharpenAmount =
|
||||
sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : sharpKnob < 0 ? 0 : options?.sharpen ? 0.5 : 0;
|
||||
if (sharpenAmount > 0) {
|
||||
const sharpened = screenSharpenImage(resultImage, sharpenAmount);
|
||||
if (sharpened && sharpened !== resultImage) {
|
||||
own(sharpened);
|
||||
release(owned, resultImage);
|
||||
resultImage = sharpened;
|
||||
}
|
||||
}
|
||||
let bytes = resultImage.encodeToBytes(Skia.ImageFormat.JPEG, 95);
|
||||
if (!bytes || bytes.length === 0) {
|
||||
console.error('Failed to encode image to JPEG');
|
||||
return null;
|
||||
}
|
||||
if (options?.dpi && options.dpi > 0) {
|
||||
bytes = patchJpegDpi(bytes, options.dpi);
|
||||
}
|
||||
return { bytes, width: resultImage.width(), height: resultImage.height() };
|
||||
} catch (error) {
|
||||
console.error('Render failed:', error);
|
||||
return null;
|
||||
} finally {
|
||||
disposeAll(owned);
|
||||
}
|
||||
}
|
||||
@@ -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