Files
RecipesCam/src/utils/exportEngine.ts
T
admin 11ed786758 Let the GPS watermark be placed, sized and hung landscape
Three WATERMARK/FRAME behaviours that share the stamp geometry:

* GPS mark: drag it anywhere on the frame/photo area and pinch it bigger or
  smaller. The 0..1 anchor plus the size multiplier ride along in the export
  options, so a framed file (whose canvas IS the card/frame) burns the mark in
  at exactly the fraction the preview showed.
* The photo's own pinch zoom stays available while the WATERMARK editor is
  closed, so a two-finger zoom is never swallowed by the mark while its panel
  is open.
* WALL FRAME gains a WALL PORTRAIT / WALL LANDSCAPE chip: the artwork hangs
  in its own 4000x3117 orientation instead of the default 90-degree rotation.
2026-09-10 16:35:35 +07:00

711 lines
32 KiB
TypeScript

import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, FontStyle, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia';
import { File } from 'expo-file-system';
import * as FileSystem from 'expo-file-system/legacy';
import * as MediaLibrary from 'expo-media-library/legacy';
import { Asset } from 'expo-asset';
import { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
import { rotateSkImage90 } from './skiaImage';
export interface ExportResult {
uri: string;
savedToLibrary: boolean;
// MediaLibrary asset id when the photo was saved to the gallery — lets the
// photo viewer's trash button remove the gallery copy too.
assetId?: string | null;
}
export interface ExportOptions {
// DPI to stamp into the JPEG header (metadata only — pixel size never changes).
dpi?: number;
// Apply a subtle Lightroom-style "Screen" output sharpen (unsharp mask) to the
// final pixels before encoding. Pixels already export at full resolution.
sharpen?: boolean;
// True for photos captured through the live camera: exposure compensation was
// already applied as a hardware AE bias at capture, so no 2^EV matrix gain
// should be added on top. Library stills omit this and get the matrix gain.
evFromCamera?: boolean;
// Composition aspect-ratio crop (upright 4:3 or 3:2). Center-largest crop
// applied right after decode — every later step (frame/GPS/sharpen/size)
// then runs on the cropped pixels, so the export matches the ratio the
// viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed
// opening and export size). 'full'/undefined = no crop.
aspect?: AspectRatio;
// Custom text watermark: typed text + 0..1 fractions of the photo area
// (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay,
// so the export lands where the preview showed it). Amber mono, no icons.
watermark?: {
text: string;
x: number;
y: number;
color?: string;
size?: number;
font?: string | null;
} | null;
// WATERMARK tab ROTATE chip: clockwise degrees (0/90/180/270) applied to
// BOTH the GPS lines and the custom text, each around its own anchor (the
// same anchor Viewfinder rotates around, so the preview matches the file).
watermarkRotation?: number;
// WATERMARK tab drag + pinch on the GPS lines: 0..1 fractions of the same
// area the custom mark uses (a framed export's canvas IS the frame) plus a
// multiplier on the base 3.2%-of-width size. Absent = the old top-left look.
gpsWatermark?: { x: number; y: number; size: number } | null;
// WALL FRAME hung the other way round (4000x3117, artwork un-rotated).
wallframeLandscape?: boolean;
// Library framed-window photo reposition (Viewfinder.getFrameWindowZoom):
// after pinch/drag inside a polaroid card window or a wall-frame opening,
// s = scale and u/v = the fraction of the photo's cover plane sitting at the
// WINDOW CENTER. The export draws the source sub-rectangle that stays
// visible through the opening, so the file matches the preview. Null /
// undefined = centered 1x (the historical full-image draw).
frameWindowZoom?: { s: number; u: number; v: number } | null;
// Library FRAME-tab rotation (90 = clockwise). Applied to the decoded source
// before the ratio crop, so the frame layout and cover maths see exactly the
// orientation the preview showed.
photoRotation?: 0 | 90;
}
// Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3
// convolution whose kernel sums to 1 (brightness preserved), amount ~0.5.
// ponytail: fixed subtle preset tuned for full-res desktop/screen output; turn
// into an amount/radius UI control if users ask for adjustable strength.
// GPU-backed surface when the Skia GPU context is available (convolutions/sharpen
// run on the Adreno shader cores instead of the CPU — big win on 50MP captures),
// falling back to the CPU raster surface. MakeOffscreen returns null when no GPU
// context exists yet, so the fallback keeps this safe in library-only cold starts.
function createSurface(width: number, height: number): SkSurface | null {
return Skia.Surface.MakeOffscreen(width, height) ?? Skia.Surface.Make(width, height);
}
function screenSharpenImage(image: SkImage, amount = 0.5): SkImage {
const w = image.width();
const h = image.height();
const center = 1 + 4 * amount;
const filter = Skia.ImageFilter.MakeMatrixConvolution(
3, 3,
[0, -amount, 0, -amount, center, -amount, 0, -amount, 0],
1, 0, 1, 1, TileMode.Clamp, false, null, null
);
const surf = createSurface(w, h);
if (!surf) return image; // fall back to unsharpened on allocation failure
const paint = Skia.Paint();
paint.setImageFilter(filter);
const c = surf.getCanvas();
c.clear(Skia.Color('transparent'));
c.drawImage(image, 0, 0, paint);
const sharpened = surf.makeImageSnapshot();
if (!sharpened) return image;
return sharpened;
}
// Unique filename per export. A fixed path made every export share one URI, and
// RN's <Image> cache (keyed by URI) then kept showing the previous photo in the
// thumbnail after a new capture overwrote the file. A fresh URI per export busts
// that cache.
const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`;
export async function processAndExportPhoto(
sourceUri: string,
recipe: Recipe,
frameId: FrameId,
useGeotag: boolean,
gpsInfo: GPSInfo | null,
options?: ExportOptions
): Promise<ExportResult | null> {
try {
// 1. Read source image file into Skia
const skiaData = await Skia.Data.fromURI(sourceUri);
if (!skiaData) {
console.error('Failed to load image data from URI');
return null;
}
const skImage0 = Skia.Image.MakeImageFromEncoded(skiaData);
if (!skImage0) {
console.error('Failed to parse image from URI');
return null;
}
let skImage = skImage0;
// FRAME-tab rotation first: the pixel-dimension swap then propagates
// through the ratio crop, frame layout and cover maths unchanged.
if (options?.photoRotation === 90) skImage = rotateSkImage90(skImage) ?? skImage;
let width = skImage.width();
let height = skImage.height();
// 1b. Aspect-ratio crop (composition): center-largest upright crop of the
// requested ratio. Runs BEFORE any processing so frame/GPS/grain/sharpen
// all apply to exactly the pixels the user framed. Wall Frame is exempt —
// its export is the fixed frame artwork whose opening cover-crops anyway.
const ratioWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
if (ratioWH && frameId !== 'wallframe') {
const curAspect = width / height;
let cropW = width;
let cropH = height;
let srcX = 0;
let srcY = 0;
if (curAspect > ratioWH) {
cropW = Math.round(height * ratioWH);
srcX = Math.round((width - cropW) / 2);
} else {
cropH = Math.round(width / ratioWH);
srcY = Math.round((height - cropH) / 2);
}
const cropSurface = createSurface(cropW, cropH);
if (!cropSurface) {
console.error('Failed to create aspect-crop surface');
return null;
}
const cropCanvas = cropSurface.getCanvas();
cropCanvas.drawImageRect(
skImage,
Skia.XYWHRect(srcX, srcY, cropW, cropH),
Skia.XYWHRect(0, 0, cropW, cropH),
Skia.Paint()
);
const cropped = cropSurface.makeImageSnapshot();
if (!cropped) {
console.error('Failed to snapshot aspect crop');
return null;
}
skImage = cropped;
width = cropW;
height = cropH;
}
// 2. Create offscreen canvas (GPU-backed when available — see createSurface)
const surface = createSurface(width, height);
if (!surface) {
console.error('Failed to create Skia surface');
return null;
}
const canvas = surface.getCanvas();
const paint = Skia.Paint();
const adjustments = recipe.adjustments;
// 3. Build Color Matrix Filter (+2^EV gain for library stills; camera
// captures already got the bias at exposure time → evFromCamera skips it).
const evStops = adjustments.exposureCompensation ?? 0;
const evMatrix = options?.evFromCamera
? getSkiaColorMatrix(recipe.baseFilter, adjustments)
: applyExposureGain(getSkiaColorMatrix(recipe.baseFilter, adjustments), evStops);
const colorFilter = Skia.ColorFilter.MakeMatrix(evMatrix);
paint.setColorFilter(colorFilter);
// 3b. Tone shader — DR / Highlight / Shadow curve on top of the matrix.
// Keep the built shader in its own variable: only drawRect (which samples
// through the shader) when a real shader exists. If the RuntimeEffect fails
// to compile / makeShaderWithChildren returns null, paint has no shader and a
// drawRect would fill solid opaque color → black/blank output.
const tone = getToneUniforms(adjustments);
const hasTone = toneIsActive(tone);
let toneShader: SkShader | null = null;
if (hasTone) {
let toneEffect: ReturnType<typeof Skia.RuntimeEffect.Make> = null;
try {
toneEffect = Skia.RuntimeEffect.Make(TONE_SKSL);
} catch (e) {
console.error('TONE_SKSL compile failed - falling back to matrix-only: ' + e);
}
if (toneEffect) {
const imageShader = skImage.makeShaderOptions(
TileMode.Clamp,
TileMode.Clamp,
FilterMode.Linear,
MipmapMode.None
);
toneShader = toneEffect.makeShaderWithChildren([tone.dr, tone.hl, tone.sh, tone.vib], [imageShader]);
if (toneShader) {
paint.setShader(toneShader);
} else {
console.error('Tone shader build failed - falling back to matrix-only');
}
} else {
console.error('TONE_SKSL compile failed - falling back to matrix-only');
}
}
// 3c. Cinema seasonal atmosphere grade (chain: cinema -> tone -> image).
// All-zero uniforms (no recipe.cinema) → skipped entirely.
const cinema = getCinemaUniforms(recipe.cinema);
let paintShader = toneShader;
if (cinema && cinemaIsActive(cinema.flat)) {
try {
const cinemaEffect = Skia.RuntimeEffect.Make(CINEMA_SKSL);
if (cinemaEffect) {
const child = toneShader ?? skImage.makeShaderOptions(
TileMode.Clamp,
TileMode.Clamp,
FilterMode.Linear,
MipmapMode.None
);
const cs = cinemaEffect.makeShaderWithChildren(cinema.flat, [child]);
if (cs) {
paintShader = cs;
paint.setShader(paintShader);
}
}
} catch (e) {
console.error('CINEMA_SKSL failed - skipped: ' + e);
}
}
// 4. Denoise / Clarity Image Filters (export-only enhancements)
let imageFilter = null;
// Denoise (Subtle blur to smooth noise)
if (adjustments.denoise > 0) {
const sigma = (adjustments.denoise / 10) * 0.6; // max 0.6px
imageFilter = Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null);
}
// Clarity (Convolution Sharpening if positive)
if (adjustments.clarity > 0) {
const sharpAmount = (adjustments.clarity / 10) * 0.8;
const kernel = [
0, -sharpAmount, 0,
-sharpAmount, 1 + 4 * sharpAmount, -sharpAmount,
0, -sharpAmount, 0,
];
const sharpenFilter = Skia.ImageFilter.MakeMatrixConvolution(
3,
3,
kernel,
1,
0,
0,
0,
TileMode.Clamp,
true,
imageFilter
);
if (sharpenFilter) {
imageFilter = sharpenFilter;
}
} else if (adjustments.clarity < 0) {
// Bloom/mist effect if negative
const mistSigma = Math.abs(adjustments.clarity / 10) * 4;
const mistFilter = Skia.ImageFilter.MakeBlur(mistSigma, mistSigma, TileMode.Clamp, imageFilter);
if (mistFilter) {
imageFilter = mistFilter;
}
}
if (imageFilter) {
paint.setImageFilter(imageFilter);
}
// 5. Draw primary image with color filters & enhancements. Tone path samples
// the image through the paint shader (child imageShader) with a rect so the
// whole chain (tone → matrix → denoise/clarity) applies — but only when a
// real shader was built; otherwise plain drawImage (never drawRect on a
// shader-less paint, which paints solid color).
if (paintShader) {
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), paint);
} else {
canvas.drawImage(skImage, 0, 0, paint);
}
// 5b. Highlight Diffusion Filter (HDF): bloom on the bright areas only —
// the software twin of the lens' built-in diffusion filter, Photoshopped
// exactly as described: extract the highlights with a hard contrast curve,
// Gaussian-blur that selection (radius 15..50px), Screen it back over the
// photo at reduced opacity. Screen against a near-black pixel is a no-op, so
// shadows stay where they were while lit areas spread their light.
// One 0..10 knob drives both the blur radius and the opacity, and the radius
// is a fraction of the width so any source size blooms alike.
// ponytail: split into radius/amount knobs only if the 1D ramp reads coarse.
if ((adjustments.hdf ?? 0) > 0) {
const strength = (adjustments.hdf ?? 0) / 10;
const sigma = width * (0.004 + 0.015 * strength); // ~10..50px at 2560 wide
// Highlight extraction: out = 2.5*in - 1.5 -> black below 0.6, speculars
// saturate white. Composed on TOP of the recipe matrix so a monochrome look
// glows white instead of leaking the original colours back through.
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,
];
// The order is load-bearing and NOT the paint's: a paint color filter runs
// after the paint's image filter, i.e. the blur would smear the recipe
// matrix into the selection instead of pulling the highlights out first —
// measured as a flat +6/255 haze in the shadows with no glow on the
// speculars. Chain both as image filters instead (inner runs first).
const selection = Skia.ImageFilter.MakeColorFilter(
Skia.ColorFilter.MakeCompose(Skia.ColorFilter.MakeMatrix(hiMatrix), colorFilter),
null
);
const glowPaint = Skia.Paint();
glowPaint.setBlendMode(BlendMode.Screen);
glowPaint.setAlphaf(0.15 + 0.35 * strength);
glowPaint.setImageFilter(
Skia.ImageFilter.MakeCompose(
Skia.ImageFilter.MakeBlur(sigma, sigma, TileMode.Clamp, null),
selection
)
);
// Same source the base draw used, so the glow sits on the processed pixels.
if (paintShader) {
glowPaint.setShader(paintShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), glowPaint);
} else {
canvas.drawImage(skImage, 0, 0, glowPaint);
}
}
// 6. Add Monochrome Grain Overlay
if (adjustments.grain > 0) {
const grainOpacity = adjustments.grain / 20; // up to 0.5 opacity
const grainPaint = Skia.Paint();
grainPaint.setBlendMode(BlendMode.Overlay);
grainPaint.setAlphaf(grainOpacity);
// Procedural noise shader
const noiseEffect = Skia.RuntimeEffect.Make(`
vec4 main(vec2 pos) {
float r = fract(sin(dot(pos.xy, vec2(12.9898, 78.233))) * 43758.5453);
return vec4(vec3(r), 1.0);
}
`);
const noiseShader = noiseEffect ? noiseEffect.makeShader([]) : null;
if (noiseShader) {
grainPaint.setShader(noiseShader);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), grainPaint);
}
}
// 6b. Vignetting: darken the four corners of the PHOTO. Radial gradient
// (clear through the mid-frame, black at the corners) whose radius is the
// photo diagonal/2, so the falloff stays circular on any aspect. Runs
// before the frame step, so on a polaroid/wall export the darkening lands
// on the picture inside the opening, never on the mat.
// ponytail: one amount knob (the Fuji-style VIGNETTING control); split into
// amount + radius/midpoint only if users ask to move the falloff.
if ((adjustments.vignette ?? 0) > 0) {
const v = Math.min(10, adjustments.vignette ?? 0) / 10;
const vignetteShader = 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],
TileMode.Clamp
);
if (vignetteShader) {
const vignettePaint = Skia.Paint();
vignettePaint.setShader(vignetteShader);
vignettePaint.setAlphaf(0.9 * v);
canvas.drawRect(Skia.XYWHRect(0, 0, width, height), vignettePaint);
}
}
// 7. Frame. RETRO POLAROID is not a border drawn on the photo: the whole
// processed picture is composited, uncropped, into the window of a bigger
// off-white card (real print proportions, see frameUtils) — the export IS
// the card, with the photo scaled to the window so it keeps ~WIN_W of the
// card width. WALL FRAME: the export IS the frame PNG at its native size —
// portrait (rotated 90° CW) or landscape, per the FRAME tab's ORIENTATION
// chip — with the photo cover-cropped
// (center) into the fixed 2:3 window (the mat opening of a real frame).
// Other frames (classic white border, legacy cinematic bars) draw over the
// photo-sized canvas as before.
let outputSurface: SkSurface = surface;
let stampCanvas = canvas;
let stampW = width;
let stampH = height;
if (frameId === 'polaroid') {
const photoImg = surface.makeImageSnapshot();
const outW = width; // card width matches the photo width
// Card proportions follow the UN-rotated source, so rotating the print
// inside the frame never resizes the frame (same rule as the viewfinder).
const frameAspect =
ratioWH ?? (options?.photoRotation === 90 ? 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) {
console.error('Failed to create polaroid card surface');
return null;
}
const cardCanvas = cardSurface.getCanvas();
const bgPaint = Skia.Paint();
bgPaint.setColor(Skia.Color('#faf9f6')); // off-white card
cardCanvas.drawRect(Skia.XYWHRect(0, 0, outW, outH), bgPaint);
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
const win = pl.window;
// Window aspect == the un-rotated photo aspect → an un-rotated export
// fills it exactly; a rotated print is contain-fitted (centered,
// letterboxed) inside it, never stretched.
// With a framed-window zoom the source rect shrinks to the visible part
// (window keeps showing the same geometry as the viewfinder).
const plainPaint = 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
);
// Thin seam separating image window from the card.
const seamPaint = 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') {
// Export at the ROTATED frame's native size (the frame is the product, so
// the file is exactly frame-sized — never the photo's own dimensions).
const photoImg = surface.makeImageSnapshot();
const pw = photoImg.width();
const ph = photoImg.height();
const wallLand = options?.wallframeLandscape === true;
const outW = wallLand ? WALLFRAME_LAND_W : WALLFRAME_W; // 3117 / 4000
const outH = wallLand ? WALLFRAME_LAND_H : WALLFRAME_H; // 4000 / 3117
const cardSurface = createSurface(outW, outH);
if (!cardSurface) {
console.error('Failed to create wallframe surface');
return null;
}
const cardCanvas = cardSurface.getCanvas();
// Decode the frame PNG artwork (bundled asset → base64, same path as the
// GPS fonts below — embedded assets may not expose a readable file URI).
const wfAsset = Asset.fromModule(require('../../wallframe.png'));
// Android release builds resolve an image asset to a drawable *name*
// ('wallframe', no scheme) and mark it downloaded, so downloadAsync()
// short-circuits and readAsStringAsync throws 'Unsupported scheme for
// location wallframe'. Clear that state so the native module copies the
// drawable out of the APK into the cache as a real file path.
wfAsset.localUri = null;
wfAsset.downloaded = false;
await wfAsset.downloadAsync();
const wfDataStr = wfAsset.localUri
? await FileSystem.readAsStringAsync(wfAsset.localUri, { encoding: FileSystem.EncodingType.Base64 })
: null;
if (!wfDataStr) {
console.error('Failed to load wallframe.png data');
return null;
}
const wfImage = Skia.Image.MakeImageFromEncoded(Skia.Data.fromBase64(wfDataStr));
if (!wfImage) {
console.error('Failed to decode wallframe.png');
return null;
}
const wl = wallframeLayout(outW, outH, 'fill', wallLand);
const win = wl.window;
// Cover-crop (center): scale the processed photo so it fills the fixed
// 3:2 window and cut the overflowing edges equally on both sides.
const plainPaint = Skia.Paint();
const scale = Math.max(win.w / pw, win.h / ph);
const srcW = win.w / scale;
const srcH = win.h / scale;
// Cover plane (center crop) that fills the window; a framed-window zoom
// shrinks the visible source sub-rect inside that plane.
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
);
// Artwork over the crop: its transparent window reveals the photo, the
// mat covers the rest. The source PNG is landscape — rotate it 90° CW
// onto the portrait canvas (same affine as wallframeLayout.drawMatrix).
cardCanvas.save();
if (wallLand) {
// Artwork is already landscape: outW/outH ARE its own size.
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);
}
// 8. Render GPS Geotag Watermark + Custom Text Watermark. One asset load +
// one amber-mono paint path serves both: GPS keeps its two icon lines; the
// custom watermark is plain text drawn at the user-dragged position.
const customWm = options?.watermark ?? null;
if ((useGeotag && gpsInfo) || customWm) {
const fontAsset = Asset.fromModule(require('../../assets/Cousine-Regular.ttf'));
const iconFontAsset = Asset.fromModule(require('../../assets/NotoEmoji-GPS.ttf'));
await Promise.all([fontAsset.downloadAsync(), iconFontAsset.downloadAsync()]);
const [fontData, iconFontData] = await Promise.all([
FileSystem.readAsStringAsync(fontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64 }),
FileSystem.readAsStringAsync(iconFontAsset.localUri!, { encoding: FileSystem.EncodingType.Base64 }),
]);
const typeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(fontData));
const iconTypeface = Skia.Typeface.MakeFreeTypeFaceFromData(Skia.Data.fromBase64(iconFontData));
if (typeface && iconTypeface) {
const fontSize = Math.round(stampW * 0.032); // 3.2% of output width
const textPaint = Skia.Paint();
textPaint.setColor(Skia.Color('#f59e0b')); // Amber 500
const iconPaint = Skia.Paint();
iconPaint.setColor(Skia.Color('#ffffff')); // icons white, distinct from text
// GPS block: its TOP-LEFT corner sits at the dragged fraction of the
// same area the viewfinder maps onto (a framed export's canvas IS the
// card/frame), so the preview and the file agree. Defaults = the old
// fixed top-left look.
if (useGeotag && gpsInfo) {
const locationName = gpsInfo.locality || 'STREET VIEW';
const d = new Date(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 = Skia.Font(typeface, gpsSize);
const gpsIconFont = Skia.Font(iconTypeface, gpsSize);
const gpsGap = Math.round(gpsSize * 1.6); // icon box (~1em) + one clear mono space (~0.6em)
const baseX = clamp01(gpsPos?.x ?? 0.05) * stampW;
const baseY = clamp01(gpsPos?.y ?? 0.1) * stampH;
const row1Y = baseY + Math.round(gpsSize * 1.1);
const row2Y = baseY + Math.round(gpsSize * 2.3);
// Spin the whole two-line block around its top-left (the anchor the
// viewfinder uses). 0 = the historical upright look.
const wmRot = options?.watermarkRotation ?? 0;
stampCanvas.save();
if (wmRot) stampCanvas.rotate(wmRot, baseX, baseY);
stampCanvas.drawText('📍', baseX, row1Y, iconPaint, gpsIconFont);
stampCanvas.drawText(locationName, baseX + gpsGap, row1Y, textPaint, gpsFont);
stampCanvas.drawText('📷', baseX, row2Y, iconPaint, gpsIconFont);
stampCanvas.drawText(timestampStr, baseX + gpsGap, row2Y, textPaint, gpsFont);
stampCanvas.restore();
}
// Custom watermark: baseline at the dragged fraction of the photo/window
// area + ~0.35em drop (the grabbed anchor reads as the text's visual
// center, not its baseline) — the exact formula Viewfinder uses, so
// preview == export.
if (customWm) {
// The mark lives on the FRAME: a framed export's canvas IS the
// card/frame (see the branches above), so the whole stamp is the
// area — the same 0..1 fractions the viewfinder drag produced.
const area = { dx: 0, dy: 0, dw: stampW, dh: stampH };
const clamp01 = (v: number) => Math.max(0, Math.min(1, v));
const wmSize = Math.round(fontSize * (customWm.size ?? 1));
const wmFont = customWm.font
? Skia.Font(
Skia.FontMgr.System().matchFamilyStyle(customWm.font, FontStyle.Normal),
wmSize
)
: Skia.Font(typeface, wmSize);
const wmPaint = Skia.Paint();
wmPaint.setColor(Skia.Color(customWm.color ?? '#f59e0b'));
const tx = area.dx + clamp01(customWm.x) * area.dw;
const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35);
const wmRot = options?.watermarkRotation ?? 0;
stampCanvas.save();
if (wmRot) stampCanvas.rotate(wmRot, tx, ty);
stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont);
stampCanvas.restore();
}
}
}
// 9. Snapshot & Encode to JPEG (base64 string, no SkData round-trip)
let resultImage = outputSurface.makeImageSnapshot();
// Sharpening knob (0..10) overrides the legacy boolean sharpen flag:
// legacy camera captures keep sharpen:true → 0.5 fallback when knob is 0.
const sharpKnob = adjustments.sharpening ?? 0;
const sharpenAmount = sharpKnob > 0 ? (sharpKnob / 10) * 0.8 : options?.sharpen ? 0.5 : 0;
if (sharpenAmount > 0) {
resultImage = screenSharpenImage(resultImage, sharpenAmount);
}
// Encode straight to raw JPEG bytes — no base64. The old path round-tripped
// the whole 12MP frame through JS base64 decode/encode for the DPI patch and
// then writeAsStringAsync, which alone took ~6s of the render.
let bytes = resultImage.encodeToBytes(ImageFormat.JPEG, 95);
if (!bytes || bytes.length === 0) {
console.error('Failed to encode image to JPEG');
return null;
}
// 9b. Patch DPI metadata (JFIF density / EXIF resolution) when requested —
// in-place Uint8Array work, no base64 round-trip.
if (options?.dpi && options.dpi > 0) {
bytes = patchJpegDpi(bytes, options.dpi);
}
// 10. Write binary bytes to a unique temporary local file. The unique name
// is load-bearing: a fixed URI would be served from RN's <Image> cache and
// the thumbnail/preview would keep showing the previous export.
const exportFile = nextExportFile();
try {
new File(exportFile).write(bytes);
} catch (e) {
console.error('Failed to write export file: ' + e);
return null;
}
// 11. Request Media Library permission & Save to device gallery.
// createAssetAsync (not saveToLibraryAsync) so the caller learns the
// gallery asset id — the photo viewer's trash button needs it to remove
// the saved copy. ponytail: old exports are NOT retired here anymore — the
// in-app photo history still references their cache files for the swipe
// viewer; the OS cache purge reclaims them eventually.
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
if (mediaPermission.granted) {
try {
const asset = await MediaLibrary.createAssetAsync(exportFile);
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };
} catch (e) {
console.warn('Save to gallery failed:', e);
}
} else {
console.warn('Media Library permission denied. Image saved to temporary cache only.');
}
return { uri: exportFile, savedToLibrary: false };
} catch (error) {
console.error('Error during photo processing and export:', error);
return null;
}
}