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
+34 -2
View File
@@ -4,7 +4,7 @@ import Slider from '@react-native-community/slider';
import * as Haptics from 'expo-haptics';
import { Trash2 } from 'lucide-react-native';
import { Skia } from '@shopify/react-native-skia';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter } from '../types';
import { Recipe, ColorAdjustments, CustomWatermark, GpsWatermark, FrameId, BaseFilter, CropRatio } from '../types';
import { FRAMES } from '../utils/frameUtils';
import { PARAM_DEFS, ContinuousParamDef } from '../utils/paramDefs';
import { FILM_SIMS, filmSimToRecipe } from '../utils/defaultRecipes';
@@ -28,7 +28,8 @@ type GroupKey =
| 'wmFont'
| 'gpsColor'
| 'gpsName'
| 'gpsTime';
| 'gpsTime'
| 'crop';
interface AdjustmentPanelProps {
activeTab: TabId;
@@ -36,6 +37,12 @@ interface AdjustmentPanelProps {
currentRecipeId: string;
adjustments: ColorAdjustments;
selectedFrame: FrameId;
// FRAME tab CROP. Only meaningful on the library's plain frames (the camera
// composes through its ratio band, polaroid/wall through their own window),
// so App hides the chip everywhere else.
cropRatio: CropRatio;
onUpdateCropRatio: (ratio: CropRatio) => void;
cropEnabled?: boolean;
useGeotag: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
// Settings sheet (gear between library picker and shutter) — no longer
@@ -150,6 +157,9 @@ export default function AdjustmentPanel({
currentRecipeId,
adjustments,
selectedFrame,
cropRatio,
onUpdateCropRatio,
cropEnabled,
useGeotag,
openParam,
onOpenParamChange,
@@ -203,6 +213,11 @@ export default function AdjustmentPanel({
setShowRecipes(false);
setCreateVisible(false);
}, [activeTab]);
// CROP is a plain-frame tool: the moment the selected frame drops it the chip
// unmounts, so the ratio strip must close with it (no orphan CROP row).
useEffect(() => {
if (!cropEnabled) setOpenGroup((g) => (g === 'crop' ? null : g));
}, [cropEnabled]);
const toggleParam = (key: string) => {
haptic();
@@ -416,6 +431,22 @@ export default function AdjustmentPanel({
options: [{ v: 'on', d: 'ON' }, { v: 'off', d: 'OFF' }],
onPick: (v) => onUpdateGpsWm({ ...gpsWm, showName: v === 'on' }),
},
crop: {
label: 'CROP', off: 'none', value: cropRatio,
// 'free' keeps an explicit user-dragged rectangle; the rest center-crop
// the photo to the fixed shape the export writes.
options: [
{ v: 'none', d: 'NONE' },
{ v: 'free', d: 'FREE' },
{ v: '1:1', d: '1:1' },
{ v: '2:3', d: '2:3' },
{ v: '3:2', d: '3:2' },
{ v: '3:4', d: '3:4' },
{ v: '4:3', d: '4:3' },
{ v: '16:9', d: '16:9' },
],
onPick: (v) => onUpdateCropRatio(v as CropRatio),
},
gpsTime: {
label: 'TIME', off: 'ON',
value: gpsWm.showTime === false ? 'OFF' : 'ON',
@@ -542,6 +573,7 @@ export default function AdjustmentPanel({
selectedFrame,
(val) => onUpdateFrame(val as FrameId)
)}
{cropEnabled && renderChip(groupChip('crop'))}
{renderChip({
key: 'rotate',
label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0',