// 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 { GRAIN_SKSL, GRAIN_SEED, grainCell } from '../../../src/utils/grainShader'; 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 { const { recipe, frameId, options } = input; const owned: SkDisposable[] = []; const own = (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); // Same shader and same cell rule as the phone's export and the preview // (see src/utils/grainShader.ts): one noise cell per 1/1080 of the // picture's width, floored at one output pixel. const noiseEffect = own(Skia.RuntimeEffect.Make(GRAIN_SKSL)); const noiseShader = noiseEffect ? own(noiseEffect.makeShader([grainCell(width), GRAIN_SEED[0], GRAIN_SEED[1]])) : 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); } }