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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user