Fix camera polaroid overflow and chrome WB chips; move GPS watermark to top

This commit is contained in:
2026-09-08 17:06:22 +07:00
parent bfdc964f65
commit b5a257e033
2 changed files with 125 additions and 107 deletions
+70 -79
View File
@@ -1,5 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState, type ComponentProps } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, useWindowDimensions, AppState } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useCameraDevice, type CameraPhotoOutput } from 'react-native-vision-camera';
import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia';
import { createSynchronizable } from 'react-native-worklets';
@@ -93,6 +94,7 @@ export default function Viewfinder({
// Fullscreen hero: the viewfinder fills the entire screen, every overlay
// rect derives from window size.
const { width: vw, height: vh } = useWindowDimensions();
const insets = useSafeAreaInsets();
// useCameraDevice resolves the back camera fresh on every render, so once the
// permission lands (first-run flow: GRANT button → OS dialog) a re-render is
@@ -315,11 +317,12 @@ export default function Viewfinder({
// the preview flicker. All live values flow through the synchronizables.
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
// RETRO POLAROID on the live camera: the card + photo window are drawn here,
// in the frame pipeline, because only the worklet can scale the camera feed
// into the polaroid window (a JS overlay could only mask the fullscreen feed,
// which crops the scene instead of fitting it into the frame).
const polaroidCam = mode === 'camera' && selectedFrame === 'polaroid';
// 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
// cannot be aligned with the dp-space overlays. The camera just renders the
// plain fullscreen feed here; the polaroid card is composited by the JS
// overlay (renderFrameOverlay) in view coordinates, like every other frame.
const handleFrame = useCallback<SkiaCameraOnFrame>(
(frame, render) => {
'worklet';
@@ -361,57 +364,11 @@ export default function Viewfinder({
}
canvas.drawImage(frameTexture, 0, 0, paint);
};
if (polaroidCam) {
const W = frameTexture.width();
const H = frameTexture.height();
// Geometry mirror of utils/frameUtils.polaroidLayout('contain'),
// inlined because worklet code cannot call across module imports.
// Physical polaroid proportions (88x107 mm card, 79x79 mm print).
const SIDE = (88 - 79) / 2 / 88; // 0.05114 per side of card width
const DECK = (107 - 79 - (88 - 79) / 2) / 88; // 0.26705 bottom deck
const WINW = 1 - 2 * SIDE; // 0.89773 window width
const naturalAspect = SIDE + DECK + WINW / (W / H); // cardH / cardW
const cardW = Math.min(W, H / naturalAspect);
const cardH = cardW * naturalAspect;
const cx = (W - cardW) / 2;
const cy = (H - cardH) / 2;
const top = SIDE * cardW;
const maxWinH = cardH - top - DECK * cardW;
let winW = WINW * cardW;
let winH = winW / (W / H);
if (winH > maxWinH) {
winH = maxWinH;
winW = winH * (W / H);
}
const wx = cx + (cardW - winW) / 2;
const wy = cy + top;
// Letterbox bars (black) around the print, then the white card.
const bgPaint = Skia.Paint();
bgPaint.setColor(Skia.Color('#000000'));
canvas.drawRect(Skia.XYWHRect(0, 0, W, H), bgPaint);
const cardPaint = Skia.Paint();
cardPaint.setColor(Skia.Color('#faf9f6'));
canvas.drawRect(Skia.XYWHRect(cx, cy, cardW, cardH), cardPaint);
// Scale the whole live frame into the window: draw the pass over the
// full frame rect while the canvas is scaled, so the complete camera
// scene is squeezed into the polaroid window — never cropped.
canvas.save();
canvas.translate(wx, wy);
canvas.scale(winW / W, winH / H);
drawPass();
canvas.restore();
const seamPaint = Skia.Paint();
seamPaint.setColor(Skia.Color('#e5e5e5'));
seamPaint.setStyle(1);
seamPaint.setStrokeWidth(1);
canvas.drawRect(Skia.XYWHRect(wx + 0.5, wy + 0.5, winW - 1, winH - 1), seamPaint);
return;
}
drawPass();
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, polaroidCam]
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged]
);
const grainOpacity = adjustments.grain / 20;
@@ -437,9 +394,9 @@ export default function Viewfinder({
const dh = h * s;
return { dx: (vw - dw) / 2, dy: (vh - dh) / 2, dw, dh };
}, [skiaImage, vw, vh]);
// Polaroid card geometry (JS/overlay space). The live camera worklet mirrors
// the same math in frame-pixel space (see handleFrame) — both spaces share the
// same aspect ratio, so the overlay rects line up with the drawn card.
// Polaroid card geometry (JS/overlay space) for the CURRENT mode. Library
// stills keep the photo aspect; camera previews use the screen aspect (the
// feed fills the screen, so the card overlays it in view coordinates).
const polaroidGeo = useMemo(() => {
if (selectedFrame !== 'polaroid') return null;
const aspect =
@@ -540,10 +497,47 @@ export default function Viewfinder({
const renderFrameOverlay = () => {
if (selectedFrame === 'none') return null;
// Polaroid is drawn by the camera frame pipeline (camera) or its own
// contain-fit canvas (library) — the overlay only handles the frames that
// sit on top of an already fullscreen picture.
if (selectedFrame === 'polaroid') return null;
// Library polaroid is drawn by its own contain-fit canvas (see the early
// return further down); the overlay here handles the frames that sit on top
// of an already fullscreen picture.
if (selectedFrame === 'polaroid') {
const geo = polaroidGeo;
if (!geo) return null;
const { card, window: win } = geo;
// Camera polaroid, composited in view (dp) coordinates: letterbox the
// live feed black outside the card, paint the white card around the photo
// window and a thin seam at the window edge. The window itself stays
// transparent so the fullscreen feed shows through underneath — drawn
// this way the card can never overflow the screen, and it lines up
// exactly with the grain clip / GPS anchor (same polaroidGeo rects).
const black = '#000000';
const white = '#faf9f6';
const seam = '#e5e5e5';
return (
<Group>
{/* black letterbox bars around the card */}
<Rect x={0} y={0} width={vw} height={card.y} color={black} />
<Rect x={0} y={card.y + card.h} width={vw} height={vh - card.y - card.h} color={black} />
<Rect x={0} y={card.y} width={card.x} height={card.h} color={black} />
<Rect x={card.x + card.w} y={card.y} width={vw - card.x - card.w} height={card.h} color={black} />
{/* white card area around the photo window */}
<Rect x={card.x} y={card.y} width={card.w} height={win.y - card.y} color={white} />
<Rect x={card.x} y={win.y + win.h} width={card.w} height={card.y + card.h - win.y - win.h} color={white} />
<Rect x={card.x} y={win.y} width={win.x - card.x} height={win.h} color={white} />
<Rect x={win.x + win.w} y={win.y} width={card.x + card.w - win.x - win.w} height={win.h} color={white} />
{/* seam between photo window and card */}
<Rect
x={win.x + 0.5}
y={win.y + 0.5}
width={win.w - 1}
height={win.h - 1}
color={seam}
style="stroke"
strokeWidth={1}
/>
</Group>
);
}
if (selectedFrame === 'classic-white') {
return (
@@ -576,13 +570,14 @@ export default function Viewfinder({
const pad = (n: number) => String(n).padStart(2, '0');
const timestampStr = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
// Anchor to the PHOTO, not the screen. In camera mode the photo fills the
// screen (area = full canvas). In library mode the photo is contain-fitted
// with black letterbox — a screen-bottom baseline used to land below the
// photo on the letterbox, off the image, so users could not see it. Compute
// inside the fitted rect (imageFitRect) so the mark sits at the photo's
// bottom-left corner; keep it above the bottom chrome/shutter rail. With a
// polaroid frame the photo lives inside the card window — anchor to that.
// Anchor to the PHOTO's top-left, not the screen. Bottom anchoring put the
// mark under the floating chip rows (open WB/FRAME panel covered it), so it
// now lives at the top of the photo/window: the bottom sheet can never reach
// it. In camera mode the photo fills the screen; in library mode it is
// contain-fitted with letterbox — use imageFitRect so the mark sits on the
// photo corner. With a polaroid frame the photo lives inside the card
// window — anchor to that. Always clear the floating TopBar (recipe name +
// SAVE), which ends around insets.top + 56 dp.
const win = polaroidGeo?.window ?? null;
const area = win
? { dx: win.x, dy: win.y, dw: win.w, dh: win.h }
@@ -590,17 +585,13 @@ export default function Viewfinder({
? imageFitRect
: { dx: 0, dy: 0, dw: vw, dh: vh };
const iconSize = Math.round(vw * 0.032); // matches customFont/iconFont size
const padBottom = win
? Math.round(iconSize * 1.4) // polaroid: just above the window's bottom edge
: mode === 'camera'
? Math.min(150, area.dh * 0.2) // keep clear of the bottom shutter deck
: Math.round(iconSize * 1.5); // library: sit at the photo corner
const yOffset =
mode === 'library'
? Math.min(area.dy + area.dh - padBottom, vh - 190) // never under the deck
: area.dy + area.dh - padBottom;
const topBarBottom = insets.top + 56;
const topBase = Math.max(area.dy + Math.round(iconSize * 0.6), topBarBottom);
const xOffset =
area.dx + (win ? area.dw * 0.05 : Math.min(16, area.dw * 0.04));
// Two stacked lines (pin/location above camera/timestamp).
const row1Y = topBase + Math.round(iconSize * 1.1);
const row2Y = topBase + Math.round(iconSize * 2.3);
// One clear space (0.6em ≈ a mono space) between each icon and its text.
const gap = (w: number) => Math.round(Math.max(w, iconSize) + iconSize * 0.6);
const pinW = iconFont.measureText('📍');
@@ -609,10 +600,10 @@ export default function Viewfinder({
return (
<Group>
{/* Icons white so they read as glyphs, not part of the amber text */}
<SkiaText x={xOffset} y={yOffset - 16} text="📍" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(pinW.width)} y={yOffset - 16} text={locationName} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={yOffset} text="📷" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(camW.width)} y={yOffset} text={timestampStr} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={row1Y} text="📍" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(pinW.width)} y={row1Y} text={locationName} font={customFont} color="#f59e0b" />
<SkiaText x={xOffset} y={row2Y} text="📷" font={iconFont} color="#ffffff" />
<SkiaText x={xOffset + gap(camW.width)} y={row2Y} text={timestampStr} font={customFont} color="#f59e0b" />
</Group>
);
};