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
+55 -28
View File
@@ -72,8 +72,12 @@ export default function AdjustmentPanel({
// The single parameter whose slider row is open — a new selection replaces
// the previous row (one row at a time, above the chips).
const [openParam, setOpenParam] = useState<string | null>(null);
// The single color-chrome group whose OFF/WEAK/STRONG strip is open above the
// chips (same one-row-at-a-time pattern as openParam, but for choice chips).
const [openGroup, setOpenGroup] = useState<'cx' | 'cxb' | null>(null);
useEffect(() => {
setOpenParam(null);
setOpenGroup(null);
}, [activeTab]);
const toggleParam = (key: string) => {
@@ -81,6 +85,11 @@ export default function AdjustmentPanel({
setOpenParam((prev) => (prev === key ? null : key));
};
const toggleGroup = (g: 'cx' | 'cxb') => {
haptic();
setOpenGroup((prev) => (prev === g ? null : g));
};
const a = adjustments;
const renderChip = (c: ChipDef) => {
@@ -201,6 +210,23 @@ export default function AdjustmentPanel({
}
};
// Color-chrome groups: one toggle chip among the WB chips (COLOR CHROME /
// CHROME BLUE); tapping it reveals the OFF/WEAK/STRONG strip above the chip
// row. Amber text while an effect is active, amber border while its strip is
// open — same affordances as the parameter chips.
const chromeGroup: Record<
'cx' | 'cxb',
{ label: string; value: ColorAdjustments['colorChrome']; onChange: (v: ColorAdjustments['colorChrome']) => void }
> = {
cx: { label: 'COLOR CHROME', value: a.colorChrome || 'none', onChange: (v) => onUpdateAdjustments({ colorChrome: v }) },
cxb: { label: 'CHROME BLUE', value: a.colorChromeBlue || 'none', onChange: (v) => onUpdateAdjustments({ colorChromeBlue: v }) },
};
const chromeOptions = [
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
];
// ---- per-tab chip rows (bottom, above the tool rail) -----------------------
const chipsRow = (() => {
switch (activeTab) {
@@ -267,34 +293,6 @@ export default function AdjustmentPanel({
case 'wb':
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{renderChip({ key: 'sep-cx', label: 'COLOR CHROME', disabled: true, active: false, onPress: () => {} })}
{choiceChips(
'cx',
[
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
],
a.colorChrome || 'none',
(val) => onUpdateAdjustments({ colorChrome: val as ColorAdjustments['colorChrome'] })
)}
{renderChip({ key: 'sep-cxb', label: 'CHROME BLUE', disabled: true, active: false, onPress: () => {} })}
{choiceChips(
'cxb',
[
{ v: 'none', d: 'OFF' },
{ v: 'weak', d: 'WEAK' },
{ v: 'strong', d: 'STRONG' },
],
a.colorChromeBlue || 'none',
(val) => onUpdateAdjustments({ colorChromeBlue: val as ColorAdjustments['colorChromeBlue'] })
)}
</ScrollView>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
@@ -315,6 +313,15 @@ export default function AdjustmentPanel({
wbApply
)}
{paramChips(paramDefs.wb)}
{(['cx', 'cxb'] as const).map((g) =>
renderChip({
key: `grp-${g}`,
label: chromeGroup[g].label,
active: openGroup === g,
amberValue: openGroup !== g && chromeGroup[g].value !== 'none',
onPress: () => toggleGroup(g),
})
)}
</ScrollView>
</View>
);
@@ -401,6 +408,26 @@ export default function AdjustmentPanel({
</View>
</View>
)}
{openGroup && (
<View className="border-b border-zinc-800/60">
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{renderChip({
key: `grp-hint-${openGroup}`,
label: chromeGroup[openGroup].label,
disabled: true,
active: false,
onPress: () => {},
})}
{choiceChips(openGroup, chromeOptions, chromeGroup[openGroup].value, (v) =>
chromeGroup[openGroup].onChange(v as ColorAdjustments['colorChrome'])
)}
</ScrollView>
</View>
)}
{chipsRow}
</View>
);
+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>
);
};