feat(frame): pinch and drag the framed window on the camera
Two fingers scale the live feed inside a polaroid card / wall opening and one finger drags it, both clamped to the window, and the shutter passes the result to the export as frameWindowZoom so the printed crop matches what the viewfinder showed. The crop centre is mapped through the same out-space -> raw transform as the window rect; feeding it straight to the raw plane swapped the axes on a rotated sensor.
This commit is contained in:
@@ -942,6 +942,9 @@ export default function App() {
|
||||
// P5: the native path (NATIVE_EXPORT) renders through RecipesCamExport —
|
||||
// same queue/pushPhoto/Haptics/Alert behavior, different renderer.
|
||||
const recipeName = activeRecipe.name;
|
||||
// Read at shutter time, not render time — the queued render runs after
|
||||
// the next shot and the gesture may have moved on.
|
||||
const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null;
|
||||
const render = () => {
|
||||
const recipeArgs = {
|
||||
...activeRecipe,
|
||||
@@ -961,6 +964,9 @@ export default function App() {
|
||||
watermarkRotation: wmRotation,
|
||||
gpsWatermark: gpsWm,
|
||||
wallframeLandscape: wallLandscape,
|
||||
// FRAME-tab pinch/drag inside a polaroid card window or wall-frame
|
||||
// opening: save the same crop the preview showed (null = 1x).
|
||||
frameWindowZoom: frameZoom,
|
||||
};
|
||||
return NATIVE_EXPORT
|
||||
? processAndExportPhotoNative(sourceUri, recipeArgs, selectedFrame, useGeotag, captureGps, options)
|
||||
|
||||
+111
-17
@@ -564,7 +564,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
}
|
||||
}
|
||||
},
|
||||
// Library frame window (polaroid/wall) zoom, for export parity. Read
|
||||
// Frame-window (polaroid/wall) zoom, for export parity. Read
|
||||
// live through the ref — the handle is captured once with deps [ref].
|
||||
getFrameWindowZoom() {
|
||||
return frameZoomRef.current;
|
||||
@@ -692,10 +692,60 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
kind = 'ev';
|
||||
v0 = evStops;
|
||||
}
|
||||
// Framed-window pinch/pan anchors (camera): seeded at touch-down like the
|
||||
// library's onLibTouchStart, consumed in onImageDragMove below.
|
||||
const gts = e?.nativeEvent?.touches ?? [];
|
||||
panRef.current =
|
||||
gts.length === 1
|
||||
? { x: gts[0].locationX, y: gts[0].locationY, tx: libZoom.tx, ty: libZoom.ty }
|
||||
: null;
|
||||
panUsedRef.current = false;
|
||||
if (gts.length < 2) pinchRef.current = null;
|
||||
imgDragRef.current = { y0: pageYOf(e), kind, v0, param, moved: false, lastSent: null };
|
||||
};
|
||||
const onImageDragMove = (e: any) => {
|
||||
const g = imgDragRef.current;
|
||||
// Framed window (polaroid card / wall opening) on the live camera: two
|
||||
// fingers pinch the picture and drag it inside the frame — exactly the crop
|
||||
// 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;
|
||||
if (g && g.kind !== 'wm' && !wmArmed && zoomWindowRect()) {
|
||||
if (wts && wts.length >= 2) {
|
||||
const [a, b] = wts;
|
||||
const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY);
|
||||
let p = pinchRef.current;
|
||||
if (!p) {
|
||||
p = pinchRef.current = {
|
||||
d,
|
||||
s: libZoom.s,
|
||||
tx: libZoom.tx,
|
||||
ty: libZoom.ty,
|
||||
fx: (a.locationX + b.locationX) / 2,
|
||||
fy: (a.locationY + b.locationY) / 2,
|
||||
};
|
||||
}
|
||||
const ns = clampZoom((p.s * d) / Math.max(p.d, 1));
|
||||
const k = ns / p.s;
|
||||
setLibZoom(clampZoomToWindow(ns, p.fx - k * (p.fx - p.tx), p.fy - k * (p.fy - p.ty)));
|
||||
g.moved = true;
|
||||
return;
|
||||
}
|
||||
if (wts && wts.length === 1 && g.kind === 'focus' && !pinchRef.current) {
|
||||
const t = wts[0];
|
||||
const pan0 = panRef.current;
|
||||
if (pan0) {
|
||||
const dx = t.locationX - pan0.x;
|
||||
const dyP = t.locationY - pan0.y;
|
||||
if (Math.abs(dx) > TAP_SLOP || Math.abs(dyP) > TAP_SLOP) panUsedRef.current = true;
|
||||
if (panUsedRef.current) {
|
||||
setLibZoom(clampZoomToWindow(libZoom.s, pan0.tx + dx, pan0.ty + dyP));
|
||||
g.moved = true;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!g || g.kind === 'focus') return;
|
||||
if (g.kind === 'wm') {
|
||||
// Watermark drag: follow the finger; the commit happens on release below.
|
||||
@@ -755,6 +805,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
imgDragRef.current = null;
|
||||
wmPinchRef.current = null;
|
||||
wmDragTgtRef.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.
|
||||
@@ -782,13 +835,18 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
// OPEN but frame delivery stops, so the heartbeat freezes.
|
||||
const frameBeatSync = useMemo(() => createSynchronizable<number>(0), []);
|
||||
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
|
||||
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
|
||||
// window / wall window) normalized to the view. While active the worklet
|
||||
// redraws the feed cover-mapped INTO that rect instead of the default
|
||||
// fullscreen cover-fit — with a 4:3 ratio (== native sensor) the whole
|
||||
// sensor shows, narrower ratios crop exactly like the export. [0]=0 keeps
|
||||
// the legacy fullscreen draw untouched.
|
||||
const feedRectSync = useMemo(() => createSynchronizable<number[]>([0, 0, 0, 0, 0, 0]), []);
|
||||
// fw, fh, z, u, v] where (fx,fy,fw,fh) is the photo-area rect (ratio band /
|
||||
// polaroid window / wall window) normalized to the view, and (z,u,v) is the
|
||||
// framed-window pinch/drag (cover-plane magnification + the plane fraction at
|
||||
// the window center, [6]<=1 meaning none). While active the worklet redraws
|
||||
// the feed cover-mapped INTO that rect instead of the default fullscreen
|
||||
// cover-fit — with a 4:3 ratio (== native sensor) the whole sensor shows,
|
||||
// narrower ratios crop exactly like the export. [0]=0 keeps the legacy
|
||||
// fullscreen draw untouched.
|
||||
const feedRectSync = useMemo(
|
||||
() => createSynchronizable<number[]>([0, 0, 0, 0, 0, 0, 0, 0.5, 0.5]),
|
||||
[]
|
||||
);
|
||||
// RETRO POLAROID on the live camera is NOT drawn in the frame pipeline: that
|
||||
// canvas is an offscreen surface in frame-pixel space which RNSkia later
|
||||
// cover-fits onto the view, so frame-space geometry overflows the screen and
|
||||
@@ -984,14 +1042,34 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
const dh = s * ftH;
|
||||
const dx = u0 + (rw - dw) / 2;
|
||||
const dy = v0 + (rh - dh) / 2;
|
||||
// Framed-window pinch/drag (feed[6..8] = z + the crop centre as a
|
||||
// fraction of the window): show that slice of the cover plane — the
|
||||
// same crop the export takes (frameWindowZoom). The centre is in VIEW
|
||||
// axes, so it goes through the same out-space -> raw mapping as the
|
||||
// window rect (toRaw); handing it straight to the raw plane swapped
|
||||
// the axes whenever the sensor is rotated. With z at 1 the centre is
|
||||
// forced to the middle and this collapses to the plain draw above.
|
||||
const z = feed[6] > 1.001 ? feed[6] : 1;
|
||||
const half = 0.5 / z;
|
||||
const cu = Math.min(1 - half, Math.max(half, feed[7]));
|
||||
const cv = Math.min(1 - half, Math.max(half, feed[8]));
|
||||
const cX = X0 + cu * (X1 - X0);
|
||||
const cY = Y0 + cv * (Y1 - Y0);
|
||||
const hX = (X1 - X0) / (2 * z);
|
||||
const hY = (Y1 - Y0) / (2 * z);
|
||||
const b0 = toRaw(cX - hX, cY - hY);
|
||||
const b1 = toRaw(cX + hX, cY + hY);
|
||||
const zwX = (Math.min(b0[0], b1[0]) - dx) / s;
|
||||
const zwY = (Math.min(b0[1], b1[1]) - dy) / s;
|
||||
const k = s * z;
|
||||
// Fresh paint; its default color is opaque black (Skia default), so
|
||||
// the surface fill below needs no explicit color set.
|
||||
const blackPaint = Skia.Paint();
|
||||
canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint);
|
||||
canvas.save();
|
||||
canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false);
|
||||
canvas.translate(dx, dy);
|
||||
canvas.scale(s, s);
|
||||
canvas.translate(u0 - k * zwX, v0 - k * zwY);
|
||||
canvas.scale(k, k);
|
||||
drawFullFrame();
|
||||
canvas.restore();
|
||||
});
|
||||
@@ -1132,11 +1210,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
if (selectedFrame !== 'wallframe') return null;
|
||||
return wallframeLayout(vw, vh, 'contain', wallLandscape);
|
||||
}, [selectedFrame, vw, vh, wallLandscape]);
|
||||
// Framed photo window (library only) whose content the user may pinch/drag:
|
||||
// polaroid card window or wall-frame opening. Null for plain frames (their
|
||||
// whole composition region is the photo) and for the live camera.
|
||||
// Framed photo window whose content the user may pinch/drag: polaroid card
|
||||
// window or wall-frame opening. Null for plain frames (their whole
|
||||
// composition region is the photo) and for the camera's ratio bands (the
|
||||
// export never crops those through a window).
|
||||
const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => {
|
||||
if (mode !== 'library') return null;
|
||||
// Fixed-ratio crop: the band is the window the picture is positioned in.
|
||||
if (libCropBand)
|
||||
return { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh };
|
||||
@@ -1169,6 +1247,11 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
};
|
||||
});
|
||||
});
|
||||
// Camera: picking another frame — or leaving the camera — starts from the
|
||||
// whole picture inside its window (the library has its own reset rules).
|
||||
useEffect(() => {
|
||||
if (mode === 'camera') setLibZoom({ s: 1, tx: 0, ty: 0 });
|
||||
}, [mode, selectedFrame]);
|
||||
|
||||
// Clamp pan/zoom so the photo always keeps COVERING the window: the framed
|
||||
// opening (photo cover-drawn into it) or the ratio-crop band (photo
|
||||
@@ -1208,12 +1291,23 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
: null
|
||||
: null;
|
||||
useEffect(() => {
|
||||
const fz = frameZoomRef.current;
|
||||
feedRectSync.setBlocking(
|
||||
cameraPhotoRect
|
||||
? [1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]
|
||||
: [0, 0, 0, 0, 0, 0]
|
||||
? [
|
||||
1,
|
||||
vw / vh,
|
||||
cameraPhotoRect.x / vw,
|
||||
cameraPhotoRect.y / vh,
|
||||
cameraPhotoRect.w / vw,
|
||||
cameraPhotoRect.h / vh,
|
||||
fz?.s ?? 1,
|
||||
fz?.u ?? 0.5,
|
||||
fz?.v ?? 0.5,
|
||||
]
|
||||
: [0, 0, 0, 0, 0, 0, 0, 0.5, 0.5]
|
||||
);
|
||||
}, [cameraPhotoRect, vw, vh, feedRectSync]);
|
||||
}, [cameraPhotoRect, vw, vh, feedRectSync, libZoom]);
|
||||
|
||||
// Camera watchdog: the Xiaomi wedges the preview — horizontal banding then
|
||||
// black, while the native session still logs OPEN (no error, no onStopped)
|
||||
|
||||
Reference in New Issue
Block a user