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:
+50
-11
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user