feat(frame): give ROTATE quarter turns plus a fine straighten
This commit is contained in:
@@ -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<number | null>(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}
|
||||
|
||||
@@ -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<ColorAdjustments>) => 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',
|
||||
|
||||
@@ -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<ViewfinderHandle, ViewfinderProps>(function Viewfi
|
||||
onWmEdit,
|
||||
wmEditing = false,
|
||||
photoRotation = 0,
|
||||
photoStraighten = 0,
|
||||
wmRotation = 0,
|
||||
wallLandscape = false,
|
||||
}: ViewfinderProps,
|
||||
@@ -327,8 +331,8 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(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<ViewfinderHandle, ViewfinderProps>(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(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user