feat(frame): read AUTO straighten from the photo, not the accelerometer

EXIF Orientation only knows 0/90/180/270, so a library still carries no
record of how the camera was held. The sensor had nothing to offer.

AUTO now measures the dominant line in the picture itself:
src/utils/horizon.ts runs a shear-projection search (coarse 1 deg over
-45..45, then a 0.5 deg refine) on a <=256px thumbnail, bails when no
line's score beats 3x the median, and returns the tilt in degrees.
App applies photoStraighten = -tilt, so preview and export share one
number exactly as the slider did.

Removes expo-sensors wiring, the horizonRoll state, effectiveStraighten
and the bubble-level overlay (autoRoll prop) from App/AdjustmentPanel/
Viewfinder. expo-sensors stays in package.json.
This commit is contained in:
2026-09-11 14:46:28 +07:00
parent d6b3ebda40
commit 1b632ba78c
4 changed files with 179 additions and 104 deletions
+17 -50
View File
@@ -6,7 +6,6 @@ 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';
@@ -72,35 +71,6 @@ 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));
@@ -223,9 +193,6 @@ export default function App() {
const [openParamKey, setOpenParamKey] = useState<string | null>(null);
// FRAME → WATERMARK sub-panel is open (the rail's WATERMARK tab is gone).
const [wmPanelOpen, setWmPanelOpen] = useState(false);
// FRAME -> ROTATE strip is open: gates the bubble-level line on the image.
const [rotatePanelOpen, setRotatePanelOpen] = useState(false);
const [libraryImageUri, setLibraryImageUri] = useState<string | null>(null);
// Session photo history (camera captures + library exports). The camera
// thumbnail/preview button shows the newest entry; the swipe viewer walks the
@@ -571,8 +538,6 @@ export default function App() {
setWmRotation(0);
setPhotoRotation(0);
setPhotoStraighten(0);
setAutoHorizon(false);
setHorizonRoll(0);
setWallLandscape(false);
setCropRatio('none');
setCropRect(DEFAULT_CROP_RECT);
@@ -593,7 +558,6 @@ export default function App() {
wmRotation !== 0 ||
photoRotation !== 0 ||
photoStraighten !== 0 ||
autoHorizon ||
wallLandscape ||
cropRatio !== 'none' ||
differs(cropRect, DEFAULT_CROP_RECT);
@@ -825,6 +789,18 @@ export default function App() {
setFavoriteId(id, on).catch((e) => console.warn('Failed to save favorite:', e));
};
// FRAME-tab AUTO: the photo is levelled from its OWN pixels, not from the
// phone. A library still carries no record of how the camera was held when
// it was shot (EXIF Orientation only knows 0/90/180/270), so the sensor had
// nothing to offer; the dominant line in the picture is the only horizon
// there is. A one-shot: it replaces whatever the slider or a previous AUTO
// left, and does nothing at all when the picture has no line worth trusting.
const handleAutoStraighten = useCallback(() => {
const tilt = viewfinderRef.current?.detectHorizon?.() ?? null;
if (tilt == null) return;
setPhotoStraighten(-tilt);
}, []);
const handlePickImage = async () => {
if (isProcessing) return;
@@ -859,12 +835,10 @@ export default function App() {
if (!result.canceled && result.assets && result.assets.length > 0) {
const asset = result.assets[0];
setLibraryImageUri(asset.uri);
// A new photo starts level: the previous photo's turn, fine straighten
// and AUTO horizon must not carry over.
// A new photo starts level: the previous photo's turn and fine
// straighten must not carry over.
setPhotoRotation(0);
setPhotoStraighten(0);
setAutoHorizon(false);
setHorizonRoll(0);
setMode('library');
// Picking from the camera is the other half of that switch (the camera
// button is the way back), so the camera's edits must not land on the
@@ -1126,7 +1100,7 @@ export default function App() {
// save the same crop the preview showed (null = centered 1x).
frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null,
photoRotation,
photoStraighten: effectiveStraighten,
photoStraighten,
}
);
@@ -1304,12 +1278,7 @@ export default function App() {
libraryImageUri={libraryImageUri}
availableHeight={deckTop}
photoRotation={photoRotation}
photoStraighten={effectiveStraighten}
autoRoll={
autoHorizon && mode === 'library' && openTab === 'frame' && rotatePanelOpen
? horizonRoll
: null
}
photoStraighten={photoStraighten}
cameraPermissionGranted={cameraPermissionGranted}
onRequestCameraPermission={requestCameraPermission}
photoOutput={photoOutput}
@@ -1390,8 +1359,7 @@ export default function App() {
photoStraighten={photoStraighten}
onPickRotation={setPhotoRotation}
onStraighten={setPhotoStraighten}
autoHorizon={autoHorizon}
onAutoHorizon={setAutoHorizon}
onAutoStraighten={handleAutoStraighten}
wallLandscape={wallLandscape}
onToggleWallLandscape={() => setWallLandscape((v) => !v)}
useGeotag={useGeotag}
@@ -1420,7 +1388,6 @@ export default function App() {
wmRotation={wmRotation}
onRotateWm={() => setWmRotation((r) => (r + 90) % 360)}
onWmPanelChange={setWmPanelOpen}
onRotatePanelChange={setRotatePanelOpen}
/>
</View>
)}