c70edce8c1
ROTATE gains the two mirrors: H-FLIP and V-FLIP toggle one at a time and stay on through the quarter turns and STRAIGHTEN, which makes them compose with every rotation the strip already offers. ROTATE's own RESET levels the whole frame, mirrors included. The flip itself lands last, in screen space, so a mirrored photo is what the eye sees rather than what the sensor saw; the pixels are copied axis-aligned, so there is nothing to resample. Session state carries the two flags, so a reopened photo comes back mirrored. Also fixes the stamp: a typed PLACE NAME with no GPS fix now prints on its own (latitude/longitude ride in as NaN), instead of the whole stamp and its box being skipped for want of coordinates.
142 lines
5.9 KiB
TypeScript
142 lines
5.9 KiB
TypeScript
import { Skia, type SkImage } from '@shopify/react-native-skia';
|
|
|
|
// Turn a decoded image 90deg clockwise into a fresh SkImage. Used by the FRAME
|
|
// tab so a library photo can go landscape <-> portrait before it enters a frame:
|
|
// every consumer reads width()/height(), so the pixel-dimension swap carries the
|
|
// new orientation through the polaroid window aspect, the cover crop and the
|
|
// watermark areas without a single extra layout branch.
|
|
// ponytail: offscreen surface, same fallback as exportEngine.createSurface; a
|
|
// matrix-based draw would avoid the copy if this ever shows up in a profile.
|
|
export function rotateSkImage90(image: SkImage | null): SkImage | null {
|
|
if (!image) return null;
|
|
const w = image.width();
|
|
const h = image.height();
|
|
if (!(w > 0 && h > 0)) return null;
|
|
// CPU-backed on purpose: a GPU (MakeOffscreen) snapshot keeps its pixels in a
|
|
// context texture that is gone by the time the <Canvas> draws a frame later —
|
|
// the preview then renders the photo completely black. Raster pixels survive
|
|
// the trip to the render thread.
|
|
const surface = Skia.Surface.Make(h, w);
|
|
if (!surface) return image; // keep the unrotated photo on allocation failure
|
|
try {
|
|
const canvas = surface.getCanvas();
|
|
canvas.clear(Skia.Color('transparent'));
|
|
canvas.translate(h, 0);
|
|
canvas.rotate(90, 0, 0);
|
|
canvas.drawImage(image, 0, 0);
|
|
return surface.makeImageSnapshot() ?? image;
|
|
} finally {
|
|
// The raster snapshot refcounts the pixels, so the surface can go now
|
|
// instead of leaving one full-size copy behind per rotation.
|
|
surface.dispose();
|
|
}
|
|
}
|
|
|
|
// 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
|
|
try {
|
|
const canvas = surface.getCanvas();
|
|
canvas.clear(Skia.Color('transparent'));
|
|
canvas.translate(w / 2, h / 2);
|
|
canvas.rotate(degrees, 0, 0);
|
|
canvas.scale(k, k);
|
|
// A plain drawImage through a rotation is nearest-neighbour on CanvasKit: the
|
|
// edge lands on the same pixel in every row and the result staircases
|
|
// (measured: residual std of the edge position 0.289 vs 0.048 filtered, see
|
|
// straighten-sampling-probe.cjs). Only the *Options/*Cubic call shapes take a
|
|
// sampling option, and `drawImageRectOptions` exists in RN Skia too, so the
|
|
// shared renderer stays portable.
|
|
canvas.drawImageRectOptions(
|
|
image,
|
|
Skia.XYWHRect(0, 0, w, h),
|
|
Skia.XYWHRect(-w / 2, -h / 2, w, h),
|
|
Skia.FilterMode.Linear,
|
|
Skia.MipmapMode.None,
|
|
null
|
|
);
|
|
return surface.makeImageSnapshot() ?? image;
|
|
} finally {
|
|
surface.dispose(); // see rotateSkImage90
|
|
}
|
|
}
|
|
|
|
// Mirror the photo left<->right (H-FLIP) and/or top<->bottom (V-FLIP). An
|
|
// axis-aligned mirror lands whole pixels on whole pixels, so the plain
|
|
// drawImage stays exact: no sampling option needed the way STRAIGHTEN's rotated
|
|
// edge does. The dimensions never change, so the crop stays valid.
|
|
// ponytail: one offscreen surface per call, same trade as rotateSkImage90.
|
|
export function flipSkImage(
|
|
image: SkImage | null,
|
|
horizontal: boolean,
|
|
vertical: boolean
|
|
): SkImage | null {
|
|
if (!image || (!horizontal && !vertical)) return image;
|
|
const w = image.width();
|
|
const h = image.height();
|
|
if (!(w > 0 && h > 0)) return null;
|
|
const surface = Skia.Surface.Make(w, h);
|
|
if (!surface) return image; // keep the unflipped photo on allocation failure
|
|
try {
|
|
const canvas = surface.getCanvas();
|
|
canvas.clear(Skia.Color('transparent'));
|
|
// A negative scale turns every pixel coordinate around, so the translate
|
|
// puts the mirrored copy back inside the surface.
|
|
canvas.scale(horizontal ? -1 : 1, vertical ? -1 : 1);
|
|
canvas.translate(horizontal ? -w : 0, vertical ? -h : 0);
|
|
canvas.drawImage(image, 0, 0);
|
|
return surface.makeImageSnapshot() ?? image;
|
|
} finally {
|
|
surface.dispose(); // see rotateSkImage90
|
|
}
|
|
}
|
|
|
|
// 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, then the
|
|
// two mirrors — a flip is what the user sees, so it mirrors the photo as it
|
|
// stands, whatever turn and angle are already on it.
|
|
export function applyPhotoRotation(
|
|
image: SkImage | null,
|
|
quarter = 0,
|
|
straighten = 0,
|
|
flipH = false,
|
|
flipV = false
|
|
): SkImage | null {
|
|
let out = image;
|
|
const turns = Math.round(quarter / 90) % 4;
|
|
for (let i = 0; i < turns; i++) {
|
|
const next = rotateSkImage90(out);
|
|
// A 180/270 turn allocates one full-size copy per turn: the intermediate is
|
|
// dead as soon as the next one exists (never the caller's input image).
|
|
if (next && next !== out && i > 0) out.dispose();
|
|
out = next ?? out;
|
|
}
|
|
if (straighten) {
|
|
const s = rotateSkImageBy(out, straighten);
|
|
if (s && s !== out && out !== image) out.dispose(); // see above
|
|
out = s ?? out;
|
|
}
|
|
if (flipH || flipV) {
|
|
const f = flipSkImage(out, flipH, flipV);
|
|
if (f && f !== out && out !== image) out.dispose(); // see above
|
|
out = f ?? out;
|
|
}
|
|
return out;
|
|
}
|