feat(crop): two-step crop with APPLY + hidden amber border, persisted in session

- FRAME/CROP: choosing a ratio shows the amber band (border + 0.55 dim);
  APPLY collapses the preview to the crop rect with an opaque mask and
  removes the amber stroke; RESET returns to 'none'.
- Viewfinder: libCropView (k = min(vw/dw, vh/dh)) + cropScreenPx drive the
  mask/band, libViewMatrix folds the crop transform into the image groups,
  libCropTouchStyle keeps touch mapping aligned.
- Persist cropApplied in the session snapshot and restore it only when it
  still matches cropRatio.
- Add PLAN-2026-09-09.md with the measurements.

tsc --noEmit unchanged at 14 pre-existing errors.
This commit is contained in:
2026-09-11 17:29:51 +07:00
parent 1b632ba78c
commit 6faef4aef7
4 changed files with 904 additions and 274 deletions
+114 -121
View File
@@ -70,6 +70,10 @@ interface ViewfinderProps {
// whole photo with a draggable keep-rectangle. Zoom/pan is suspended while a
// crop is active so the preview and the file can never disagree.
cropRatio?: CropRatio;
// The ratio the CROP strip's APPLY committed. Equal to cropRatio only while
// the crop is confirmed: the viewer then shows the crop alone (no band
// outline, no dim surround). Picking any other ratio drops back to framing.
cropApplied?: CropRatio | null;
cropRect?: CropRect;
onCropRectChange?: (r: CropRect) => void;
// RAW DNG toggle: while on, the DNG output below is attached to the session
@@ -82,18 +86,13 @@ interface ViewfinderProps {
onSessionError?: (error: Error) => void;
// Quick exposure slider (shown while AE/AF is locked) — live AE bias in EV.
onExposureChange?: (ev: number) => void;
// Metering-mode EV offset (highlight-weighted): extra EV stops ADDED on top
// of the recipe EV. Applied to the preview matrix AND the capture-time AE
// flush so preview/export/capture agree; the EV slider UI keeps showing the
// raw user EV. Negative = protect highlights. 0 = off.
meteringEvOffset?: number;
// Metering-mode tonal look (highlight-weighted): small extra adjustments
// merged on top of the recipe for RENDERING only (preview matrix + tone, and
// App mirrors it into the export). Real highlight-weighted metering (GR /
// Sony) doesn't only underexpose — it meters for the BRIGHTEST area to keep
// its detail (absolute no-blowout): highlight roll, deep shadow crush, raised
// contrast/clarity. Never contains exposureCompensation (that rides the EV
// offset above); UI sliders keep showing the raw recipe.
// Metering-mode tonal look (highlight-weighted): extra adjustments merged on
// top of the recipe for BOTH the preview matrix/tone and the export. Real
// highlight-weighted metering (GR / Sony) doesn't only underexpose — it meters
// for the BRIGHTEST area to keep its detail (absolute no-blowout): highlight
// roll, deep shadow crush, raised contrast/clarity. Carries the small EV dip
// too (exposureCompensation), so preview and export share ONE EV source; the
// UI sliders keep showing the raw recipe.
meteringAdjustments?: Partial<ColorAdjustments>;
// Continuous panel parameter whose slider row is open (App lifts it from the
// AdjustmentPanel). While set, a vertical drag ANYWHERE on the live image
@@ -139,13 +138,6 @@ interface ViewfinderProps {
}
export interface ViewfinderHandle {
/**
* Flush the current EV (adjustments.exposureCompensation) to the camera AE
* bias. Called by App right before a photo capture so the shot carries true
* hardware compensation; never called live while the EV slider drags (each
* hardware bias change re-locks AE on some devices and flashes the preview).
*/
flushExposureBias(): Promise<void>;
/**
* Current interactive photo reposition inside a framed library window
* (RETRO INSTANT / WALL FRAME): scale s (>=1) and the visible window-center
@@ -225,13 +217,13 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
photoOutput,
aspectRatio,
cropRatio = 'none',
cropApplied = null,
cropRect = DEFAULT_CROP_RECT,
onCropRectChange,
rawEnabled,
rawOutput,
onSessionError,
onExposureChange,
meteringEvOffset = 0,
meteringAdjustments,
imageAdjustTarget,
customWm = { enabled: false, text: '', x: 0.5, y: 0.5 },
@@ -293,6 +285,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
selectedFrame === 'none' || selectedFrame === 'classic-white' || selectedFrame === 'cinematic';
const libCropActive = mode === 'library' && cropRatio !== 'none' && libCropPlainFrame;
const libFreeCrop = libCropActive && cropRatio === 'free';
// CROP's second step: once APPLY has committed the ratio the band is no
// longer an outline over the whole picture — the crop itself is the view.
const libCropApplied = libCropActive && cropApplied === cropRatio;
// Camera outputs: attach the DNG output only while RAW is on — CameraX
// cannot bind two ImageCapture use cases at once, and App's session-error
// handler turns RAW off again when that ever fails.
@@ -375,27 +370,15 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const adjustments = meteringAdjustments
? { ...recipe.adjustments, ...meteringAdjustments }
: recipe.adjustments;
// Hardware AE bias (EV stops) currently sitting on the camera from the LAST
// capture flush (CameraX keeps it until the next change and it affects the
// live preview too). The preview matrix must render target − bias, otherwise
// after a highlight-weighted capture the preview double-darkens (the target
// EV again on top of the already-biased hardware).
const [hwBiasStops, setHwBiasStops] = useState(0);
// Exposure compensation, applied as software 2^EV matrix gain for BOTH modes.
// Camera AE bias is only flushed to hardware at capture time
// (flushExposureBias): setting it live per slider value re-locks AE on the
// Xiaomi and flashes the preview (matrix change is instant and flicker-free).
// Metering offset (highlight-weighted) rides on top of the user EV so the
// preview shows the protected-exposure look; EV_RANGE ±3 caps the software
// gain (flush clamps to the device range separately).
// Exposure compensation, applied as a software 2^EV matrix gain for BOTH
// modes — the camera's hardware AE bias is never touched. setExposureBias
// re-locks AE on the Xiaomi and flashes the preview, and the bias lands
// asynchronously, so a shot exposed through it could disagree with what the
// viewfinder showed at shutter time. The software gain is instant and
// flicker-free, and it is what the export applies too (no evFromCamera), so
// preview and file always match. EV_RANGE ±3 caps the gain.
const clampEvApplied = (v: number) => Math.max(-3, Math.min(3, v));
// Full EV the shot should carry (user EV + metering-mode offset). The
// software matrix renders target − hardware-bias while the camera is live —
// the hardware applies its bias to the preview itself, so adding the full
// target again would double it. Library stills (no hardware bias) and fresh
// camera sessions (bias reset to 0) render plain target.
const evTarget = clampEvApplied((adjustments.exposureCompensation ?? 0) + meteringEvOffset);
const evApplied = clampEvApplied(mode === 'camera' ? evTarget - hwBiasStops : evTarget);
const evApplied = clampEvApplied(adjustments.exposureCompensation ?? 0);
const colorMatrix = applyExposureGain(
getSkiaColorMatrix(recipe.baseFilter, adjustments),
evApplied
@@ -438,10 +421,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
hdfSync.setBlocking(adjustments.hdf ?? 0);
}, [colorMatrix, toneParams, cinemaParams, adjustments.hdf, colorMatrixSync, toneSync, cinemaSync, hdfSync]);
// Camera controller lives behind SkiaCamera. AE bias is not tracked live:
// flushExposureBias() below applies it right before each photo capture so the
// shot carries true hardware compensation while EV slider drags stay
// flicker-free (preview EV is software matrix gain).
// Camera controller lives behind SkiaCamera. The camera AE bias is never
// written: EV (user + metering offset) is purely software matrix gain, for
// the preview and for the export alike.
const skiaCameraRef = useRef<SkiaCameraRef>(null);
const [cameraActive, setCameraActive] = useState(false);
// Re-render kicker bumped on every onStarted. A flip remounts <SkiaCamera>
@@ -458,10 +440,6 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
watchdogBudgetRef.current = 0;
setCameraActive(true);
bumpSessionStart((n) => n + 1);
// A fresh CameraX session starts with the AE bias reset to 0 — mirror that
// so the preview compensation (target − bias) never subtracts a stale bias
// from the previous session.
setHwBiasStops(0);
}, []);
const handleCameraStopped = useCallback(() => {
setCameraActive(false);
@@ -531,56 +509,14 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
zoomRef.current = 1;
setZoomRatio(1);
}, [facing]);
const evStops = adjustments.exposureCompensation ?? 0;
// Latest slider value, so stale retries from a previous value back off when
// the user has already dragged further (dragging fast used to queue 5 retries
// per intermediate value — each one cancelled by the next, spamming [ev]).
const evStopsRef = useRef(evStops);
evStopsRef.current = evStops;
// Metering offset mirrored into a ref: flushExposureBias is a stable
// imperative handle (useImperativeHandle deps [ref]) and must read the LIVE
// offset when the user switches metering mode.
const meteringEvOffsetRef = useRef(meteringEvOffset);
meteringEvOffsetRef.current = meteringEvOffset;
useEffect(() => {
if (!controller) return;
const d = controller.device;
if (d && !d.supportsExposureBias) {
console.warn('Device has no exposure-bias support — EV applies to preview and export only.');
}
}, [controller]);
// Hardware AE bias is applied ONLY at capture time via flushExposureBias()
// (called by App right before the shutter) — never live while the EV slider
// drags: each setExposureBias re-locks AE on the Xiaomi and flashes the
// preview. The preview shows the EV through the software matrix above.
// CameraX exposes compensation *indexes* (1 index = 1/8 EV on this device
// class, range -24..24 = ±3 EV): map EV -> index, clamp to the device range,
// retry on Cancel (a session reconfig cancels an in-flight bias change).
// The quick-EV bubble/drag is the recipe's OWN EV: `adjustments` above also
// carries the metering-mode dip (highlight-weighted), so reading it here
// showed EV -0.5 while the LIGHT tab slider sat at 0 — and a drag then wrote
// that leaked value back as the user's EV.
const evStops = recipe.adjustments.exposureCompensation ?? 0;
useImperativeHandle(
ref,
() => ({
async flushExposureBias() {
const ctl = skiaCameraRef.current?.controller;
const d = ctl?.device;
if (!ctl || !d || !d.supportsExposureBias) return;
const ev = evStopsRef.current + meteringEvOffsetRef.current;
const index = Math.round(ev * 8);
const clamped = Math.max(d.minExposureBias, Math.min(d.maxExposureBias, index));
// Record the bias that just landed (or is already present) on the
// hardware: the preview subtracts it (target − bias) so the EV is never
// applied twice between captures. Index units / 8 = EV stops.
setHwBiasStops(clamped / 8);
if (clamped === ctl.exposureBias) return;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await ctl.setExposureBias(clamped);
return;
} catch (e) {
console.warn(`Exposure-bias flush failed (attempt ${attempt}): ${String(e).slice(0, 100)}`);
await new Promise((r) => setTimeout(r, 80));
}
}
},
// Frame-window (polaroid/wall) zoom, for export parity. Read
// live through the ref — the handle is captured once with deps [ref].
getFrameWindowZoom() {
@@ -636,7 +572,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
// Quick exposure slider — appears while AE/AF is locked. Drag up/down maps
// directly to the recipe EV in stops (same live software-matrix path as the
// EV slider; flushExposureBias applies it to the camera at capture time).
// EV slider, and the same EV the export applies).
const EV_RANGE = 3; // matches the ±24 index range at 1 index = 1/8 EV
const evTrackTop = vh * 0.24;
const evTrackH = vh * 0.42;
@@ -1160,6 +1096,23 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
dh,
};
}, [libCropActive, cropRatio, imageFitRect]);
// Committed crop geometry: the band's contents scaled up so the ratio fills
// the viewer (centred, black around it). Frame, marks, grain and vignette all
// hug the band from inside the photo group, so one extra matrix over that
// group carries them along; the touch layer takes the same transform so its
// canvas coordinates still land on the pixel under the finger.
const libCropView = useMemo(() => {
if (!libCropApplied || !libCropBand) return null;
const k = Math.min(vw / libCropBand.dw, vh / libCropBand.dh);
const w = libCropBand.dw * k;
const h = libCropBand.dh * k;
return {
k,
ox: (vw - w) / 2 - k * libCropBand.dx,
oy: (vh - h) / 2 - k * libCropBand.dy,
rect: { x: (vw - w) / 2, y: (vh - h) / 2, w, h },
};
}, [libCropApplied, libCropBand, vw, vh]);
// Photo display rect + draw fit in the plain library viewer: the full photo
// is contain-fitted to the screen (fill into its own aspect rect — no
// distortion, identical to the old fullscreen contain). The ratio band only
@@ -1189,6 +1142,9 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
const cropWinPx = libCropBand
? { x: libCropBand.dx, y: libCropBand.dy, w: libCropBand.dw, h: libCropBand.dh }
: freeCropPx;
// What the screen-space dark mask surrounds: the same band, or the rect the
// committed crop was scaled onto.
const cropScreenPx = libCropView ? libCropView.rect : cropWinPx;
const frameRect =
cropWinPx
? cropWinPx
@@ -1840,6 +1796,37 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
() => [libZoom.s, 0, libZoom.tx, 0, libZoom.s, libZoom.ty, 0, 0, 1],
[libZoom]
);
// Committed crop: one more matrix ON TOP of the photo group's zoom/pan, so
// the band's framed contents land on the viewer rect — outer·inner =
// [ks, 0, k·tx + s·ox, 0, ks, k·ty + s·oy, 0, 0, 1].
const libViewMatrix = useMemo(
() =>
libCropView
? [
libZoom.s * libCropView.k,
0,
libZoom.s * libCropView.ox + libCropView.k * libZoom.tx,
0,
libZoom.s * libCropView.k,
libZoom.s * libCropView.oy + libCropView.k * libZoom.ty,
0,
0,
1,
]
: libZoomMatrix,
[libCropView, libZoom, libZoomMatrix]
);
// Same transform on the touch layer. RN scales about the view's centre, so
// the translation carries a centre correction ((k-1)·centre).
const libCropTouchStyle = libCropView
? {
transform: [
{ translateX: libCropView.ox + (vw / 2) * (libCropView.k - 1) },
{ translateY: libCropView.oy + (vh / 2) * (libCropView.k - 1) },
{ scale: libCropView.k },
],
}
: undefined;
// Export-facing summary of the current framed-window photo reposition. The
// gesture state (libZoom) is canvas-absolute; u/v convert it to the fraction
@@ -2432,7 +2419,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{libraryImageUri && skiaImage ? (
<>
<Canvas style={StyleSheet.absoluteFill}>
<Group matrix={libZoomMatrix}>
<Group matrix={libViewMatrix}>
<ColorMatrix matrix={colorMatrix} />
{useShaderPass ? (
// DR/Highlight/Shadow tone + Cinema seasonal grade, both live
@@ -2513,7 +2500,7 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{renderVignette(frameRect.x, frameRect.y, frameRect.w, frameRect.h)}
{/* Frame + GPS watermark belong to the photo → zoom with it. Grain
is a fullscreen screen effect → stays fixed above. */}
<Group matrix={libZoomMatrix}>
<Group matrix={libViewMatrix}>
{renderFrameOverlay()}
{renderGPSWatermark()}
{renderCustomWatermark()}
@@ -2521,44 +2508,50 @@ const Viewfinder = forwardRef<ViewfinderHandle, ViewfinderProps>(function Viewfi
{/* Crop keep-rectangle: dim everything the export will drop and
outline what it keeps in amber. Drawn last so it masks every
overlay (frame, marks, grain) outside the crop. */}
{cropWinPx && (
{cropScreenPx && (
<Group>
<Rect x={0} y={0} width={vw} height={Math.max(0, cropWinPx.y)} color="#000000" opacity={0.55} />
<Rect x={0} y={0} width={vw} height={Math.max(0, cropScreenPx.y)} color="#000000" opacity={libCropView ? 1 : 0.55} />
<Rect
x={0}
y={cropWinPx.y + cropWinPx.h}
y={cropScreenPx.y + cropScreenPx.h}
width={vw}
height={Math.max(0, vh - cropWinPx.y - cropWinPx.h)}
color="#000000" opacity={0.55}
height={Math.max(0, vh - cropScreenPx.y - cropScreenPx.h)}
color="#000000" opacity={libCropView ? 1 : 0.55}
/>
<Rect
x={0}
y={cropWinPx.y}
width={Math.max(0, cropWinPx.x)}
height={cropWinPx.h}
color="#000000" opacity={0.55}
y={cropScreenPx.y}
width={Math.max(0, cropScreenPx.x)}
height={cropScreenPx.h}
color="#000000" opacity={libCropView ? 1 : 0.55}
/>
<Rect
x={cropWinPx.x + cropWinPx.w}
y={cropWinPx.y}
width={Math.max(0, vw - cropWinPx.x - cropWinPx.w)}
height={cropWinPx.h}
color="#000000" opacity={0.55}
/>
<Rect
x={cropWinPx.x}
y={cropWinPx.y}
width={cropWinPx.w}
height={cropWinPx.h}
color="#f59e0b"
style="stroke"
strokeWidth={2}
x={cropScreenPx.x + cropScreenPx.w}
y={cropScreenPx.y}
width={Math.max(0, vw - cropScreenPx.x - cropScreenPx.w)}
height={cropScreenPx.h}
color="#000000" opacity={libCropView ? 1 : 0.55}
/>
{/* Committed crop: the surround is opaque (it hides the photo
the transform pushed outside the ratio) and the amber
outline is gone — nothing left to frame. */}
{!libCropView && (
<Rect
x={cropScreenPx.x}
y={cropScreenPx.y}
width={cropScreenPx.w}
height={cropScreenPx.h}
color="#f59e0b"
style="stroke"
strokeWidth={2}
/>
)}
</Group>
)}
</Canvas>
<View
className="absolute inset-0"
style={libCropTouchStyle}
onStartShouldSetResponder={() => true}
onMoveShouldSetResponder={() => true}
onResponderGrant={onLibTouchStart}