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:
+157
-137
@@ -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
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user