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.
This commit is contained in:
2026-09-10 08:02:55 +07:00
parent c0c2f8532f
commit ff06045e02
3 changed files with 150 additions and 38 deletions
+3
View File
@@ -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,
}
);
+105 -36
View File
@@ -101,6 +101,15 @@ export interface ViewfinderHandle {
* hardware bias change re-locks AE on some devices and flashes the preview).
*/
flushExposureBias(): Promise<void>;
/**
* 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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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 = (
<View
className="absolute inset-0"
onStartShouldSetResponder={() => 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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(function Viewfi
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={card.x} y={card.y} width={card.w} height={card.h} color="#faf9f6" />
<Group>
{/* Photo is clipped to the window and repositioned by pinch/drag;
the card, seam, grain and watermarks stay put. */}
<Group clip={winRect}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
@@ -1515,6 +1565,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<SkiaImage image={skiaImage} fit={polaroidFit} rect={winRect} />
)}
</Group>
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
@@ -1535,7 +1586,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{renderGPSWatermark()}
{renderCustomWatermark()}
</Canvas>
{wmArmed && wmDragOverlay}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
</View>
);
}
@@ -1553,7 +1611,10 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<View className="flex-1 relative overflow-hidden bg-black items-center justify-center">
<Canvas style={StyleSheet.absoluteFill}>
<Rect x={0} y={0} width={vw} height={vh} color="#000000" />
<Group>
{/* Photo is clipped to the mat window and repositioned by pinch/
drag; the artwork, grain and watermarks stay put. */}
<Group clip={winRect}>
<Group matrix={libZoomMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
<Rect x={win.x} y={win.y} width={win.w} height={win.h}>
@@ -1583,6 +1644,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
<SkiaImage image={skiaImage} fit="cover" rect={winRect} />
)}
</Group>
</Group>
{grainOpacity > 0 && noiseEffect && (
<Group blendMode="overlay">
<Rect x={win.x} y={win.y} width={win.w} height={win.h} opacity={grainOpacity}>
@@ -1599,7 +1661,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{renderGPSWatermark()}
{renderCustomWatermark()}
</Canvas>
{wmArmed && wmDragOverlay}
<View
className="absolute inset-0"
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
</View>
);
}
+42 -2
View File
@@ -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
);