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
+21 -10
View File
@@ -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),
};
};
+25
View File
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfi
wmEditing = false,
photoRotation = 0,
photoStraighten = 0,
autoRoll = null,
wmRotation = 0,
wallLandscape = false,
}: ViewfinderProps,
@@ -2253,6 +2257,24 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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 : (
<View pointerEvents="none" className="absolute inset-0 items-center justify-center">
<View style={{ width: '55%', transform: [{ rotate: `${autoRoll}deg` }] }}>
<View style={{ height: 4, backgroundColor: 'rgba(0,0,0,0.5)' }} />
<View
style={{
height: 2,
marginTop: -3,
backgroundColor: Math.abs(autoRoll) <= 1 ? '#f59e0b' : '#ffffff',
}}
/>
</View>
</View>
);
// 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<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
{levelOverlay}
</View>
);
}
@@ -2404,6 +2427,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
{levelOverlay}
</View>
);
}
@@ -2546,6 +2570,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
onResponderMove={onLibTouchMove}
onResponderRelease={onLibTouchEnd}
/>
{levelOverlay}
</>
) : (
<View className="items-center justify-center p-6">