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:
+105
-36
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user