Files
RecipesCam/docker/frontend/shared/utils/skiaImage.ts
T
3dtours 3a09674831 web: filter the straighten draw so a rotated edge stops staircasing
The FRAME tab's fine rotation drew the photo through canvas.rotate() +
canvas.scale() with a plain drawImage, which CanvasKit samples with
nearest: the edge landed on the same pixel in every row, so a rotated
edge came out as 1px steps every 1/tan(angle) rows. Measured on the 30deg
export of a hard black/white edge: 42.3% of rows repeated the previous
row's edge position, the step across the edge was 252.9 of 255, and there
were no intermediate pixels at all.

Only the *Options/*Cubic call shapes take a sampling option, and
drawImageRectOptions exists in RN Skia too, so the shared renderer can use
it unchanged. The same export now moves the edge in every row (0.2% of
rows repeat, 0.35 intermediate pixels per row) and its edge step drops to
222. Cost: the filtered draw takes 0.35s against 0.24s for the 1600px
preview copy and 2.0s against 1.4s for a 12MP photo, once per render.

Preview and export share the function, so both change together.
2026-09-23 07:33:44 +07:00

103 lines
4.4 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
}
}
// 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;
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;
}
return out;
}