diff --git a/App.tsx b/App.tsx index 87ea12c..3d777b5 100644 --- a/App.tsx +++ b/App.tsx @@ -66,9 +66,11 @@ export default function App() { // photo. Library mode letterboxes the image above it so no watermark // position hides under an open panel. Null until the first layout pass. const [deckTop, setDeckTop] = useState(null); - // Library photo rotation (FRAME tab), 0 or 90deg clockwise; feeds the - // preview source and the export alike. - const [photoRotation, setPhotoRotation] = useState<0 | 90>(0); + // Library photo rotation (FRAME tab): the quarter turn in degrees clockwise + // plus the fine STRAIGHTEN offset. Both feed the preview source and the + // 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); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', (e) => setKeyboardHeight(e.endCoordinates.height)); const hide = Keyboard.addListener('keyboardDidHide', () => setKeyboardHeight(0)); @@ -536,6 +538,7 @@ export default function App() { setUseGeotag(false); setWmRotation(0); setPhotoRotation(0); + setPhotoStraighten(0); setWallLandscape(false); setCropRatio('none'); setCropRect(DEFAULT_CROP_RECT); @@ -555,6 +558,7 @@ export default function App() { useGeotag || wmRotation !== 0 || photoRotation !== 0 || + photoStraighten !== 0 || wallLandscape || cropRatio !== 'none' || differs(cropRect, DEFAULT_CROP_RECT); @@ -1082,6 +1086,7 @@ export default function App() { // save the same crop the preview showed (null = centered 1x). frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null, photoRotation, + photoStraighten, } ); @@ -1259,6 +1264,7 @@ export default function App() { libraryImageUri={libraryImageUri} availableHeight={deckTop} photoRotation={photoRotation} + photoStraighten={photoStraighten} cameraPermissionGranted={cameraPermissionGranted} onRequestCameraPermission={requestCameraPermission} photoOutput={photoOutput} @@ -1336,7 +1342,9 @@ export default function App() { selectedFrame === 'cinematic') } photoRotation={photoRotation} - onToggleRotation={() => setPhotoRotation((r) => (r === 0 ? 90 : 0))} + photoStraighten={photoStraighten} + onPickRotation={setPhotoRotation} + onStraighten={setPhotoStraighten} wallLandscape={wallLandscape} onToggleWallLandscape={() => setWallLandscape((v) => !v)} useGeotag={useGeotag} diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index c2addf6..2612cbf 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -32,6 +32,7 @@ type GroupKey = | 'gpsName' | 'gpsTime' | 'crop' + | 'rotate' // Not a strip: the WATERMARK row REPLACES the frame chips (sub-panel). | 'wm'; @@ -81,10 +82,13 @@ interface AdjustmentPanelProps { onToggleFavorite: (id: string) => void; onUpdateAdjustments: (adjustments: Partial) => void; onUpdateFrame: (frameId: FrameId) => void; - // FRAME tab: turn the inserted library photo 90deg (landscape <-> portrait) - // so it fits the chosen frame better. - photoRotation: 0 | 90; - onToggleRotation: () => void; + // FRAME tab: turn the inserted library photo by a quarter turn (0/90/180/ + // 270deg clockwise) so it fits the chosen frame better, plus a fine + // STRAIGHTEN offset (-45..45deg) applied on top of that turn. + photoRotation: 0 | 90 | 180 | 270; + photoStraighten: number; + onPickRotation: (deg: 0 | 90 | 180 | 270) => void; + onStraighten: (deg: number) => void; // FRAME tab (WALL FRAME only): hang the artwork landscape instead of the // default portrait rotation. wallLandscape: boolean; @@ -213,6 +217,9 @@ export default function AdjustmentPanel({ onUpdateAdjustments, onUpdateFrame, photoRotation, + photoStraighten, + onPickRotation, + onStraighten, wallLandscape, onToggleWallLandscape, onToggleGeotag, @@ -224,7 +231,6 @@ export default function AdjustmentPanel({ onAcceptCustomWm, onCancelCustomWm, wmEditNonce, - onToggleRotation, wmRotation, onRotateWm, onWmPanelChange, @@ -421,7 +427,21 @@ export default function AdjustmentPanel({ : p ), filters: toRows(PARAM_DEFS.filters), - frame: [], + // FRAME carries one slider row: the fine STRAIGHTEN angle. The ROTATE + // strip's STRAIGHTEN option opens it, exactly like COLOR TEMP's. + frame: [ + { + key: 'straighten', + label: 'STRAIGHTEN', + value: photoStraighten, + default: 0, + min: -45, + max: 45, + step: 0.5, + display: (v: number) => `${v > 0 ? '+' : ''}${v}°`, + onChange: (v: number) => onStraighten(v), + }, + ], }; // TEMP chip's value: the remembered preset while the engine pair still @@ -546,6 +566,31 @@ 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: { + label: 'ROTATE', off: '0', + // 'straighten' while its slider row is open, so that chip highlights. + value: openParam === 'straighten' ? 'straighten' : String(photoRotation), + options: [ + { v: 'reset', d: 'RESET' }, + { v: '0', d: '0' }, + { v: '90', d: '90' }, + { v: '180', d: '180' }, + { v: '270', d: '270' }, + { v: 'straighten', d: 'STRAIGHTEN (±45°)' }, + ], + onPick: (v) => { + if (v === 'reset') { + onPickRotation(0); + onStraighten(0); + } else if (v === 'straighten') { + toggleParam('straighten'); + } else { + onPickRotation(Number(v) as 0 | 90 | 180 | 270); + } + }, + }, // PHOTO STYLE: the eight film simulations, collapsed into one strip chip. // Its value is the applied sim's id — or '' when a saved recipe is active // (then the chip reads as "not the startup sim" and glows amber). The label @@ -591,6 +636,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). + const rotateChip = (): ChipDef => { + const label = `ROTATE ${photoRotation}`; + return { + ...groupChip('rotate'), + label: photoStraighten + ? `${label} · ${photoStraighten > 0 ? '+' : ''}${photoStraighten}°` + : label, + amberValue: openGroup !== 'rotate' && (photoRotation !== 0 || photoStraighten !== 0), + }; + }; + // WATERMARK is a sub-panel of FRAME now (the rail tab is gone): its chip swaps // the frame chips for the watermark controls, behind a breadcrumb back. const wmOn = useGeotag || customWm.enabled; @@ -777,12 +835,7 @@ export default function AdjustmentPanel({ (val) => onUpdateFrame(val as FrameId) )} {cropEnabled && renderChip(groupChip('crop'))} - {renderChip({ - key: 'rotate', - label: photoRotation === 0 ? 'ROTATE 90' : 'ROTATE 0', - active: photoRotation !== 0, - onPress: onToggleRotation, - })} + {renderChip(rotateChip())} {selectedFrame === 'wallframe' && renderChip({ key: 'wall-orient', diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index 27ec720..68f7bb1 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -32,7 +32,7 @@ import { getSkiaColorMatrix, applyExposureGain } from '../utils/colorUtils'; import { TONE_SKSL, getToneUniforms, toneUniformArray } from '../utils/toneShader'; import { CINEMA_SKSL, getCinemaUniforms, cinemaOffFlat } from '../utils/cinemaShader'; import { polaroidLayout, wallframeLayout, WALLFRAME_PNG_W, WALLFRAME_PNG_H } from '../utils/frameUtils'; -import { rotateSkImage90 } from '../utils/skiaImage'; +import { applyPhotoRotation } from '../utils/skiaImage'; interface ViewfinderProps { mode: 'camera' | 'library'; @@ -49,8 +49,11 @@ interface ViewfinderProps { // custom-watermark position) hides under an open panel. Camera ignores this: // its feed rects are normalized against the real window. availableHeight?: number | null; - // Library only: user rotation of the inserted photo, 0 or 90deg clockwise. - photoRotation?: 0 | 90; + // Library only: user rotation of the inserted photo — the quarter turn in + // degrees clockwise (0/90/180/270) plus the fine STRAIGHTEN offset, applied + // on top of that turn. + photoRotation?: 0 | 90 | 180 | 270; + photoStraighten?: number; // 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; @@ -230,6 +233,7 @@ const Viewfinder = forwardRef(function Viewfi onWmEdit, wmEditing = false, photoRotation = 0, + photoStraighten = 0, wmRotation = 0, wallLandscape = false, }: ViewfinderProps, @@ -327,8 +331,8 @@ const Viewfinder = forwardRef(function Viewfi // flows through every derived rect (polaroid window aspect, cover crop, // watermark area) and the export matches the preview for free. const skiaImage = useMemo( - () => (photoRotation === 90 ? rotateSkImage90(loadedImage) : loadedImage), - [loadedImage, photoRotation] + () => applyPhotoRotation(loadedImage, photoRotation, photoStraighten), + [loadedImage, photoRotation, photoStraighten] ); // Wall-frame PNG artwork (project root). Loaded lazily so other frames pay // no asset cost; source switches to '' when unselected (useImage → null). @@ -1196,7 +1200,7 @@ const Viewfinder = forwardRef(function Viewfi // stretched, and the card keeps its dimensions. const polaroidFit: 'cover' | 'contain' | 'fill' = ratioWH ? 'cover' - : photoRotation === 90 + : photoRotation === 90 || photoRotation === 270 ? 'contain' : 'fill'; const polaroidGeo = useMemo(() => { diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index c1b2d88..a16ea18 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -9,7 +9,7 @@ import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray } from './to import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; -import { rotateSkImage90 } from './skiaImage'; +import { applyPhotoRotation } from './skiaImage'; export interface ExportResult { uri: string; @@ -70,10 +70,12 @@ export interface ExportOptions { // visible through the opening, so the file matches the preview. Null / // undefined = centered 1x (the historical full-image draw). frameWindowZoom?: { s: number; u: number; v: number } | null; - // Library FRAME-tab rotation (90 = clockwise). Applied to the decoded source - // before the ratio crop, so the frame layout and cover maths see exactly the - // orientation the preview showed. - photoRotation?: 0 | 90; + // Library FRAME-tab rotation: the quarter turn in degrees clockwise + // (0/90/180/270) and the fine STRAIGHTEN offset in degrees, applied to the + // decoded source before the ratio crop, so the frame layout and cover maths + // see exactly the orientation the preview showed. + photoRotation?: 0 | 90 | 180 | 270; + photoStraighten?: number; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 @@ -139,7 +141,8 @@ export async function processAndExportPhoto( let skImage = skImage0; // FRAME-tab rotation first: the pixel-dimension swap then propagates // through the ratio crop, frame layout and cover maths unchanged. - if (options?.photoRotation === 90) skImage = rotateSkImage90(skImage) ?? skImage; + skImage = + applyPhotoRotation(skImage, options?.photoRotation, options?.photoStraighten) ?? skImage; let width = skImage.width(); let height = skImage.height(); // 1b. Aspect-ratio crop (composition): center-largest upright crop of the @@ -445,7 +448,10 @@ export async function processAndExportPhoto( // Card proportions follow the UN-rotated source, so rotating the print // inside the frame never resizes the frame (same rule as the viewfinder). const frameAspect = - ratioWH ?? (options?.photoRotation === 90 ? height / width : width / height); + ratioWH ?? + (options?.photoRotation === 90 || options?.photoRotation === 270 + ? height / width + : width / height); const outH = Math.round( width * (POLAROID_CARD.sideMargin + POLAROID_CARD.bottomDeck) + (width * POLAROID_WIN_W) / frameAspect diff --git a/src/utils/skiaImage.ts b/src/utils/skiaImage.ts index e6d628a..463b8fd 100644 --- a/src/utils/skiaImage.ts +++ b/src/utils/skiaImage.ts @@ -25,3 +25,44 @@ export function rotateSkImage90(image: SkImage | null): SkImage | null { canvas.drawImage(image, 0, 0); return surface.makeImageSnapshot() ?? image; } + +// Fine rotation about the centre (the FRAME tab's STRAIGHTEN), keeping the +// original pixel dimensions: the content is scaled up just enough that the +// rotated rectangle still covers the frame, so no empty corner shows. The +// scale is the axis-aligned wrap of the counter-rotated frame — slightly +// conservative, which crops a hair more rather than leaking a corner. +// ponytail: one offscreen surface per call, same trade as rotateSkImage90. +export function rotateSkImageBy(image: SkImage | null, degrees: number): SkImage | null { + if (!image) return null; + const w = image.width(); + const h = image.height(); + if (!(w > 0 && h > 0)) return null; + if (!degrees) return image; + const rad = (degrees * Math.PI) / 180; + const cos = Math.abs(Math.cos(rad)); + const sin = Math.abs(Math.sin(rad)); + const k = Math.max((w * cos + h * sin) / w, (w * sin + h * cos) / h); + const surface = Skia.Surface.Make(w, h); + if (!surface) return image; // keep the unrotated photo on allocation failure + const canvas = surface.getCanvas(); + canvas.clear(Skia.Color('transparent')); + canvas.translate(w / 2, h / 2); + canvas.rotate(degrees, 0, 0); + canvas.scale(k, k); + canvas.drawImage(image, -w / 2, -h / 2); + return surface.makeImageSnapshot() ?? image; +} + +// The whole FRAME-tab photo orientation in one call: the quarter turn first +// (rotateSkImage90 swaps the pixel dimensions, which then propagates through +// every consumer unchanged), then the fine STRAIGHTEN on top of it. +export function applyPhotoRotation( + image: SkImage | null, + quarter = 0, + straighten = 0 +): SkImage | null { + let out = image; + for (let i = 0; i < Math.round(quarter / 90) % 4; i++) out = rotateSkImage90(out); + if (straighten) out = rotateSkImageBy(out, straighten); + return out; +}