feat(frame): CROP ratios in the library editor, and the framed-photo fixes

CROP chip on the FRAME tab for the library's plain frames: NONE, FREE
(drag the box) and the fixed ratios 1:1, 2:3, 3:2, 3:4, 4:3, 16:9. The
preview draws the keep-rectangle over the photo and the export honours it
pixel-for-pixel, pinching and dragging inside the band to pick the framing.

- Viewfinder: build frameRect from the normalised rect so the Skia canvas
  never sees undefined width/height (nothing to commit until now), keep
  the watermark drag target across the WATERMARK tab so a zoomed photo
  no longer resets, and derive the crop band + export rect from the photo
  fit rect.
- exportEngine: crop by fraction rect (or the ratio fallback) before the
  frame is composited.
- Leaving a plain frame clears the crop, and the ratio strip closes with
  the CROP chip it belongs to.
This commit is contained in:
2026-09-10 20:12:03 +07:00
parent 299d81bade
commit 93f48537f8
5 changed files with 378 additions and 41 deletions
+23 -6
View File
@@ -3,7 +3,7 @@ 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 { Recipe, GPSInfo, FrameId, AspectRatio, ASPECT_RATIO_W_H, CropRect } from '../types';
import { getSkiaColorMatrix, applyExposureGain } from './colorUtils';
import { TONE_SKSL, getToneUniforms, toneIsActive } from './toneShader';
import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
@@ -35,6 +35,13 @@ export interface ExportOptions {
// viewfinder displayed. WALL FRAME ignores this (frame keeps its fixed
// opening and export size). 'full'/undefined = no crop.
aspect?: AspectRatio;
// FRAME-tab CROP, fixed ratio: width/height of the kept area, center-cropped
// exactly like `aspect` but for every non-full ratio. Wins over `aspect`.
cropWH?: number;
// FRAME-tab CROP, FREE: the kept rectangle as 0..1 fractions of the
// (post-rotation) source image. Wins over `cropWH` and `aspect`. WALL FRAME
// ignores both (fixed frame opening, cover-cropped by the artwork itself).
cropRect?: CropRect | null;
// 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.
@@ -139,18 +146,28 @@ export async function processAndExportPhoto(
// 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 aspectWH = options?.aspect && options.aspect !== 'full' ? ASPECT_RATIO_W_H[options.aspect] : null;
// FRAME-tab CROP (the App only passes it for the plain frames): an explicit
// FREE rectangle wins, then a fixed crop ratio, then the camera composition
// ratio.
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 (curAspect > ratioWH) {
cropW = Math.round(height * ratioWH);
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);
cropH = Math.round(width / (ratioWH as number));
srcY = Math.round((height - cropH) / 2);
}
const cropSurface = createSurface(cropW, cropH);