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
+157 -137
View File
@@ -36,6 +36,12 @@ type GroupKey =
// Not a strip: the WATERMARK row REPLACES the frame chips (sub-panel).
| 'wm';
// The watermark's own option strips (TEXT COLOR / SIZE / FONT, GPS name+time).
// They hang OFF the sub-panel, so opening one must NOT close the sub-panel's
// chip row — that swap is what hid the edit chips behind the strip.
const WM_SUBGROUPS: GroupKey[] = ['wmColor', 'wmSize', 'wmFont', 'gpsColor', 'gpsName', 'gpsTime'];
const isWmPanel = (g: GroupKey | null) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g));
interface AdjustmentPanelProps {
activeTab: TabId;
recipes: Recipe[];
@@ -53,6 +59,10 @@ interface AdjustmentPanelProps {
// so App hides the chip everywhere else.
cropRatio: CropRatio;
onUpdateCropRatio: (ratio: CropRatio) => void;
// The ratio APPLY has committed; the strip offers APPLY only while the
// picked ratio is still uncommitted.
cropApplied?: CropRatio | null;
onApplyCrop: () => void;
cropEnabled?: boolean;
useGeotag: boolean;
// RAW (DNG sidecar) / aspect-ratio / metering-mode now live in the gear
@@ -205,6 +215,8 @@ export default function AdjustmentPanel({
selectedFrame,
cropRatio,
onUpdateCropRatio,
cropApplied,
onApplyCrop,
cropEnabled,
useGeotag,
openParam,
@@ -271,7 +283,7 @@ export default function AdjustmentPanel({
// The WATERMARK sub-panel is what arms the mark's drag layer on the image:
// one effect on the strip state keeps App's flag true exactly while it shows.
useEffect(() => {
onWmPanelChange(openGroup === 'wm');
onWmPanelChange(isWmPanel(openGroup));
}, [openGroup]);
// CROP is a plain-frame tool: the moment the selected frame drops it the chip
// unmounts, so the ratio strip must close with it (no orphan CROP row).
@@ -314,7 +326,13 @@ export default function AdjustmentPanel({
: 'border-zinc-700/60 bg-titan/80';
const textTone = c.active || c.amberValue ? 'text-amber-500' : 'text-zinc-300';
return (
<View key={c.key} className="mx-1">
<View
key={c.key}
className="mx-1"
onLayout={(e) => {
chipXRef.current[c.key] = e.nativeEvent.layout.x;
}}
>
{c.trash || c.star ? (
<View className={`flex-row items-center rounded-full border py-1.5 pl-3 pr-1 ${tone}`}>
<TouchableOpacity onPress={() => { haptic(); c.onPress(); }} activeOpacity={0.7}>
@@ -372,16 +390,14 @@ export default function AdjustmentPanel({
// Continuous parameter chips: label + live value; amber text when edited;
// amber border while its slider row is open.
const paramChips = (params: ParamDef[]) =>
params.map((p) =>
renderChip({
key: p.key,
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
active: openParam === p.key,
amberValue: openParam !== p.key && p.value !== p.default,
onPress: () => toggleParam(p.key),
})
);
const paramChips = (params: ParamDef[]): ChipDef[] =>
params.map((p) => ({
key: p.key,
label: `${p.label.toUpperCase()} ${p.display(p.value)}`,
active: openParam === p.key,
amberValue: openParam !== p.key && p.value !== p.default,
onPress: () => toggleParam(p.key),
}));
// Every tab carries the same RESET. It is pinned OUTSIDE the scroll row on
// purpose: the rows keep their scroll offset across tabs, so a chip inside
@@ -406,15 +422,13 @@ export default function AdjustmentPanel({
options: { v: string; d: string }[],
current: string,
onPick: (v: string) => void
) =>
options.map((o) =>
renderChip({
key: `${keyPrefix}:${o.v}`,
label: o.d,
active: current === o.v,
onPress: () => onPick(o.v),
})
);
): ChipDef[] =>
options.map((o) => ({
key: `${keyPrefix}:${o.v}`,
label: o.d,
active: current === o.v,
onPress: () => onPick(o.v),
}));
// ---- per-tab parameter definitions ---------------------------------------
// Static bounds/labels/accessors live in PARAM_DEFS (shared with App, which
@@ -590,13 +604,15 @@ export default function AdjustmentPanel({
// 'straighten' while its slider row is open, so that chip highlights.
value: openParam === 'straighten' ? 'straighten' : String(photoRotation),
options: [
// The slider entry leads: the strip starts at offset 0, and this is the
// only option here that opens an editor.
{ v: 'straighten', d: 'STRAIGHTEN (±45°)' },
{ v: 'reset', d: 'RESET' },
{ v: 'auto', d: 'AUTO' },
{ v: '0', d: '0' },
{ v: '90', d: '90' },
{ v: '180', d: '180' },
{ v: '270', d: '270' },
{ v: 'straighten', d: 'STRAIGHTEN (±45°)' },
],
onPick: (v) => {
if (v === 'reset') {
@@ -636,7 +652,7 @@ export default function AdjustmentPanel({
// slider row above (the chip keeps naming the kelvin value meanwhile).
temp: {
label: 'TEMP', off: 'auto', value: wbValue(),
options: [...WB_PRESETS, { v: 'color-temp', d: 'COLOR TEMP' }],
options: [{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS],
onPick: (v) => (v === 'color-temp' ? toggleParam('temperature') : wbApply(v)),
},
gpsTime: {
@@ -717,6 +733,39 @@ export default function AdjustmentPanel({
];
// ---- per-tab chip rows (bottom, above the tool rail) -----------------------
// The rail rows keep their scroll offset across tabs, so a chip that was
// edited can sit off-screen on the tab you just came back to: every row
// scrolls to its first EDITED chip (amber), or to the start when none is.
// Offsets come from onLayout — x is already the content coordinate scrollTo
// wants — and land one frame after the new row has laid out. The watermark
// sub-panel swaps the frame row for another one, so it re-aims too (it used
// to inherit the frame row's offset, which pushed its chips off-screen).
const chipsScrollRef = useRef<ScrollView>(null);
const chipXRef = useRef<Record<string, number>>({});
const firstAmberChipRef = useRef<string | null>(null);
useEffect(() => {
const id = setTimeout(() => {
const key = firstAmberChipRef.current;
const x = key ? chipXRef.current[key] ?? 0 : 0;
chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false });
}, 80);
return () => clearTimeout(id);
}, [activeTab, isWmPanel(openGroup)]);
const chipRow = (chips: ChipDef[], padV = 6) => {
firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null;
return (
<ScrollView
ref={chipsScrollRef}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}
>
{chips.map(renderChip)}
</ScrollView>
);
};
const chipsRow = (() => {
switch (activeTab) {
case 'recipes': {
@@ -724,32 +773,26 @@ export default function AdjustmentPanel({
// own), then the RECIPES toggle (expands the user/bundled recipe strip
// above) and CREATE (quick-input modal). Recipes themselves are no
// longer inline chips here.
const chips: ChipDef[] = [
groupChip('style'),
{
key: 'recipes-toggle',
label: 'RECIPES',
active: showRecipes,
onPress: () => {
setOpenGroup(null);
setShowRecipes((v) => !v);
return chipRow(
[
groupChip('style'),
{
key: 'recipes-toggle',
label: 'RECIPES',
active: showRecipes,
onPress: () => {
setOpenGroup(null);
setShowRecipes((v) => !v);
},
},
},
{
key: 'create',
label: 'CREATE',
active: false,
onPress: () => setCreateVisible(true),
},
];
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{chips.map(renderChip)}
</ScrollView>
{
key: 'create',
label: 'CREATE',
active: false,
onPress: () => setCreateVisible(true),
},
],
8
);
}
@@ -757,32 +800,27 @@ export default function AdjustmentPanel({
// FAVORITED: every starred recipe as a chip (star to un-star), newest
// look selectable in one tap. Ids whose recipe was deleted simply drop.
const favs = recipes.filter((r) => favoriteIds.includes(r.id));
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{favs.length === 0
? renderChip({
return chipRow(
favs.length === 0
? [
{
key: 'no-favorites',
label: 'NO FAVORITES YET - TAP THE STAR ON A RECIPE',
disabled: true,
active: false,
onPress: () => {},
})
: favs.map((r) =>
renderChip({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
star: true,
starred: true,
onPress: () => onSelectRecipe(r),
onPressStar: () => onToggleFavorite(r.id),
})
)}
</ScrollView>
},
]
: favs.map((r) => ({
key: r.id,
label: r.name,
active: r.id === currentRecipeId,
star: true,
starred: true,
onPress: () => onSelectRecipe(r),
onPressStar: () => onToggleFavorite(r.id),
})),
8
);
}
@@ -790,69 +828,34 @@ export default function AdjustmentPanel({
// LIGHT: continuous params on one row; D.RANGE is a toggle chip that
// opens its AUTO/DR100/DR200/DR400 strip above (same as the chrome
// groups on WB).
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{paramChips(paramDefs.iq)}
{renderChip(groupChip('dr'))}
</ScrollView>
);
return chipRow([...paramChips(paramDefs.iq), groupChip('dr')]);
case 'wb':
return (
<View>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
>
{renderChip(tempChip())}
{paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature'))}
{(['cx', 'cxb'] as const).map((g) => renderChip(groupChip(g)))}
</ScrollView>
</View>
);
return chipRow([
tempChip(),
...paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature')),
...(['cx', 'cxb'] as const).map(groupChip),
]);
case 'filters':
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{paramChips(paramDefs.filters)}
</ScrollView>
);
return chipRow(paramChips(paramDefs.filters));
case 'frame':
// FRAME → WATERMARK: the sub-panel row (breadcrumb back to the frames).
if (openGroup === 'wm') {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{renderChip({
key: 'wm-back',
label: '< FRAME',
active: false,
onPress: () => setOpenGroup(null),
})}
{wmChips().map(renderChip)}
</ScrollView>
// FRAME -> WATERMARK: the sub-panel row (breadcrumb back to the frames).
// It stays up while ANY watermark option strip is open — the strips are
// choices of the mark, not another panel.
if (isWmPanel(openGroup)) {
return chipRow(
[
{ key: 'wm-back', label: '< FRAME', active: false, onPress: () => setOpenGroup(null) },
...wmChips(),
],
8
);
}
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 8 }}
>
{choiceChips(
return chipRow(
[
...choiceChips(
'fr',
FRAMES.map((f) => ({ v: f.id, d: f.name.toUpperCase() })),
selectedFrame,
@@ -864,18 +867,32 @@ export default function AdjustmentPanel({
setOpenGroup(null);
onUpdateFrame(val as FrameId);
}
)}
{cropEnabled && renderChip(groupChip('crop'))}
{renderChip(rotateChip())}
{selectedFrame === 'wallframe' &&
renderChip({
key: 'wall-orient',
label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT',
active: wallLandscape,
onPress: onToggleWallLandscape,
})}
{renderChip(wmChip())}
</ScrollView>
),
...(cropEnabled ? [groupChip('crop')] : []),
// APPLY commits the framed ratio: the viewer swaps the band (amber
// outline + dim surround) for the crop alone. Only while a fixed
// ratio is framed but not committed — RESET or another ratio is
// the way back to the framing state.
...(cropEnabled &&
cropRatio !== 'none' &&
cropRatio !== 'free' &&
cropApplied !== cropRatio
? [{ key: 'crop-apply', label: 'APPLY', active: true, onPress: onApplyCrop }]
: []),
rotateChip(),
...(selectedFrame === 'wallframe'
? [
{
key: 'wall-orient',
label: wallLandscape ? 'WALL LANDSCAPE' : 'WALL PORTRAIT',
active: wallLandscape,
onPress: onToggleWallLandscape,
},
]
: []),
wmChip(),
],
8
);
default:
@@ -935,6 +952,7 @@ export default function AdjustmentPanel({
{openGroup && openGroup !== 'wm' && (
<View className="border-b border-zinc-800/60">
<ScrollView
key={openGroup}
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: 6 }}
@@ -946,7 +964,9 @@ export default function AdjustmentPanel({
active: false,
onPress: () => {},
})}
{choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick)}
{choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick).map(
renderChip
)}
</ScrollView>
</View>
)}
+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}