// SPDX-License-Identifier: Apache-2.0 /** * PanoramaBandOverlay — V16 Phase 2 (merged band + strip). * * SINGLE source of truth for the live "progress strip" that sits on * top of the camera preview during a panorama hold. Replaces what * was previously TWO components rendered side-by-side: * * 1. live per-keyframe thumbnail strip — fed by accepted-frame URIs * (batch-keyframe engine) OR by * periodic vision-camera snapshots. * 2. — a single cumulative-panorama * thumbnail with a "fill ratio" * bar growing with the pan. * * The split made the UI visually noisy AND made it differ between * platforms when one side emitted keyframe events and the other * didn't. V16 Phase 2 collapses them into ONE component that: * * • Renders a horizontally-scrolling list of per-keyframe * thumbnails when `frameUris` is non-empty (batch-keyframe * mode). Each frame the KeyframeGate accepts shows up as a * mini-thumb. * * • Falls back to a SINGLE cumulative-panorama thumbnail (the * V12.14.9 fill-ratio behaviour) when `frameUris` is empty — * i.e. the live-stitching engines that don't surface * per-keyframe paths. This preserves the existing visual for * hybrid / firstwins / firstwins-rectilinear engines. * * • Edge-pinned to the BOTTOM of the camera area in portrait, and * to the user's RIGHT in landscape (which corresponds to * JS-bottom under the app's portrait-lock). Both anchors keep * the band out of the centre of the scene the operator is * framing. * * • Trailing arrow points along the pan axis (→ in portrait, ← in * landscape-left's user perception). Arrow always sits at the * pan-END side, so the LATEST keyframe abuts the arrow. * * • Auto-scrolls a `` so the latest keyframe stays * visible regardless of how many frames have been accumulated. * * Empty-state intentional non-design: * The KeyframeGate force-accepts the FIRST frame of every capture * (see C++ `AcceptFirstAnchoredOnPlane` / `AcceptFirstNoPlane` in * keyframe_gate.cpp). By the time the operator's perceived "the * band appeared", we already have at least one thumb/snapshot in * flight. We therefore don't render any "no frames yet" * placeholder — the empty period is sub-perceptual. * * Why this component is in react-native-image-stitcher (not host): * It's the same JSX shipped to iOS and Android. Differences in * what shows up come only from native-emitted data * (`state.batchKeyframeThumbnailPath` / `state.panoramaPath`), * not from per-platform component code. That's exactly the parity * property the user wants: "the UI should not differ between iOS * and Android — it's the same UI reused". */ import React, { useCallback, useMemo, useRef } from 'react'; import { Image, ScrollView, StyleSheet, Text, View, type ViewStyle, } from 'react-native'; import type { IncrementalState } from '../stitching/incremental'; /** * 2026-05-18 (Issue #3 fix) — 4-way capture orientation classifier. * Replaces the 2-way `state.isLandscape` boolean which couldn't * distinguish landscape-LEFT (home button on user's right) from * landscape-RIGHT (home button on user's left). Required because * the JS-coordinate mapping to user-perceived directions inverts * between the two landscape rotations — `flexDirection: 'row'` * gives oldest-at-user-top in landscape-LEFT but oldest-at-user- * bottom in landscape-RIGHT, so we need to branch the layout. */ export type BandCaptureOrientation = | 'portrait' | 'portrait-upside-down' | 'landscape-left' | 'landscape-right'; export interface PanoramaBandOverlayProps { /** * v0.12.0 — `true` when the band should render as a vertical * column in JS (anchor edge is JS-left or JS-right, i.e. * non-locked host with device-landscape). `false` (default) * renders the legacy horizontal strip — covers portrait-locked * hosts in any device orientation AND non-locked hosts in * portrait. The flagship `` derives this from * `useWindowDimensions()` + `useDeviceOrientation()` (see * `homeIndicatorEdge` in `Camera.tsx`); Layer-2 hosts pass it * directly. */ vertical?: boolean; /** * Latest engine state. Pass `useIncrementalStitcher().state`. * Used for single-thumb fallback URI and fill-ratio when no * per-keyframe URIs are provided. `state.isLandscape` is now * superseded by `captureOrientation` below for layout selection. */ state: IncrementalState | null; /** * Optional list of per-keyframe thumbnail URIs accumulated by the * host as the native batch-keyframe engine emits * `batchKeyframeThumbnailPath` events. When non-empty, the band * renders these as a scrolling mini-thumb strip. When empty or * undefined, the band falls back to the single cumulative-panorama * thumbnail (legacy live-engine visual). * * Caller should cap the list length itself if needed (e.g. the * AuditCaptureScreen already trims at 24 entries). This component * applies an internal hard cap as a safety net so a runaway * emission doesn't blow up the scroll view. */ frameUris?: string[]; /** * 2026-05-18 (Issue #3) — capture orientation passed from the host. * Drives a 4-way layout switch so the band reads correctly in * either landscape rotation (the 2-way `state.isLandscape` boolean * collapses landscape-LEFT and landscape-RIGHT to the same render * path, which inverts the user's perceived "oldest-top, grows * down" intent in one of them). Pass * `panoramaSettings.captureOrientation` from the host. Defaults * to `'portrait'` when omitted (back-compat). */ captureOrientation?: BandCaptureOrientation; } // ── Layout constants — tuned to read clearly at arm's length ──────── const BAND_PADDING = 6; const BAND_THICKNESS = 64; const ARROW_TRACK_LEN = 44; // fixed slot for the arrow glyph const SINGLE_THUMB_INNER = BAND_THICKNESS - BAND_PADDING * 2; const SINGLE_THUMB_MAX_PAN_LEN = 240; const MULTI_THUMB_LEN = 48; const MULTI_THUMB_GAP = 4; const MULTI_THUMB_HARD_CAP = 32; // safety net; host typically caps at 24 type LayoutKind = 'portrait' | 'landscape'; interface Layout { kind: LayoutKind; /** Outer container style — positioning + flexDirection. */ band: ViewStyle; /** * Direction used by both the outer band AND the scroll content. * row/row-reverse for horizontal bands; column/column-reverse for * vertical bands (non-locked host in landscape, jsLandscape=true). */ flexDirection: 'row' | 'row-reverse' | 'column' | 'column-reverse'; /** Unicode arrow pointing along the user-perceived pan axis. */ arrowGlyph: string; } /** * Resolve band layout from capture orientation. 2026-05-18 (Issue #3) * — uses the 4-way `BandCaptureOrientation` instead of the 2-way * `state.isLandscape` so we can pick the right flex direction + * arrow glyph in EACH landscape rotation. * * The two landscape rotations require different JS-coordinate setups * because the phone tilts the JS coordinate system relative to the * user differently: * * LANDSCAPE-LEFT (Apple: home indicator on user's RIGHT; phone * rotated 90° CCW from portrait). * JS-left = user-top * JS-right = user-bottom * Band at JS-bottom edge appears on user's RIGHT edge. * For "oldest at user-top, newest at user-bottom": * flexDirection = 'row' (array[0] at JS-left = user-top). * For arrow appearing as user-DOWN-arrow: * glyph `←` (rotated 90° CCW = points user-down). * * LANDSCAPE-RIGHT (Apple: home indicator on user's LEFT; phone * rotated 90° CW from portrait). * JS-left = user-bottom * JS-right = user-top * Band at JS-TOP edge appears on user's RIGHT edge (so we move * the band to JS-top here, not JS-bottom). * For "oldest at user-top, newest at user-bottom": * flexDirection = 'row-reverse' (array[0] at JS-right = user-top). * For arrow appearing as user-DOWN-arrow: * glyph `→` (rotated 90° CW = points user-down). * * PORTRAIT (and portrait-upside-down — collapsed because the band's * bottom-anchored position remains sensible either way): * Band at JS-bottom = user-bottom. Row left-to-right. Arrow `→` * reads as user-right-arrow (pointing along the horizontal pan * direction). */ /** * v0.13.1 — pure rotation-decision helpers, extracted for unit testing * (the lib's jest config is pure-TS, no component mounting; see * jest.config.js). These encode the orientation contract the band * relies on, so a regression in the angles/branches is caught in CI * rather than only on-device. * * `bandThumbRotation` — the CSS rotate transform that aligns a thumb's * pixels with the band box. Returns the transform array RN expects, or * `undefined` for "no rotation". Two regimes: * - vertical=false (portrait-locked UI): the box is device-aligned, so * a landscape device needs a 90° counter-rotation (CW for * landscape-left, CCW for landscape-right). * - vertical=true (non-locked, OS-rotated framebuffer): the screen * rotation already did half the work, so the compensation is the * OPPOSITE sign. * Exported as `_bandThumbRotationForTests`. */ function bandThumbRotation( orientation: BandCaptureOrientation, vertical: boolean, ): Array<{ rotate: string }> | undefined { if (vertical) { if (orientation === 'landscape-left') return [{ rotate: '-90deg' }]; if (orientation === 'landscape-right') return [{ rotate: '90deg' }]; return undefined; } if (orientation === 'landscape-left') return [{ rotate: '90deg' }]; if (orientation === 'landscape-right') return [{ rotate: '-90deg' }]; return undefined; } /** * v0.13.1 — the rotation actually applied to the per-keyframe (multi- * thumb) TILES. This is the EXIF double-rotation fix: the saved * `keyframe-N.jpg` is sensor-native landscape + EXIF Orientation 6, which * RN's already auto-rotates upright. So in the portrait-locked * (vertical=false) path NO further transform is applied — adding one * double-rotates (the original v0.12 bug). Only the non-locked * (vertical=true) path needs the compensation. Returns `undefined` for * "no transform". Exported as `_tileRotationForTests`. */ function tileRotation( orientation: BandCaptureOrientation, vertical: boolean, ): Array<{ rotate: string }> | undefined { return vertical ? bandThumbRotation(orientation, vertical) : undefined; } /** @internal test-only export — see `bandThumbRotation`. */ export const _bandThumbRotationForTests = bandThumbRotation; /** @internal test-only export — see `tileRotation`. */ export const _tileRotationForTests = tileRotation; function layoutFor( orientation: BandCaptureOrientation, vertical: boolean, ): Layout { const commonInner: ViewStyle = { alignItems: 'center', paddingHorizontal: BAND_PADDING, paddingVertical: BAND_PADDING, backgroundColor: 'rgba(0, 0, 0, 0.55)', }; // v0.12.0 — band structural orientation tracks the host's // `vertical` flag (which the host derives from JS layout // orientation): // // vertical=false Horizontal strip in JS coords. Under // portrait-lock + device-landscape this appears // as a vertical column on user-right via the // un-rotated framebuffer. // vertical=true Vertical column in JS coords. Non-locked // + device-landscape — band lives along the // JS-side strip where the home indicator is. // // What still varies by physical orientation regardless: the // thumbnail flow direction so newest sits at the user-perceived // pan-leading edge (flexDirection + arrowGlyph). if (vertical) { // Vertical band in JS coords (non-locked landscape). The OS // rotated the framebuffer so user-top = JS-top, user-bottom = // JS-bottom — same scroll direction regardless of whether the // device is landscape-left or landscape-right. Latest grows // toward user-bottom (= JS-bottom). flexDirection 'column' // puts array[0]/oldest at JS-top. return { kind: 'landscape', band: { marginHorizontal: 8, marginVertical: 16, width: BAND_THICKNESS, flexDirection: 'column', ...commonInner, }, flexDirection: 'column', arrowGlyph: '↓', }; } // vertical=false branch: pre-v0.12 horizontal-strip behavior // keyed on device-physical orientation for thumbnail direction. if (orientation === 'landscape-left') { // Phone rotated 90° CCW from portrait (home indicator on the // user's RIGHT). With UI orientation-locked to portrait: // JS-left (band horizontal start) = user-BOTTOM // JS-right (band horizontal end) = user-TOP // For the canonical "oldest at user-TOP, growth toward user- // BOTTOM" reading direction the monorepo established, we want: // array[0] (oldest) at user-TOP = JS-rightmost // newest at user-BOTTOM = JS-leftmost // → flexDirection: 'row-reverse' (array[0] at JS-rightmost) return { kind: 'landscape', band: { marginHorizontal: 16, marginVertical: 8, height: BAND_THICKNESS, flexDirection: 'row-reverse', ...commonInner, }, flexDirection: 'row-reverse', arrowGlyph: '←', }; } if (orientation === 'landscape-right') { // Phone rotated 90° CW from portrait (home indicator on the // user's LEFT). Mirror of landscape-left: // JS-left = user-TOP // JS-right = user-BOTTOM // For "oldest at user-TOP, newest at user-BOTTOM": // array[0] (oldest) at user-TOP = JS-leftmost // → flexDirection: 'row' (array[0] at JS-leftmost) return { kind: 'landscape', band: { marginHorizontal: 16, marginVertical: 8, height: BAND_THICKNESS, flexDirection: 'row', ...commonInner, }, flexDirection: 'row', arrowGlyph: '→', }; } // portrait / portrait-upside-down / default. Held portrait, pan // is horizontal left→right (or right→left for left-handed scans; // the band doesn't enforce a direction). newest at JS-rightmost. return { kind: 'portrait', band: { marginHorizontal: 16, marginVertical: 8, height: BAND_THICKNESS, flexDirection: 'row', ...commonInner, }, flexDirection: 'row', arrowGlyph: '→', }; } function PanoramaBandOverlayImpl({ state, frameUris, captureOrientation, vertical = false, }: PanoramaBandOverlayProps): React.JSX.Element | null { // 2026-05-18 (Issue #3 fix) — orientation source priority: // 1. `captureOrientation` prop from the host (4-way; correct // for landscape-left vs landscape-right disambiguation). // 2. Fallback to `state.isLandscape` (2-way; collapses both // landscape rotations to landscape-left semantics). // 3. Default `portrait` (the band's bottom-anchor still reads // sensibly before any orientation info is available). const resolvedOrientation: BandCaptureOrientation = captureOrientation ?? (state?.isLandscape ? 'landscape-left' : 'portrait'); const layout = useMemo( () => layoutFor(resolvedOrientation, vertical), [resolvedOrientation, vertical], ); const scrollRef = useRef(null); // Trim incoming URIs to a hard cap. The host already caps at 24 // (AuditCaptureScreen) but defence-in-depth keeps the ScrollView // bounded if a different host forgets to. Slice from the END so // we keep the MOST RECENT N — older frames slide off the start. const cappedFrameUris = useMemo(() => { if (!frameUris || frameUris.length === 0) return []; return frameUris.length > MULTI_THUMB_HARD_CAP ? frameUris.slice(frameUris.length - MULTI_THUMB_HARD_CAP) : frameUris; }, [frameUris]); const hasMultiThumb = cappedFrameUris.length > 0; // Auto-scroll on content-size change. `*-reverse` puts latest at // scroll origin (scrollTo {0,0}); normal `row`/`column` puts // latest at scroll end (scrollToEnd). const isReverse = layout.flexDirection === 'row-reverse' || layout.flexDirection === 'column-reverse'; const onContentSizeChange = useCallback(() => { const sv = scrollRef.current; if (!sv) return; if (isReverse) { sv.scrollTo({ x: 0, y: 0, animated: false }); } else { sv.scrollToEnd({ animated: false }); } }, [isReverse]); // ── Single cumulative thumbnail (live-engine fallback) ────────── // // Same fill-ratio math as V12.14.9. Kept so live-stitching engines // (hybrid / firstwins / firstwins-rectilinear / firstwins-zoomed) // that don't emit per-keyframe URIs still get a useful // progress-thumbnail UX — the thumb widens proportionally as the // operator pans further. const cumulativeUri = useMemo(() => { if (!state?.panoramaPath) return null; return `file://${state.panoramaPath}?v=${state.acceptedCount}`; }, [state?.panoramaPath, state?.acceptedCount]); const fillRatio = useMemo(() => { if (!state?.paintedExtent || !state?.panExtent) return 0; return Math.max(0, Math.min(1, state.paintedExtent / state.panExtent)); }, [state?.paintedExtent, state?.panExtent]); const singleThumbPanLen = useMemo(() => { return Math.max(SINGLE_THUMB_INNER, SINGLE_THUMB_MAX_PAN_LEN * fillRatio); }, [fillRatio]); // Image rotation transform for thumbnails. Captured frames are in // user-perspective orientation (the capture pipeline rotates the // sensor-native bytes via `outputOrientation="device"` + EXIF // baking in `normaliseOrientation`). The thumbnail BOX is in // JS coords. When JS coords are device-aligned (portrait-lock, // i.e. vertical=false here) and the device is in landscape, the // image content is rotated 90° from the box's axes → appears // sideways without compensation. Apply a counter-rotation to // line content up with the box's perceived "top". // // When vertical=true (non-locked + device-landscape; JS coords // rotated with screen), the box IS user-aligned already. No // rotation needed — the image is already correctly oriented for // direct display. // // V12.14.9 → v0.12.0 — extended from single-thumb (cumulative // panorama image fallback) to the multi-thumb path too. Pre- // v0.12 the multi-thumb keyframe thumbnails had no rotation // transform, so they appeared sideways in portrait-locked // landscape captures (the case the example app's batch-keyframe // engine hits). // Rotation for the single cumulative thumb (panorama-*.jpg, a JFIF // with NO EXIF tag → RN does not auto-rotate it, so the transform is // always needed). See `bandThumbRotation` for the angle contract. const thumbRotationTransform = useMemo( () => bandThumbRotation(resolvedOrientation, vertical), [resolvedOrientation, vertical], ); const singleImageStyle = useMemo( () => thumbRotationTransform ? [StyleSheet.absoluteFill, { transform: thumbRotationTransform }] : StyleSheet.absoluteFill, [thumbRotationTransform], ); // v0.13.1 — per-keyframe tile rotation is conditional on `vertical`. // // The keyframe JPEGs (`keyframe-N.jpg`) are saved as sensor-native // landscape PIXELS *plus* an EXIF Orientation tag (= 6, "rotate 90° // CW for display") — verified on-device: Android SM-A356U1 640×480 // + EXIF6, iOS iPhone16Pro 1920×1080 + EXIF6. RN's (Fresco // on Android, ImageIO on iOS) HONORS EXIF and auto-rotates each tile // to gravity-upright on its own. Whether a *further* JS transform is // needed depends on the band box's coordinate frame: // // vertical=false (portrait-locked UI): box is in portrait JS coords, // which align with the EXIF-upright tile → NO transform. Applying // one here double-rotates (the original v0.12 bug — tiles appeared // 90° off in portrait-locked landscape captures). Verified fixed // on Android portrait-lock. // vertical=true (non-locked host, device-landscape): box is in // landscape JS coords, rotated 90° from the EXIF-upright tile → // the counter-rotation is STILL required (verified on iOS: with no // transform the tiles sit 90° off). // // So reuse `thumbRotationTransform` (which already encodes the correct // per-orientation angle) ONLY in the vertical=true branch. // // The single cumulative thumb above always needs the transform: its // source (`panorama-*.jpg`) is a JFIF with NO EXIF tag (verified: // header ff d8 ff e0), so RN never auto-rotates it. // // Stitcher is unaffected — it reads `keyframe-N.jpg` with EXIF IGNORED // (IMREAD_IGNORE_ORIENTATION) so it still gets the sensor-native // pixels its pose intrinsics expect. Display-only. const multiThumbStyle = useMemo(() => { const tileTransform = tileRotation(resolvedOrientation, vertical); return tileTransform ? [styles.multiThumb, { transform: tileTransform }] : styles.multiThumb; }, [resolvedOrientation, vertical]); return ( {hasMultiThumb ? ( // Multi-thumb path: one image per accepted keyframe, scrolling // horizontally (in JS-coords) within the band. Content // flex-direction matches the outer band so OLDEST is at the // pan-start side and LATEST sits next to the arrow. // // 2026-05-18 (Issue A — arrow placement) — the arrow is the // LAST child of contentContainer (after the thumbnail map) // so it flows with the scroll content and always sits // adjacent to the newest thumbnail. Previously it was a // sibling of the ScrollView at the band's far end, which // looked detached when there were only a few thumbnails. {cappedFrameUris.map((uri, idx) => ( ))} {layout.arrowGlyph} ) : ( <> {/* Single-thumb path: cumulative panorama image, width * grows with the pan extent. Visually identical to * pre-V16 PanoramaBandOverlay so live-engine UX is * unchanged. Arrow stays a sibling here so it sits at * the band's end (the single-thumb View is fixed-width * so the layout is naturally "thumb + arrow"). */} {cumulativeUri ? ( ) : null} {layout.arrowGlyph} )} ); } // 2026-06-16 (audit #7) — memoized. This is the lone ~6 Hz consumer that mounts // in PRODUCTION (the debug pills are settings.debug-gated), and most engine ticks // are REJECTED frames that don't change its visible inputs (frameUris / // acceptedCount / orientation). React.memo skips the re-render on those, so the // ~6×/sec engine emits no longer re-render this overlay's subtree on the hot // capture path (battery/heat on long captures). export const PanoramaBandOverlay = React.memo(PanoramaBandOverlayImpl); const styles = StyleSheet.create({ // Properties common to every layout — uniform border-radius so the // band reads as a single capsule regardless of which edge it's // anchored to. Orientation-specific values (position, flexDirection, // sizing) come from `layoutFor()`. bandBase: { borderRadius: 12, }, thumbScroll: { flex: 1, }, thumbScrollContent: { alignItems: 'center', paddingHorizontal: BAND_PADDING, // 2026-05-18 (Issue #4 fix-a): contentContainer must FILL the // ScrollView width so flexDirection aligns items at the correct // end of the viewport. Without flexGrow, contentContainer // takes the natural width of its items (e.g. 150 px for 3 // thumbs) and anchors at JS-leftmost of the ScrollView, leaving // a big empty gap on JS-right. In landscape-left that gap is // on user-TOP — exactly what the operator reports as "thumbs // clump at the bottom". flexGrow:1 makes the contentContainer // span the viewport so items align at the END of the row- // direction (JS-right for `row`, JS-left for `row-reverse`). flexGrow: 1, }, multiThumb: { width: MULTI_THUMB_LEN, height: MULTI_THUMB_LEN, borderRadius: 4, // marginHorizontal so the gap applies in both `row` and // `row-reverse` directions identically; flex layout collapses // adjacent margins, giving us a single inter-thumb gap. marginHorizontal: MULTI_THUMB_GAP / 2, backgroundColor: 'rgba(255, 255, 255, 0.08)', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.55)', }, thumbBox: { backgroundColor: 'rgba(255, 255, 255, 0.08)', borderWidth: 1, borderColor: 'rgba(255, 255, 255, 0.55)', borderRadius: 4, overflow: 'hidden', }, arrowTrack: { width: ARROW_TRACK_LEN, alignItems: 'center', justifyContent: 'center', paddingHorizontal: BAND_PADDING, }, arrowGlyph: { color: 'rgba(255, 255, 255, 0.9)', fontSize: 28, lineHeight: 28, fontWeight: '600', }, });