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.
This commit is contained in:
2026-09-11 11:35:48 +07:00
parent 9ddaeac260
commit 0ce561cb70
5 changed files with 103 additions and 15 deletions
+38 -2
View File
@@ -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}