From ff06045e021e6c043a704030dc1ebdc66f93a353 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Thu, 10 Sep 2026 08:02:55 +0700 Subject: [PATCH] Pinch/drag library photo inside polaroid & wall frame windows with export parity The framed windows (polaroid card / wall frame opening) now take the same pinch + double-tap gestures as plain library, clamped so the photo keeps covering the mat opening, and the visible sub-rectangle is handed to the export engine so the file matches the preview. Wall frame export also stopped crashing: on Android release an image asset resolves to a drawable *name* ('wallframe') and is flagged downloaded, so Asset.downloadAsync short-circuited and readAsStringAsync threw 'Unsupported scheme for location wallframe'. Clearing that state lets the native module copy the drawable into the cache. --- App.tsx | 3 + src/components/Viewfinder.tsx | 141 +++++++++++++++++++++++++--------- src/utils/exportEngine.ts | 44 ++++++++++- 3 files changed, 150 insertions(+), 38 deletions(-) diff --git a/App.tsx b/App.tsx index 421f062..b4668e7 100644 --- a/App.tsx +++ b/App.tsx @@ -748,6 +748,9 @@ export default function App() { { aspect: aspectRatio === 'full' ? undefined : aspectRatio, watermark: wmForExport(), + // Pinch/drag inside a polaroid card window or wall-frame opening: + // save the same crop the preview showed (null = centered 1x). + frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, } ); diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 368b015..0286802 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -101,6 +101,15 @@ export interface ViewfinderHandle { * hardware bias change re-locks AE on some devices and flashes the preview). */ flushExposureBias(): Promise; + /** + * Current interactive photo reposition inside a framed library window + * (RETRO POLAROID / WALL FRAME): scale s (>=1) and the visible window-center + * as 0..1 fractions of the photo's cover plane (the area that fills the + * window at 1x). Null when no framed window is showing or the photo is + * unzoomed. App passes it to the export engine so the saved file shows + * exactly the crop the user framed after pinch/drag. + */ + getFrameWindowZoom(): { s: number; u: number; v: number } | null; } const noiseEffect = Skia.RuntimeEffect.Make(` @@ -460,6 +469,11 @@ const Viewfinder = forwardRef(function Viewfi } } }, + // Library 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; + }, }), [ref] ); @@ -864,6 +878,29 @@ const Viewfinder = forwardRef(function Viewfi if (selectedFrame !== 'wallframe') return null; return wallframeLayout(vw, vh, 'contain'); }, [selectedFrame, vw, vh]); + // 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. + const zoomWindowRect = (): { x: number; y: number; w: number; h: number } | null => { + if (mode !== 'library') return null; + if (polaroidGeo) return polaroidGeo.window; + if (selectedFrame === 'wallframe' && wallGeo && wallImage) return wallGeo.window; + return null; + }; + // Clamp pan/zoom so the photo always keeps COVERING the framed window (never + // drags far enough to reveal the card/mat through the transparent opening). + // Transform is P' = s*P + t in canvas coords; with the photo drawn over the + // window rect, covering means t in [(x+w)(1-s), x(1-s)] (y likewise). + const clampZoomToWindow = (s: number, tx: number, ty: number) => { + const win = zoomWindowRect(); + if (!win) return { s, tx, ty }; // plain library/camera: unchanged + if (s <= 1.0001) return { s, tx: 0, ty: 0 }; + return { + s, + tx: Math.min(win.x * (1 - s), Math.max((win.x + win.w) * (1 - s), tx)), + ty: Math.min(win.y * (1 - s), Math.max((win.y + win.h) * (1 - s), ty)), + }; + }; // Live feed band rect for the CAMERA preview: the photo-area that must show // the export-equivalent sensor crop — the ratio band region (plain frames), // the polaroid photo window or the wall-frame window. When present the frame @@ -980,27 +1017,11 @@ const Viewfinder = forwardRef(function Viewfi if (wmDragPos) onWmPositionChange(wmDragPos.x, wmDragPos.y); setWmDragPos(null); }; - // Library polaroid/wall returns have no pinch responder of their own — this - // fullscreen drag layer carries the watermark gesture there (drag = move, - // tap = place, lift = commit). Plain-library and camera modes reuse their - // existing gesture layers via the wm branches above. - const wmDragOverlay = ( - true} - onResponderGrant={(e: any) => { - const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; - placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); - }} - onResponderMove={(e: any) => { - const t = e?.nativeEvent?.touches?.[0] ?? e?.nativeEvent; - placeWm(t?.pageX ?? t?.locationX ?? 0, t?.pageY ?? t?.locationY ?? 0); - }} - onResponderRelease={() => commitWm()} - onResponderTerminate={() => commitWm()} - /> - ); - + // 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, pinch + double-tap repositions the photo + // inside the frame window (watermark stays armed when zoomed so detail can + // be inspected without accidentally moving text — matching plain library). const onLibTouchStart = (e: any) => { const ts = e.nativeEvent.touches; if (ts.length >= 2) { @@ -1047,11 +1068,12 @@ const Viewfinder = forwardRef(function Viewfi const d = Math.hypot(a.locationX - b.locationX, a.locationY - b.locationY); const s = clampZoom((p.s * d) / Math.max(p.d, 1)); const k = s / p.s; - setLibZoom({ + const next = clampZoomToWindow( s, - tx: p.fx - k * (p.fx - p.tx), - ty: p.fy - k * (p.fy - p.ty), - }); + p.fx - k * (p.fx - p.tx), + p.fy - k * (p.fy - p.ty) + ); + setLibZoom(next); } }; const onLibTouchEnd = (e: any) => { @@ -1075,20 +1097,26 @@ const Viewfinder = forwardRef(function Viewfi lastTapRef.current = null; setLibZoom((z) => { if (z.s > 1.01) return { s: 1, tx: 0, ty: 0 }; // double-tap again -> 100% - // Clamp the focal point into the photo's display rect (composition - // region under a ratio band, contain rect otherwise) so a double-tap - // that lands in the black letterbox cannot fling the photo away. + // Clamp the focal point into the photo's display rect — the framed + // window when one is showing (polaroid/wall), the composition region + // under a ratio band, or the contain rect — so a double-tap that lands + // in the black letterbox cannot fling the photo away. let fx = x; let fy = y; - const fit = regionBand - ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } - : imageFitRect; + const win = zoomWindowRect(); + const fit = win + ? { dx: win.x, dy: win.y, dw: win.w, dh: win.h } + : regionBand + ? { dx: region.x, dy: region.y, dw: region.w, dh: region.h } + : imageFitRect; if (fit) { fx = Math.min(Math.max(fx, fit.dx), fit.dx + fit.dw); fy = Math.min(Math.max(fy, fit.dy), fit.dy + fit.dh); } const s = 2.5; - return { s, tx: fx - s * fx, ty: fy - s * fy }; // zoom into the tapped point + // zoom into the tapped point, then clamp so the photo keeps covering + // the framed window (plain library keeps the raw focal transform). + return clampZoomToWindow(s, fx - s * fx, fy - s * fy); }); } }; @@ -1105,6 +1133,25 @@ const Viewfinder = forwardRef(function Viewfi [libZoom] ); + // Export-facing summary of the current framed-window photo reposition. The + // gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction + // of the photo's cover plane sitting at the WINDOW CENTER — layout units the + // export engine can apply at its own pixel size (the Skia render draws the + // source sub-rectangle that stays visible through the frame's opening). The + // handle method reads this ref because useImperativeHandle captures once. + const frameZoomRef = useRef<{ s: number; u: number; v: number } | null>(null); + frameZoomRef.current = (() => { + const win = zoomWindowRect(); + if (!win || libZoom.s <= 1.001) return null; + const tlx = libZoom.tx + (libZoom.s - 1) * win.x; + const tly = libZoom.ty + (libZoom.s - 1) * win.y; + return { + s: libZoom.s, + u: (win.w / 2 - tlx) / (libZoom.s * win.w), + v: (win.h / 2 - tly) / (libZoom.s * win.h), + }; + })(); + // Border/bars scale with the composition region so framed exports stay // proportional to the cropped photo (region == fullscreen when no ratio). const borderWidth = Math.min(region.w, region.h) * 0.05; @@ -1485,7 +1532,10 @@ const Viewfinder = forwardRef(function Viewfi - + {/* Photo is clipped to the window and repositioned by pinch/drag; + the card, seam, grain and watermarks stay put. */} + + {useShaderPass ? ( @@ -1515,6 +1565,7 @@ const Viewfinder = forwardRef(function Viewfi )} + {grainOpacity > 0 && noiseEffect && ( @@ -1535,7 +1586,14 @@ const Viewfinder = forwardRef(function Viewfi {renderGPSWatermark()} {renderCustomWatermark()} - {wmArmed && wmDragOverlay} + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onLibTouchStart} + onResponderMove={onLibTouchMove} + onResponderRelease={onLibTouchEnd} + /> ); } @@ -1553,7 +1611,10 @@ const Viewfinder = forwardRef(function Viewfi - + {/* Photo is clipped to the mat window and repositioned by pinch/ + drag; the artwork, grain and watermarks stay put. */} + + {useShaderPass ? ( @@ -1583,6 +1644,7 @@ const Viewfinder = forwardRef(function Viewfi )} + {grainOpacity > 0 && noiseEffect && ( @@ -1599,7 +1661,14 @@ const Viewfinder = forwardRef(function Viewfi {renderGPSWatermark()} {renderCustomWatermark()} - {wmArmed && wmDragOverlay} + true} + onMoveShouldSetResponder={() => true} + onResponderGrant={onLibTouchStart} + onResponderMove={onLibTouchMove} + onResponderRelease={onLibTouchEnd} + /> ); } diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index de238ac..e5072f5 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -38,6 +38,13 @@ export interface ExportOptions { // (BASELINE anchor + 0.35em drop — same formula as the viewfinder overlay, // so the export lands where the preview showed it). Amber mono, no icons. watermark?: { text: string; x: number; y: number } | null; + // Library framed-window photo reposition (Viewfinder.getFrameWindowZoom): + // after pinch/drag inside a polaroid card window or a wall-frame opening, + // s = scale and u/v = the fraction of the photo's cover plane sitting at the + // WINDOW CENTER. The export draws the source sub-rectangle that stays + // visible through the opening, so the file matches the preview. Null / + // undefined = centered 1x (the historical full-image draw). + frameWindowZoom?: { s: number; u: number; v: number } | null; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 @@ -332,10 +339,22 @@ export async function processAndExportPhoto( const pl = polaroidLayout(outW, outH, width / height, 'contain'); const win = pl.window; // Window aspect == photo aspect → fills exactly, no letterbox inside. + // With a framed-window zoom the source rect shrinks to the visible part + // (window keeps showing the same geometry as the viewfinder). const plainPaint = Skia.Paint(); + const pz = options?.frameWindowZoom ?? null; + const pwz = pz && pz.s > 1.001 ? pz : null; + const pVisW = pwz ? width / pwz.s : width; + const pVisH = pwz ? height / pwz.s : height; + const pSx = pwz + ? Math.min(Math.max(width * pwz.u - pVisW / 2, 0), width - pVisW) + : 0; + const pSy = pwz + ? Math.min(Math.max(height * pwz.v - pVisH / 2, 0), height - pVisH) + : 0; cardCanvas.drawImageRect( photoImg, - Skia.XYWHRect(0, 0, width, height), + Skia.XYWHRect(pSx, pSy, pVisW, pVisH), Skia.XYWHRect(win.x, win.y, win.w, win.h), plainPaint ); @@ -367,6 +386,13 @@ export async function processAndExportPhoto( // Decode the frame PNG artwork (bundled asset → base64, same path as the // GPS fonts below — embedded assets may not expose a readable file URI). const wfAsset = Asset.fromModule(require('../../wallframe.png')); + // Android release builds resolve an image asset to a drawable *name* + // ('wallframe', no scheme) and mark it downloaded, so downloadAsync() + // short-circuits and readAsStringAsync throws 'Unsupported scheme for + // location wallframe'. Clear that state so the native module copies the + // drawable out of the APK into the cache as a real file path. + wfAsset.localUri = null; + wfAsset.downloaded = false; await wfAsset.downloadAsync(); const wfDataStr = wfAsset.localUri ? await FileSystem.readAsStringAsync(wfAsset.localUri, { encoding: FileSystem.EncodingType.Base64 }) @@ -388,9 +414,23 @@ export async function processAndExportPhoto( const scale = Math.max(win.w / pw, win.h / ph); const srcW = win.w / scale; const srcH = win.h / scale; + // Cover plane (center crop) that fills the window; a framed-window zoom + // shrinks the visible source sub-rect inside that plane. + const planeX = (pw - srcW) / 2; + const planeY = (ph - srcH) / 2; + const wz = options?.frameWindowZoom ?? null; + const wZoom = wz && wz.s > 1.001 ? wz : null; + const wVisW = wZoom ? srcW / wZoom.s : srcW; + const wVisH = wZoom ? srcH / wZoom.s : srcH; + const wSx = wZoom + ? Math.min(Math.max(planeX + srcW * wZoom.u - wVisW / 2, planeX), planeX + srcW - wVisW) + : planeX; + const wSy = wZoom + ? Math.min(Math.max(planeY + srcH * wZoom.v - wVisH / 2, planeY), planeY + srcH - wVisH) + : planeY; cardCanvas.drawImageRect( photoImg, - Skia.XYWHRect((pw - srcW) / 2, (ph - srcH) / 2, srcW, srcH), + Skia.XYWHRect(wSx, wSy, wVisW, wVisH), Skia.XYWHRect(win.x, win.y, win.w, win.h), plainPaint );