Fix camera polaroid overflow and chrome WB chips; move GPS watermark to top
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user