diff --git a/App.tsx b/App.tsx index 8cbac35..87ea12c 100644 --- a/App.tsx +++ b/App.tsx @@ -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) diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index ef1755b..95a2c20 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -564,7 +564,7 @@ const Viewfinder = forwardRef(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(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(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(function Viewfi // OPEN but frame delivery stops, so the heartbeat freezes. const frameBeatSync = useMemo(() => createSynchronizable(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([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([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(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(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(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(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)