From 56c86df1b551cbd8166b117fb28be0ae730d7e4b Mon Sep 17 00:00:00 2001 From: 3dtours Date: Tue, 8 Sep 2026 19:34:32 +0700 Subject: [PATCH] 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. --- src/components/Viewfinder.tsx | 138 ++++++++++++++++++++++++++++++++-- 1 file changed, 132 insertions(+), 6 deletions(-) diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index cdd2a43..8b5cf81 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -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['onFrame']>; const toneFailLogged = useMemo(() => createSynchronizable(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([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) {