Edit the watermark in place, rotate the frame photo, place a custom watermark

Watermark editing no longer covers the photo: the preview letterboxes the
image inside its fit rect while the deck is open, and the idle hint is gone.

The FRAME tab can rotate a library photo 90 degrees clockwise. Rotation
swaps the pixel dimensions instead of the frame, so the polaroid/wall window
keeps its aspect and every consumer (cover crop, watermark area, export) picks
up the new orientation from width()/height() with no extra layout branch. The
rotate surface is CPU-backed on purpose - a GPU snapshot loses its pixels
before the next frame and renders the photo black.

Custom watermark text can now be dragged anywhere on the photo, double-tapped
to re-edit, and is only baked into the file once ACCEPT is pressed; the export
engine takes the same position. It also gained COLOR/SIZE/FONT chips, with the
font list read from the OS via Skia's FontMgr so any installed family can be
used.
This commit is contained in:
2026-09-10 09:26:55 +07:00
parent ff06045e02
commit 3a8b110c1b
6 changed files with 347 additions and 42 deletions
+50 -11
View File
@@ -1,4 +1,4 @@
import { Skia, ImageFormat, BlendMode, TileMode, FilterMode, MipmapMode, type SkImage, type SkShader, type SkSurface } from '@shopify/react-native-skia';
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';
@@ -9,6 +9,7 @@ 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 } from './frameUtils';
import { patchJpegDpi } from './jpegDpi';
import { rotateSkImage90 } from './skiaImage';
export interface ExportResult {
uri: string;
@@ -37,7 +38,14 @@ export interface ExportOptions {
// 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 } | null;
watermark?: {
text: string;
x: number;
y: number;
color?: string;
size?: number;
font?: string | null;
} | null;
// 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
@@ -45,6 +53,10 @@ export interface ExportOptions {
// 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
@@ -108,6 +120,9 @@ export async function processAndExportPhoto(
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
@@ -324,8 +339,13 @@ export async function processAndExportPhoto(
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) + height * POLAROID_WIN_W
width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) +
(width * POLAROID_WIN_W) / frameAspect
);
const cardSurface = createSurface(outW, outH);
if (!cardSurface) {
@@ -336,9 +356,11 @@ export async function processAndExportPhoto(
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, width / height, 'contain');
const pl = polaroidLayout(outW, outH, frameAspect, 'contain');
const win = pl.window;
// Window aspect == photo aspect → fills exactly, no letterbox inside.
// 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();
@@ -352,10 +374,17 @@ export async function processAndExportPhoto(
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.y, win.w, win.h),
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.
@@ -505,13 +534,23 @@ export async function processAndExportPhoto(
// center, not its baseline) — the exact formula Viewfinder uses, so
// preview == export.
if (customWm) {
const area = framedWindow
? { dx: framedWindow.x, dy: framedWindow.y, dw: framedWindow.w, dh: framedWindow.h }
: { dx: 0, dy: 0, dw: stampW, dh: stampH };
// 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(fontSize * 0.35);
stampCanvas.drawText(customWm.text, tx, ty, textPaint, font);
const ty = area.dy + clamp01(customWm.y) * area.dh + Math.round(wmSize * 0.35);
stampCanvas.drawText(customWm.text, tx, ty, wmPaint, wmFont);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Skia, type SkImage } from '@shopify/react-native-skia';
// Turn a decoded image 90deg clockwise into a fresh SkImage. Used by the FRAME
// tab so a library photo can go landscape <-> portrait before it enters a frame:
// every consumer reads width()/height(), so the pixel-dimension swap carries the
// new orientation through the polaroid window aspect, the cover crop and the
// watermark areas without a single extra layout branch.
// ponytail: offscreen surface, same fallback as exportEngine.createSurface; a
// matrix-based draw would avoid the copy if this ever shows up in a profile.
export function rotateSkImage90(image: SkImage | null): SkImage | null {
if (!image) return null;
const w = image.width();
const h = image.height();
if (!(w > 0 && h > 0)) return null;
// CPU-backed on purpose: a GPU (MakeOffscreen) snapshot keeps its pixels in a
// context texture that is gone by the time the <Canvas> draws a frame later —
// the preview then renders the photo completely black. Raster pixels survive
// the trip to the render thread.
const surface = Skia.Surface.Make(h, w);
if (!surface) return image; // keep the unrotated photo on allocation failure
const canvas = surface.getCanvas();
canvas.clear(Skia.Color('transparent'));
canvas.translate(h, 0);
canvas.rotate(90, 0, 0);
canvas.drawImage(image, 0, 0);
return surface.makeImageSnapshot() ?? image;
}