Show native sensor in viewfinder composition band

SkiaCamera cover-fits the upright frame snapshot onto the fullscreen view,
so a 4:3 ratio band (aspect == native 3:4 sensor) only revealed a center
slice of the sensor instead of the whole frame. Redraw the feed inside the
frame worklet: when a photo-area rect is active (ratio band / polaroid
window / wall window) black the surface, clip to the rect's raw-frame
inverse under the renderToTexture rotation/mirror CTM, and cover-map the
sensor there. Equal-aspect bands now show the full sensor; narrower ratios
(3:2) crop center like the export. Inactive feed keeps the legacy draw.
This commit is contained in:
2026-09-08 19:34:32 +07:00
parent d43405a4de
commit 56c86df1b5
+132 -6
View File
@@ -6,6 +6,7 @@ import { SkiaCamera, type SkiaCameraRef } from 'react-native-vision-camera-skia'
import { createSynchronizable } from 'react-native-worklets';
import {
Canvas,
ClipOp,
useImage,
useFont,
Text as SkiaText,
@@ -89,6 +90,7 @@ const cinemaEffect = (() => {
const T_CLAMP = TileMode.Clamp;
const F_LINEAR = FilterMode.Linear;
const M_NONE = MipmapMode.None;
const CLIP_INTERSECT = ClipOp.Intersect;
export default function Viewfinder({
mode,
@@ -364,6 +366,14 @@ export default function Viewfinder({
// the preview flicker. All live values flow through the synchronizables.
type SkiaCameraOnFrame = NonNullable<ComponentProps<typeof SkiaCamera>['onFrame']>;
const toneFailLogged = useMemo(() => createSynchronizable<boolean>(false), []);
// Composition band fed to the frame worklet: [active, viewAspect, fx, fy,
// fw, fh] where (fx,fy,fw,fh) is the photo-area rect (ratio band / polaroid
// window / wall window) normalized to the view. While active the worklet
// redraws the feed cover-mapped INTO that rect instead of the default
// fullscreen cover-fit — with a 4:3 ratio (== native sensor) the whole
// sensor shows, narrower ratios crop exactly like the export. [0]=0 keeps
// the legacy fullscreen draw untouched.
const feedRectSync = useMemo(() => createSynchronizable<number[]>([0, 0, 0, 0, 0, 0]), []);
// RETRO POLAROID on the live camera is NOT drawn in the frame pipeline: that
// canvas is an offscreen surface in frame-pixel space which RNSkia later
// cover-fits onto the view, so frame-space geometry overflows the screen and
@@ -380,13 +390,14 @@ export default function Viewfinder({
const cinema = cinemaSync.getBlocking();
const hasTone = toneEffect != null && (tone[0] !== 0 || tone[1] !== 0 || tone[2] !== 0);
const hasCinema = cinemaEffect != null && cinema[0] > 0;
const drawPass = () => {
// Draws the full frame with the tone/cinema shader when active (drawRect,
// not drawImage: paint shaders only apply to geometry fills — drawImage
// ignores the paint shader, which is why the DR/Highlight/Shadow tone
// never showed on the live preview) or as a plain color-matrix image.
const drawFullFrame = () => {
if (hasTone || hasCinema) {
try {
const imageShader = frameTexture.makeShaderOptions(T_CLAMP, T_CLAMP, F_LINEAR, M_NONE);
// drawRect, not drawImage: paint shaders only apply to geometry
// fills — drawImage ignores the paint shader, which is why the
// DR/Highlight/Shadow tone never showed on the live preview.
let final = null;
if (hasTone) {
final = toneEffect.makeShaderWithChildren(tone, [imageShader]);
@@ -411,11 +422,104 @@ export default function Viewfinder({
}
canvas.drawImage(frameTexture, 0, 0, paint);
};
drawPass();
// Composition band: without this the SkiaCamera layer later cover-fits
// the upright snapshot onto the fullscreen view, so a 4:3 ratio band
// only revealed a center slice of the sensor. Redraw the feed INTO the
// band rect: black the surface, clip to the rect's raw-frame inverse
// (under the renderToTexture rotation/mirror CTM) and cover-map the
// sensor there. Equal-aspect bands (ratio 4:3 == native 3:4 sensor)
// then show the ENTIRE sensor; narrower bands crop like the export.
const feed = feedRectSync.getBlocking();
const bandActive = feed[0] > 0;
if (!bandActive) {
drawFullFrame();
return;
}
const ftW = frameTexture.width();
const ftH = frameTexture.height();
const orient = frame.orientation;
const isLand = orient === 'left' || orient === 'right';
const ow = isLand ? ftH : ftW;
const oh = isLand ? ftW : ftH;
const ac = ow / oh;
const viewAspect = feed[1];
if (!(ow > 0 && oh > 0 && ac > 0 && viewAspect > 0 && feed[4] > 0 && feed[5] > 0)) {
drawFullFrame();
return;
}
// View -> upright-snapshot mapping: the snapshot is cover-fitted to the
// view, so the view shows a centered visX x visY window of it.
const visX = Math.min(1, viewAspect / ac);
const visY = Math.min(1, ac / viewAspect);
const ox0 = (1 - visX) / 2 + feed[2] * visX;
const ox1 = ox0 + feed[4] * visX;
const oy0 = (1 - visY) / 2 + feed[3] * visY;
const oy1 = oy0 + feed[5] * visY;
const X0 = ox0 * ow;
const X1 = ox1 * ow;
const Y0 = oy0 * oh;
const Y1 = oy1 * oh;
// Invert the renderToTexture CTM (out-space -> raw frame space) by
// walking its concats in reverse: A translate(ow/2,oh/2), mirror
// scale(-1,1), rotate(-orientation), D translate(-oh/2,-ow/2).
const mirrored = frame.isMirrored === true;
const deg =
orient === 'down' ? 180 : orient === 'left' ? 90 : orient === 'right' ? 270 : 0;
const toRaw = (X: number, Y: number): [number, number] => {
let x = X - ow / 2;
let y = Y - oh / 2;
if (mirrored) x = -x;
const r = (-deg * Math.PI) / 180;
const c = Math.cos(r);
const s = Math.sin(r);
const rx = x * c - y * s;
const ry = x * s + y * c;
if (isLand) return [rx + oh / 2, ry + ow / 2];
return [rx + ow / 2, ry + oh / 2];
};
let u0 = ftW;
let u1 = 0;
let v0 = ftH;
let v1 = 0;
const c0 = toRaw(X0, Y0);
const c1 = toRaw(X1, Y0);
const c2 = toRaw(X0, Y1);
const c3 = toRaw(X1, Y1);
const cs = [c0, c1, c2, c3];
for (let i = 0; i < 4; i++) {
u0 = Math.min(u0, cs[i][0]);
u1 = Math.max(u1, cs[i][0]);
v0 = Math.min(v0, cs[i][1]);
v1 = Math.max(v1, cs[i][1]);
}
const rw = u1 - u0;
const rh = v1 - v0;
if (!(rw > 0 && rh > 0)) {
drawFullFrame();
return;
}
// Cover-map the full sensor into the raw rect (dst preserves the sensor
// aspect, centered; the clip below trims the overflow) — translate then
// scale: Skia concat applies the later call first (p -> s*p + d).
const s = Math.max(rw / ftW, rh / ftH);
const dw = s * ftW;
const dh = s * ftH;
const dx = u0 + (rw - dw) / 2;
const dy = v0 + (rh - dh) / 2;
// Fresh paint; its default color is opaque black (Skia default), so
// the surface fill below needs no explicit color set.
const blackPaint = Skia.Paint();
canvas.drawRect(Skia.XYWHRect(0, 0, ftW, ftH), blackPaint);
canvas.save();
canvas.clipRect(Skia.XYWHRect(u0, v0, rw, rh), CLIP_INTERSECT, false);
canvas.translate(dx, dy);
canvas.scale(s, s);
drawFullFrame();
canvas.restore();
});
frame.dispose();
},
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged]
[colorMatrixSync, toneSync, cinemaSync, toneFailLogged, feedRectSync]
);
const grainOpacity = adjustments.grain / 20;
@@ -469,6 +573,28 @@ export default function Viewfinder({
if (selectedFrame !== 'wallframe') return null;
return wallframeLayout(vw, vh, 'contain');
}, [selectedFrame, vw, vh]);
// Live feed band rect for the CAMERA preview: the photo-area that must show
// the export-equivalent sensor crop — the ratio band region (plain frames),
// the polaroid photo window or the wall-frame window. When present the frame
// worklet redraws the feed into it (native 4:3 shows the whole sensor);
// when null ([0]=0) the feed stays the legacy fullscreen cover draw.
const cameraPhotoRect =
mode === 'camera'
? polaroidGeo
? polaroidGeo.window
: selectedFrame === 'wallframe' && wallGeo && wallImage
? wallGeo.window
: regionBand
? region
: null
: null;
useEffect(() => {
feedRectSync.setBlocking(
cameraPhotoRect
? [1, vw / vh, cameraPhotoRect.x / vw, cameraPhotoRect.y / vh, cameraPhotoRect.w / vw, cameraPhotoRect.h / vh]
: [0, 0, 0, 0, 0, 0]
);
}, [cameraPhotoRect, vw, vh, feedRectSync]);
const onLibTouchStart = (e: any) => {
const ts = e.nativeEvent.touches;
if (ts.length >= 2) {