From 0ce561cb703b8dd0d32b72d2afc2183eab9a07c6 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Fri, 11 Sep 2026 11:35:48 +0700 Subject: [PATCH] feat(frame): level the photo to the horizon with AUTO The ROTATE strip gains an AUTO chip that reads the device tilt from expo-sensors and feeds it into the straighten angle while the library is open, so the export is levelled to the horizon. It sits right after RESET, combines with the manual STRAIGHTEN offset and with the quarter turns, and both RESET paths clear it. The accelerator is sampled every 100 ms only while AUTO is on and the library is visible; near-flat and past 45 degrees readings are ignored so the level does not chase noise. AUTO rotates the image by +roll (a +10 degree emulator tilt exports 10 degrees clockwise), which is the sign that matches the preview. A level line overlay is drawn in the preview: amber within one degree of level, white otherwise. Adds the expo-sensors dependency, so a native rebuild is required. --- App.tsx | 40 ++++++++++++++++++++++++++++-- package-lock.json | 19 ++++++++++++-- package.json | 3 ++- src/components/AdjustmentPanel.tsx | 31 +++++++++++++++-------- src/components/Viewfinder.tsx | 25 +++++++++++++++++++ 5 files changed, 103 insertions(+), 15 deletions(-) diff --git a/App.tsx b/App.tsx index 3d777b5..8a44fce 100644 --- a/App.tsx +++ b/App.tsx @@ -6,6 +6,7 @@ import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library'; import * as MediaLibraryLegacy from 'expo-media-library/legacy'; import * as Haptics from 'expo-haptics'; +import { Accelerometer } from 'expo-sensors'; import AsyncStorage from '@react-native-async-storage/async-storage'; import TopBar from './src/components/TopBar'; @@ -71,6 +72,35 @@ export default function App() { // export alike, the straighten angle applied on top of the turn. const [photoRotation, setPhotoRotation] = useState<0 | 90 | 180 | 270>(0); const [photoStraighten, setPhotoStraighten] = useState(0); + // FRAME-tab AUTO: level the photo to the horizon. The accelerometer feeds the + // very same straighten angle the hand slider does (as an offset on top of + // it), so preview and export share one number. Library only — a live camera + // frame has no horizon to bake in. + const [autoHorizon, setAutoHorizon] = useState(false); + const [horizonRoll, setHorizonRoll] = useState(0); + useEffect(() => { + if (!autoHorizon || mode !== 'library') return; + Accelerometer.setUpdateInterval(100); + const sub = Accelerometer.addListener(({ x, y }) => { + // atan2(x, y) is the lean about the screen normal: 0 upright in portrait. + // Skip a near-flat hold — x/y are noise there and no horizon is readable. + // Whole degrees only: a 12 MP re-rotation per sensor tick would burn the + // battery for a sub-pixel move. + if (Math.hypot(x, y) < 0.2) return; + const deg = (Math.atan2(x, y) * 180) / Math.PI; + // Past 45 the phone is not being held to shoot: the level line and the + // angle both stop meaning anything, so keep the last good reading. + if (Math.abs(deg) > 45) return; + setHorizonRoll(Math.round(deg)); + }); + return () => sub.remove(); + }, [autoHorizon, mode]); + // Lean the phone and everything painted on its screen leans with it, so the + // photo gains exactly the lean the STRAIGHTEN slider would have to undo: the + // measured roll is added, never negated. An emulator tilt of +10° puts the + // export 10° clockwise of the untilted one. + const effectiveStraighten = + photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0)); @@ -539,6 +569,8 @@ export default function App() { setWmRotation(0); setPhotoRotation(0); setPhotoStraighten(0); + setAutoHorizon(false); + setHorizonRoll(0); setWallLandscape(false); setCropRatio('none'); setCropRect(DEFAULT_CROP_RECT); @@ -559,6 +591,7 @@ export default function App() { wmRotation !== 0 || photoRotation !== 0 || photoStraighten !== 0 || + autoHorizon || wallLandscape || cropRatio !== 'none' || differs(cropRect, DEFAULT_CROP_RECT); @@ -1086,7 +1119,7 @@ export default function App() { // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, photoRotation, - photoStraighten, + photoStraighten: effectiveStraighten, } ); @@ -1264,7 +1297,8 @@ export default function App() { libraryImageUri={libraryImageUri} availableHeight={deckTop} photoRotation={photoRotation} - photoStraighten={photoStraighten} + photoStraighten={effectiveStraighten} + autoRoll={autoHorizon && mode === 'library' ? horizonRoll : null} cameraPermissionGranted={cameraPermissionGranted} onRequestCameraPermission={requestCameraPermission} photoOutput={photoOutput} @@ -1345,6 +1379,8 @@ export default function App() { photoStraighten={photoStraighten} onPickRotation={setPhotoRotation} onStraighten={setPhotoStraighten} + autoHorizon={autoHorizon} + onAutoHorizon={setAutoHorizon} wallLandscape={wallLandscape} onToggleWallLandscape={() => setWallLandscape((v) => !v)} useGeotag={useGeotag} diff --git a/package-lock.json b/package-lock.json index eea29a2..d6342dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "recipescamera", - "version": "1.0.0", + "version": "1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "recipescamera", - "version": "1.0.0", + "version": "1.2", "dependencies": { "@react-native-async-storage/async-storage": "2.2.0", "@react-native-community/slider": "5.2.0", @@ -19,8 +19,10 @@ "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "expo-image-picker": "~57.0.4", + "expo-keep-awake": "~57.0.1", "expo-location": "~57.0.4", "expo-media-library": "~57.0.2", + "expo-sensors": "~57.0.2", "expo-sharing": "~57.0.18", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.24.0", @@ -3593,6 +3595,19 @@ "react-native": "*" } }, + "node_modules/expo-sensors": { + "version": "57.0.2", + "resolved": "https://registry.npmjs.org/expo-sensors/-/expo-sensors-57.0.2.tgz", + "integrity": "sha512-5TjX3b207CZWqMhbV8PMbvRCq+oLLEpnHCzB9TOGqGvcWx6LUx50vuGdPIGMPhuvBzlrPm4jvZik1U712j9BuQ==", + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4" + }, + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-server": { "version": "57.0.1", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-57.0.1.tgz", diff --git a/package.json b/package.json index 7e60021..ce4cdfd 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,16 @@ "@shopify/react-native-skia": "2.6.2", "babel-preset-expo": "~57.0.0", "expo": "~57.0.7", - "expo-keep-awake": "~57.0.1", "expo-asset": "~57.0.5", "expo-dev-client": "~57.0.6", "expo-file-system": "~57.0.1", "expo-font": "~57.0.1", "expo-haptics": "~57.0.1", "expo-image-picker": "~57.0.4", + "expo-keep-awake": "~57.0.1", "expo-location": "~57.0.4", "expo-media-library": "~57.0.2", + "expo-sensors": "~57.0.2", "expo-sharing": "~57.0.18", "expo-status-bar": "~57.0.1", "lucide-react-native": "^1.24.0", diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 2612cbf..c747d24 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -89,6 +89,8 @@ interface AdjustmentPanelProps { photoStraighten: number; onPickRotation: (deg: 0 | 90 | 180 | 270) => void; onStraighten: (deg: number) => void; + autoHorizon: boolean; + onAutoHorizon: (on: boolean) => void; // FRAME tab (WALL FRAME only): hang the artwork landscape instead of the // default portrait rotation. wallLandscape: boolean; @@ -220,6 +222,8 @@ export default function AdjustmentPanel({ photoStraighten, onPickRotation, onStraighten, + autoHorizon, + onAutoHorizon, wallLandscape, onToggleWallLandscape, onToggleGeotag, @@ -566,14 +570,15 @@ export default function AdjustmentPanel({ ], onPick: (v) => onUpdateCropRatio(v as CropRatio), }, - // ROTATE: the quarter turns plus the fine STRAIGHTEN slider in one strip. - // Its RESET undoes this parameter only (quarter turn + fine angle). + // ROTATE: the quarter turns, the fine STRAIGHTEN slider and AUTO horizon in + // one strip. Its RESET undoes this parameter only (turn + angle + auto). rotate: { label: 'ROTATE', off: '0', // 'straighten' while its slider row is open, so that chip highlights. - value: openParam === 'straighten' ? 'straighten' : String(photoRotation), + value: openParam === 'straighten' ? 'straighten' : autoHorizon ? 'auto' : String(photoRotation), options: [ { v: 'reset', d: 'RESET' }, + { v: 'auto', d: 'AUTO' }, { v: '0', d: '0' }, { v: '90', d: '90' }, { v: '180', d: '180' }, @@ -584,8 +589,11 @@ export default function AdjustmentPanel({ if (v === 'reset') { onPickRotation(0); onStraighten(0); + onAutoHorizon(false); } else if (v === 'straighten') { toggleParam('straighten'); + } else if (v === 'auto') { + onAutoHorizon(!autoHorizon); } else { onPickRotation(Number(v) as 0 | 90 | 180 | 270); } @@ -636,16 +644,19 @@ export default function AdjustmentPanel({ label: wbTempLabel(), }); - // ROTATE's chip names the applied turn — and the fine angle once it is set - // (the strip holds the options; nothing else names the angle). + // ROTATE's chip names every part of the angle it applies: the turn (dropped + // when it is 0 and AUTO is doing the work), the horizon mode, the fine angle. const rotateChip = (): ChipDef => { - const label = `ROTATE ${photoRotation}`; + const parts = [ + photoRotation !== 0 || !autoHorizon ? `ROTATE ${photoRotation}` : 'ROTATE', + autoHorizon ? 'AUTO' : null, + photoStraighten ? `${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` : null, + ]; return { ...groupChip('rotate'), - label: photoStraighten - ? `${label} · ${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` - : label, - amberValue: openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0), + label: parts.filter(Boolean).join(' · '), + amberValue: + openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0 || autoHorizon), }; }; diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 68f7bb1..1717b40 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -54,6 +54,9 @@ interface ViewfinderProps { // on top of that turn. photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; + // FRAME-tab AUTO: the device's lean in degrees, null while AUTO is off. Only + // drives the level line — the photo arrives already counter-rotated. + autoRoll?: number | null; // WATERMARK tab: double-tap ON the mark re-opens the text editor (App bumps // a nonce so the panel puts the caret back in its input). onWmEdit?: () => void; @@ -234,6 +237,7 @@ const Viewfinder = forwardRef(function Viewfi wmEditing = false, photoRotation = 0, photoStraighten = 0, + autoRoll = null, wmRotation = 0, wallLandscape = false, }: ViewfinderProps, @@ -2253,6 +2257,24 @@ const Viewfinder = forwardRef(function Viewfi const cinemaOn = cinemaEffect != null && !!cinemaParams; const useShaderPass = !!imageFitRect && ((toneEffect != null && toneOn) || cinemaOn); + // FRAME-tab AUTO: a bubble level that follows the device's own lean. The photo + // under it is already counter-rotated by the same angle, so this line is the + // only place the raw tilt is visible. Amber once within a degree of level. + const levelOverlay = autoRoll == null ? null : ( + + + + + + + ); + // RETRO INSTANT (library): the whole picture is contain-fitted into the // card's photo window — nothing is masked or cropped, and the card is // centered on the black background like a physical print. The window keeps @@ -2328,6 +2350,7 @@ const Viewfinder = forwardRef(function Viewfi onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> + {levelOverlay} ); } @@ -2404,6 +2427,7 @@ const Viewfinder = forwardRef(function Viewfi onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> + {levelOverlay} ); } @@ -2546,6 +2570,7 @@ const Viewfinder = forwardRef(function Viewfi onResponderMove={onLibTouchMove} onResponderRelease={onLibTouchEnd} /> + {levelOverlay} ) : (