Zoom the FRAME preview instead of the photo, and save to DCIM/Camera

- FRAME tab now pinches the whole view (frame + photo + watermark) in both
  camera and library; one finger pans that view. Preview only: the exported
  file is identical at 1x and while the view is magnified.
- Watermark pinch/drag stay relative and keep priority while the WATERMARK
  row is open, so they still work after returning from the FRAME tab.
- Exports go through a native MediaStore insert into DCIM/Camera, falling
  back to expo-media-library when the native save fails.
This commit is contained in:
2026-09-11 23:09:26 +07:00
parent 40c91d2a75
commit 1d84fe8a4a
4 changed files with 348 additions and 69 deletions
+289 -69
View File
@@ -133,6 +133,11 @@ interface ViewfinderProps {
// to focus / zoom) while this is set, so a watermark left ON can never
// swallow the camera's tap-to-focus or the library's pinch/double-tap.
wmEditing?: boolean;
// True while the FRAME tab is the open deck tab. Pinching then magnifies the
// WHOLE preview — frame, photo and watermarks together — instead of the
// photo: a preview-only loupe for placing the mark. The saved file never
// changes, because the export reads the photo zoom, not this view zoom.
frameTabActive?: boolean;
// WALL FRAME hung landscape (4000x3117) instead of portrait.
wallLandscape?: boolean;
}
@@ -233,6 +238,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onWmSizeChange,
onWmEdit,
wmEditing = false,
frameTabActive = false,
photoRotation = 0,
photoStraighten = 0,
wmRotation = 0,
@@ -640,13 +646,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
v0 = t.value;
param = { min: t.min, max: t.max, step: t.step, onChange: t.onChange };
} else if (wmArmed || gpsWmArmed) {
// Watermark armed: every touch drags/places the mark under it (an open
// param slider keeps priority; the AE-lock EV drag yields while armed).
// Watermark armed: every touch drags the mark (an open param slider keeps
// priority; the AE-lock EV drag yields while armed). Anchor the drag —
// the mark only MOVES once the finger does, it never jumps under it.
kind = 'wm';
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
const tgt = wmTargetAt(t?.locationX ?? 0, t?.locationY ?? 0) ?? (wmArmed ? 'custom' : 'gps');
const tp = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 };
const tgt = wmTargetAt(tp.x, tp.y) ?? (wmArmed ? 'custom' : 'gps');
wmDragTgtRef.current = tgt;
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0, tgt);
beginWmDrag(tgt, tp.x, tp.y);
} else if (focusPoint && onExposureChange) {
kind = 'ev';
v0 = evStops;
@@ -656,7 +664,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const gts = e?.nativeEvent?.touches ?? [];
panRef.current =
gts.length === 1
? { x: gts[0].locationX, y: gts[0].locationY, tx: libZoom.tx, ty: libZoom.ty }
? {
x: gts[0].locationX,
y: gts[0].locationY,
px: gts[0].pageX,
py: gts[0].pageY,
tx: viewZoomed ? viewZoom.tx : libZoom.tx,
ty: viewZoomed ? viewZoom.ty : libZoom.ty,
}
: null;
panUsedRef.current = false;
if (gts.length < 2) pinchRef.current = null;
@@ -669,6 +684,49 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// the export takes (frameWindowZoom). One finger drags it too, but only a
// touch that no parameter row and no AE-lock EV drag has claimed.
const wts = e?.nativeEvent?.touches;
// Two fingers: a mark under them resizes (the WATERMARK panel's own pinch);
// anything else on the FRAME tab magnifies the whole view.
if (wts && wts.length >= 2) {
const mx = (wts[0].locationX + wts[1].locationX) / 2;
const my = (wts[0].locationY + wts[1].locationY) / 2;
const d = Math.hypot(
wts[0].locationX - wts[1].locationX,
wts[0].locationY - wts[1].locationY
);
const mi = { x: mx, y: my };
const mark = pinchMarkAt(mi.x, mi.y) ?? wmPinchRef.current?.tgt ?? null;
if (mark) {
const p = wmPinchRef.current;
if (!p) wmPinchRef.current = { tgt: mark, d, size: wmSizeOf(mark) };
else {
applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1));
if (g) g.moved = true;
}
return;
}
if (frameTabActive) {
applyViewPinch(wts);
if (g) g.moved = true;
return;
}
}
// FRAME tab, magnified view: one finger drags the picture inside the
// viewport (preview only, like the pinch). An armed mark keeps the touch.
if (viewZoomed && wts?.length === 1 && g?.kind === 'focus' && !pinchRef.current) {
const pan0 = panRef.current;
if (pan0) {
// pageX/pageY: absolute screen dp, immune to the view transform, so the
// picture tracks the finger 1:1 (locationX is scaled by the matrix).
const dx = wts[0].pageX - pan0.px;
const dy = wts[0].pageY - pan0.py;
if (Math.abs(dx) > TAP_SLOP || Math.abs(dy) > TAP_SLOP) panUsedRef.current = true;
if (panUsedRef.current) {
setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy }));
g.moved = true;
}
}
return;
}
if (g && g.kind !== 'wm' && !wmArmed && zoomWindowRect()) {
if (wts && wts.length >= 2) {
const [a, b] = wts;
@@ -707,32 +765,20 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
if (!g || g.kind === 'focus') return;
if (g.kind === 'wm') {
// Watermark drag: follow the finger; the commit happens on release below.
// Watermark drag: the mark keeps its place under the finger and travels
// by the finger's DELTA — it must never jump to the touch point. The
// second finger (pinch, above) resizes it instead; the commit is below.
const ts = e?.nativeEvent?.touches ?? [];
if (ts.length >= 2 && wmEditing) {
// A second finger turns placement into a pinch: it scales the mark
// (the camera frame behind it never changes).
const d = Math.hypot(
ts[0].locationX - ts[1].locationX,
ts[0].locationY - ts[1].locationY
);
const p = wmPinchRef.current;
if (!p) {
const tgt = wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps');
wmPinchRef.current = { tgt, d, size: wmSizeOf(tgt) };
} else {
applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1));
g.moved = true;
}
return;
}
const t = ts[0] ?? e?.nativeEvent;
placeWm(
t?.pageX ?? t?.locationX ?? 0,
t?.pageY ?? t?.locationY ?? 0,
wmDragTgtRef.current ?? (wmArmed ? 'custom' : 'gps')
);
g.moved = true;
const { x, y } = { x: t?.locationX ?? 0, y: t?.locationY ?? 0 };
const d0 = wmDragRef.current;
if (d0 && (Math.abs(x - d0.px) > TAP_SLOP || Math.abs(y - d0.py) > TAP_SLOP)) {
d0.moved = true;
}
if (d0?.moved) {
moveWmDrag(x, y);
g.moved = true;
}
return;
}
const dy = g.y0 - pageYOf(e); // up = positive / increase
@@ -764,16 +810,18 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
imgDragRef.current = null;
wmPinchRef.current = null;
wmDragTgtRef.current = null;
wmDragRef.current = null;
viewPinchRef.current = null;
pinchRef.current = null;
panRef.current = null;
panUsedRef.current = false;
if (!g) return;
if (g.kind === 'wm') {
// A tap places the text at the tapped point; a drag already moved it.
// Never lock AE/AF while the watermark is armed.
// A tap (never a drag) drops the text at the tapped point; a drag has
// already moved it. Never lock AE/AF while the watermark is armed.
if (!g.moved) {
const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent;
placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0);
placeWm(t?.locationX ?? 0, t?.locationY ?? 0);
}
commitWm();
return;
@@ -1052,12 +1100,33 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
useEffect(() => {
if (libCropActive) setLibZoom({ s: 1, tx: 0, ty: 0 });
}, [libCropActive, cropRatio]);
// FRAME-tab PREVIEW zoom: while that tab is open two fingers magnify the
// whole view (frame + photo + marks together) so a watermark can be placed
// and sized against a big picture. Preview only — the export reads libZoom,
// never this — and leaving the tab (a mode switch closes it) drops it to 1x.
const [viewZoom, setViewZoom] = useState({ s: 1, tx: 0, ty: 0 });
// Magnified preview (FRAME tab only): one finger then drags the picture
// inside the viewport, so it is the pan that pairs with the pinch above.
const viewZoomed = frameTabActive && viewZoom.s > 1.01;
// Keep the scaled view covering the viewport: no edge may pull inside it.
const clampViewZoom = (z: { s: number; tx: number; ty: number }) => {
const s = clampZoom(z.s);
return {
s,
tx: Math.min(0, Math.max(-(s - 1) * vw, z.tx)),
ty: Math.min(0, Math.max(-(s - 1) * vh, z.ty)),
};
};
const viewPinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
useEffect(() => {
if (!frameTabActive) setViewZoom({ s: 1, tx: 0, ty: 0 });
}, [frameTabActive]);
const pinchRef = useRef<{ d: number; s: number; tx: number; ty: number; fx: number; fy: number } | null>(null);
const pinchUsedRef = useRef(false);
// Framed-window drag: anchor at touch-down (finger + transform origin), so a
// move never accumulates rounding across a slow drag.
const panRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const panRef = useRef<{ x: number; y: number; px: number; py: number; tx: number; ty: number } | null>(null);
const panUsedRef = useRef(false);
const lastTapRef = useRef<{ x: number; y: number; t: number } | null>(null);
const clampZoom = (s: number) => Math.max(1, Math.min(6, s));
@@ -1424,17 +1493,52 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// The mark a running gesture grabbed, decided at grant time (the box under
// the first touch) so a drag keeps hold of the mark it started on.
const wmDragTgtRef = useRef<'custom' | 'gps' | null>(null);
const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') => {
const pos = wmFracFromXY(x, y);
const setWmFrac = (frac: { x: number; y: number }, tgt: 'custom' | 'gps') => {
if (tgt === 'custom') {
if (!wmArmed) return;
wmDragPosRef.current = pos;
setWmDragPos(pos);
wmDragPosRef.current = frac;
setWmDragPos(frac);
return;
}
if (!gpsWmArmed) return;
gpsDragPosRef.current = pos;
setGpsDragPos(pos);
gpsDragPosRef.current = frac;
setGpsDragPos(frac);
};
const placeWm = (x: number, y: number, tgt: 'custom' | 'gps') =>
setWmFrac(wmFracFromXY(x, y), tgt);
// The mark drags RELATIVELY: it keeps its spot when the finger lands and only
// travels by the finger's delta. Placing it AT the touch (the old behavior)
// yanked the mark under the finger, so a pinch that started on it threw it
// across the frame. Anchor its fraction + the touch-down point here.
const wmDragRef = useRef<{
tgt: 'custom' | 'gps';
fx: number;
fy: number;
px: number;
py: number;
moved: boolean;
} | null>(null);
const beginWmDrag = (tgt: 'custom' | 'gps', x: number, y: number) => {
const frac =
tgt === 'custom'
? { x: wmDragPosRef.current?.x ?? customWm.x, y: wmDragPosRef.current?.y ?? customWm.y }
: {
x: gpsDragPosRef.current?.x ?? gpsWm?.x ?? 0.05,
y: gpsDragPosRef.current?.y ?? gpsWm?.y ?? 0.1,
};
wmDragRef.current = { tgt, fx: frac.x, fy: frac.y, px: x, py: y, moved: false };
};
const moveWmDrag = (x: number, y: number) => {
const d = wmDragRef.current;
if (!d) return;
const area = wmArea();
setWmFrac(
{
x: Math.max(0, Math.min(1, d.fx + (x - d.px) / (area.dw || 1))),
y: Math.max(0, Math.min(1, d.fy + (y - d.py) / (area.dh || 1))),
},
d.tgt
);
};
const commitWm = () => {
const pos = wmDragPosRef.current;
@@ -1507,6 +1611,35 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
if (gpsWmArmed) return 'gps';
return null;
};
// Touch coordinates are CANVAS coordinates: RN hands locationX/Y back with the
// touch layer's own transform undone, and that layer now carries the very same
// matrix as the Skia canvas (see touchZoomStyle), so a mark hit-test or a drag
// line reads the touch straight off — no inverse at any view zoom.
// The armed mark a two-finger pinch is ON. Boxes only: a pinch on empty space
// must not steal the gesture from the FRAME tab's view zoom.
const pinchMarkAt = (x: number, y: number): 'custom' | 'gps' | null => {
if (!wmEditing) return null;
if (wmArmed && wmBoxHit(wmBox(), x, y)) return 'custom';
if (gpsWmArmed && gpsBoxHit(x, y)) return 'gps';
return null;
};
// Two fingers on the FRAME tab magnify the whole VIEW about their midpoint
// (the photo zoom's maths, aimed at the view instead).
const applyViewPinch = (ts: { locationX: number; locationY: number }[]) => {
const [a, b] = ts;
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
const mx = (a.locationX + b.locationX) / 2;
const my = (a.locationY + b.locationY) / 2;
const p = viewPinchRef.current;
if (!p) {
viewPinchRef.current = { d, s: viewZoom.s, tx: viewZoom.tx, ty: viewZoom.ty, fx: mx, fy: my };
return;
}
const s = clampZoom((p.s * d) / Math.max(p.d, 1));
// f* is the canvas point the fingers pinch on: keep s*f + t constant with
// the OLD s/t (at 1x this is the old fx*(1-k), now correct when zoomed too).
setViewZoom(clampViewZoom({ s, tx: p.tx + p.fx * (p.s - s), ty: p.ty + p.fy * (p.s - s) }));
};
// Library framed (polaroid/wall) returns reuse the SAME responder handlers as
// plain library (see the framed JSX below): single-finger drags place/move
// the text watermark while armed, and otherwise DRAG the photo inside the
@@ -1578,16 +1711,26 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
const ts = e.nativeEvent.touches;
panRef.current =
ts.length === 1 ? { x: ts[0].locationX, y: ts[0].locationY, tx: libZoom.tx, ty: libZoom.ty } : null;
ts.length === 1
? {
x: ts[0].locationX,
y: ts[0].locationY,
px: ts[0].pageX,
py: ts[0].pageY,
tx: viewZoomed ? viewZoom.tx : libZoom.tx,
ty: viewZoomed ? viewZoom.ty : libZoom.ty,
}
: null;
panUsedRef.current = false;
if (ts.length === 1 && (wmArmed || gpsWmArmed)) {
const x = ts[0].locationX;
const y = ts[0].locationY;
const { x, y } = { x: ts[0].locationX, y: ts[0].locationY };
const boxHit = (wmArmed && wmBoxHit(wmBox(), x, y)) || (gpsWmArmed && gpsBoxHit(x, y));
// Zoomed photo: only a touch ON a mark takes the gesture, the rest keeps
// panning the framed photo. At 1x the lone armed mark takes any touch so
// a plain tap still places it.
wmDragTgtRef.current = libZoom.s <= 1.01 || boxHit ? wmTargetAt(x, y) : null;
// Anything zoomed (photo or view): only a touch ON a mark takes the
// gesture, the rest keeps panning / magnifying. At 1x the lone armed mark
// takes any touch so a plain tap still places it.
wmDragTgtRef.current =
(libZoom.s <= 1.01 && viewZoom.s <= 1.01) || boxHit ? wmTargetAt(x, y) : null;
if (wmDragTgtRef.current) beginWmDrag(wmDragTgtRef.current, x, y);
} else wmDragTgtRef.current = null;
if (ts.length >= 2) {
const [a, b] = ts;
@@ -1597,14 +1740,21 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
pinchUsedRef.current = true;
lastTapRef.current = null;
if (wmEditing) {
// WATERMARK tab open: a two-finger gesture belongs to the marks. It
// scales the one under the fingers and NEVER zooms the photo — pinch
// zoom comes back the moment the panel closes.
const tgt = wmTargetAt(mx, my);
// WATERMARK tab open: a two-finger gesture ON a mark scales it. One
// that lands off the marks falls through to the FRAME tab's view zoom
// (seeded in the move handler), so it never zooms the photo either.
const mi = { x: mx, y: my };
const tgt = pinchMarkAt(mi.x, mi.y);
wmPinchRef.current = tgt ? { tgt, d, size: wmSizeOf(tgt) } : null;
pinchRef.current = null;
return;
}
// FRAME tab open: the pinch magnifies the VIEW, not the photo — the
// export keeps the framing the user sees at 1x.
if (frameTabActive) {
pinchRef.current = null;
return;
}
pinchRef.current = { d, s: libZoom.s, tx: libZoom.tx, ty: libZoom.ty, fx: mx, fy: my };
}
};
@@ -1623,10 +1773,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// editor. Seed the baseline the same way.
let p = wmPinchRef.current;
if (!p) {
const tgt = wmTargetAt(
(ts[0].locationX + ts[1].locationX) / 2,
(ts[0].locationY + ts[1].locationY) / 2
);
const mi = {
x: (ts[0].locationX + ts[1].locationX) / 2,
y: (ts[0].locationY + ts[1].locationY) / 2,
};
const tgt = pinchMarkAt(mi.x, mi.y);
if (tgt) {
p = {
tgt,
@@ -1639,15 +1790,45 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
if (p) {
const d = Math.hypot(ts[0].locationX - ts[1].locationX, ts[0].locationY - ts[1].locationY);
applyWmScale(p.tgt, (p.size * d) / Math.max(p.d, 1));
pinchUsedRef.current = true;
return;
}
// Off the marks on the FRAME tab: fall through to the view zoom below.
}
// FRAME tab: two fingers magnify the whole view — frame, photo and marks
// together — so a watermark can be dragged against a big picture. Preview
// only; nothing export-facing reads viewZoom.
if (ts.length >= 2 && frameTabActive) {
applyViewPinch(ts);
pinchUsedRef.current = true;
return;
}
// Watermark armed + one finger + unzoomed: the drag moves the mark (not the
// photo). Zoomed-in keeps pinch/pan so the user can inspect detail first.
if (ts.length === 1 && wmDragTgtRef.current) {
const t = { x: ts[0].locationX, y: ts[0].locationY };
const d = wmDragRef.current;
if (d && (Math.abs(t.x - d.px) > 6 || Math.abs(t.y - d.py) > 6)) {
d.moved = true;
}
if (d?.moved) moveWmDrag(t.x, t.y);
return;
}
// FRAME tab, magnified view: one finger drags the picture inside the
// viewport instead of the framed photo (preview only).
if (ts.length === 1 && !pinchRef.current && viewZoomed) {
const t = ts[0];
placeWm(t.pageX ?? t.locationX, t.pageY ?? t.locationY, wmDragTgtRef.current);
const pan0 = panRef.current;
if (pan0) {
// pageX/pageY: absolute screen dp, immune to the view transform, so the
// picture tracks the finger 1:1 (locationX is scaled by the matrix).
const dx = t.pageX - pan0.px;
const dy = t.pageY - pan0.py;
if (Math.abs(dx) > 6 || Math.abs(dy) > 6) panUsedRef.current = true;
if (panUsedRef.current) {
setViewZoom(clampViewZoom({ s: viewZoom.s, tx: pan0.tx + dx, ty: pan0.ty + dy }));
}
}
return;
}
// One finger, no pinch running: drag the photo inside the framed window
@@ -1704,14 +1885,17 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const wasPan = panUsedRef.current;
// Decided at touch-down; the reset below would lose it.
const wmTgt = wmDragTgtRef.current;
const wmMoved = !!wmDragRef.current?.moved;
pinchRef.current = null;
wmPinchRef.current = null;
wmDragTgtRef.current = null;
wmDragRef.current = null;
viewPinchRef.current = null;
pinchUsedRef.current = false;
panRef.current = null;
panUsedRef.current = false;
// A drag is neither a tap nor the first half of a double-tap.
if (wasPan) lastTapRef.current = null;
if (wasPan || wmMoved) lastTapRef.current = null;
if (wasPinch || wasPan) return; // a double-tap is always a single finger
const x = e.nativeEvent.locationX;
const y = e.nativeEvent.locationY;
@@ -1734,7 +1918,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
}
// Watermark armed: a tap places + commits the text and skips the double-tap
// zoom bookkeeping (double-tap zoom is disabled while placing).
if ((wmArmed || gpsWmArmed) && (libZoom.s <= 1.01 || wmTgt)) {
if ((wmArmed || gpsWmArmed) && ((libZoom.s <= 1.01 && viewZoom.s <= 1.01) || wmTgt)) {
const now = Date.now();
const prev = lastTapRef.current;
lastTapRef.current = { x, y, t: now };
@@ -1750,7 +1934,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onWmEdit?.();
return;
}
placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom');
// A drag has already moved the mark where the finger left it; only a
// plain tap drops it at the tapped point.
if (!wmMoved) placeWm(x, y, wmTgt ?? wmTargetAt(x, y) ?? 'custom');
commitWm();
return;
}
@@ -1816,17 +2002,36 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
: libZoomMatrix,
[libCropView, libZoom, libZoomMatrix]
);
// Same transform on the touch layer. RN scales about the view's centre, so
// the translation carries a centre correction ((k-1)·centre).
const libCropTouchStyle = libCropView
? {
transform: [
{ translateX: libCropView.ox + (vw / 2) * (libCropView.k - 1) },
{ translateY: libCropView.oy + (vh / 2) * (libCropView.k - 1) },
{ scale: libCropView.k },
],
}
: undefined;
// FRAME-tab view zoom as a Skia matrix, drawn as an OUTER group around a
// branch's whole canvas. Preview only: nothing export-facing reads it.
const viewZoomMatrix = useMemo(
() => [viewZoom.s, 0, viewZoom.tx, 0, viewZoom.s, viewZoom.ty, 0, 0, 1],
[viewZoom]
);
// Same transform on the touch layer, and that is the point of it: RN scales
// about the view's CENTRE by default, which dragged the layer's local space
// off the canvas — and every mark hit-test with it — as soon as the view was
// zoomed. Pinning the origin to the top left makes the layer map exactly like
// the Skia matrix the canvas draws with (P' = s·P + t), which is also what
// lets the gesture handlers use their coordinates as canvas coordinates.
const touchZoomStyle = (z: { s: number; tx: number; ty: number }) =>
z.s === 1 && z.tx === 0 && z.ty === 0
? undefined
: {
transformOrigin: 'top left',
transform: [{ translateX: z.tx }, { translateY: z.ty }, { scale: z.s }],
};
// The view zoom composes OVER the committed crop (outer·inner), exactly like
// the canvas draws it.
const libCropTouchStyle = touchZoomStyle(
libCropView
? {
s: viewZoom.s * libCropView.k,
tx: viewZoom.s * libCropView.ox + viewZoom.tx,
ty: viewZoom.s * libCropView.oy + viewZoom.ty,
}
: viewZoom
);
// Export-facing summary of the current framed-window photo reposition. The
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
@@ -2043,6 +2248,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
return (
<>
<Canvas style={StyleSheet.absoluteFill} pointerEvents="none">
<Group matrix={viewZoomMatrix}>
{/* Aspect-ratio band: black out the live feed outside the composition
region. Frames with their own geometry (polaroid/wall) draw their
own bars later, so this only fires for the plain frames. */}
@@ -2078,6 +2284,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
{/* Rule-of-thirds guide, clipped to the composition region (fullscreen
@@ -2126,6 +2333,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
return (
<View className="flex-1 relative overflow-hidden bg-black">
{/* FRAME-tab view zoom: the feed and its gesture layer magnify together
(preview only — a capture is untouched) so the watermark can be
dragged against a big picture. The overlays mirror the same matrix. */}
<View style={[StyleSheet.absoluteFill, touchZoomStyle(viewZoom)]}>
<SkiaCamera
key={'cam-' + facing + '-' + cameraMountNonce}
ref={skiaCameraRef}
@@ -2151,6 +2362,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderRelease={onImageDragEnd}
onResponderTerminate={onImageDragEnd}
/>
</View>
{focusPoint && (
/* The locked-AE/AF reticle IS the unlock control: the old
"AE/AF LOCKED — TAP TO UNLOCK" banner was removed from the preview
@@ -2270,6 +2482,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
{/* Photo is clipped to the window and repositioned by pinch/drag;
the card, seam, grain and watermarks stay put. */}
@@ -2325,9 +2538,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
/>
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
style={libCropTouchStyle}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
@@ -2350,6 +2565,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
return (
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
{/* Photo is clipped to the mat window and repositioned by pinch/
drag; the artwork, grain and watermarks stay put. */}
@@ -2401,9 +2617,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
</Group>
{renderGPSWatermark()}
{renderCustomWatermark()}
</Group>
</Canvas>
<View
className="absolute inset-0"
style={libCropTouchStyle}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
@@ -2419,6 +2637,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={viewZoomMatrix}>
<Group matrix={libViewMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
@@ -2548,6 +2767,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
)}
</Group>
)}
</Group>
</Canvas>
<View
className="absolute inset-0"
+12
View File
@@ -11,6 +11,9 @@ import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallf
import { patchJpegDpi } from './jpegDpi';
import { writeJpegExif } from './exifWrite';
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
import RecipescamExport from '../../modules/recipescam-export';
const stripScheme = (uri: string) => uri.replace(/^file:\/\//, '');
import { applyPhotoRotation } from './skiaImage';
export interface ExportResult {
@@ -746,6 +749,15 @@ export async function processAndExportPhoto(
// viewer; the OS cache purge reclaims them eventually.
const mediaPermission = await MediaLibrary.requestPermissionsAsync();
if (mediaPermission.granted) {
// DCIM/Camera (stock camera location) first: expo-media-library can only
// reach DCIM (root) or Pictures/Camera, so the native module does the
// MediaStore insert. Falls back to the plain expo save if that fails.
try {
const assetId = await RecipescamExport.saveToCameraRollAsync(stripScheme(exportFile), 'image/jpeg');
if (assetId) return { uri: exportFile, savedToLibrary: true, assetId };
} catch (e) {
console.warn('Native DCIM/Camera save failed, falling back to MediaLibrary:', e);
}
try {
const asset = await MediaLibrary.createAssetAsync(exportFile);
return { uri: exportFile, savedToLibrary: true, assetId: asset.id };