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
+21 -16
View File
@@ -156,6 +156,11 @@ export default function App() {
// the Viewfinder lets the user drag; fixed ratios center-crop. Both are
// handed to the export so the file matches the preview.
const [cropRatio, setCropRatio] = useState<CropRatio>('none');
// CROP is a two-step tool: the ratio strip only FRAMES the band (amber
// outline + dim surround) until APPLY commits it, after which the viewer
// shows the crop alone. Holds the committed ratio, so picking another ratio
// (or RESET) drops straight back to the framing state.
const [cropApplied, setCropApplied] = useState<CropRatio | null>(null);
const [cropRect, setCropRect] = useState<CropRect>(DEFAULT_CROP_RECT);
const [rawEnabled, setRawEnabled] = useState(false);
// Full-frame metering mode (FOCUS → METERING MODE): highlight-weighted meters
@@ -174,15 +179,15 @@ export default function App() {
const meteringActive = mode === 'camera' && meterMode === 'highlight';
const meteringAdj: Partial<ColorAdjustments> | undefined = meteringActive
? {
exposureCompensation: clampAdj((adjustments.exposureCompensation ?? 0) + METER_HL_EV),
highlight: clampAdj((adjustments.highlight ?? 0) + METER_HL_HIGHLIGHT),
shadow: clampAdj((adjustments.shadow ?? 0) + METER_HL_SHADOW),
contrast: clampAdj((adjustments.contrast ?? 0) + METER_HL_CONTRAST),
clarity: clampAdj((adjustments.clarity ?? 0) + METER_HL_CLARITY),
}
: undefined;
// Same tonal overlay for the camera EXPORT (the EV dip is not inside — the
// hardware AE bias at capture already carries it, evFromCamera skips software
// EV, so folding the dip here would darken twice).
// Same overlay for the camera EXPORT — the EV dip included: EV is software
// only (no hardware AE bias anywhere), so preview and file must both apply it.
const renderAdj: ColorAdjustments = meteringAdj ? { ...adjustments, ...meteringAdj } : adjustments;
const rawFailRef = useRef(false);
// Progressive disclosure: a tool chip opens its panel; null = photo hero only.
@@ -301,6 +306,7 @@ export default function App() {
aspectRatio,
cropRatio,
cropRect,
cropApplied,
meterMode,
shutterSound,
selectedRecipeId: selectedRecipe?.id || null,
@@ -316,7 +322,7 @@ export default function App() {
}
});
return () => subscription.remove();
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, cropRatio, cropRect, meterMode, shutterSound]);
}, [mode, libraryImageUri, adjustments, selectedFrame, useGeotag, selectedRecipe, gpsInfo, customWm, gpsWm, aspectRatio, cropRatio, cropRect, cropApplied, meterMode, shutterSound]);
// Guards the camera-settings persist effect below: until initSession has
// read the stored values, changes are just the initial defaults — writing
@@ -405,6 +411,9 @@ export default function App() {
if (session.aspectRatio === 'full' || session.aspectRatio === '4:3' || session.aspectRatio === '3:2') setAspectRatio(session.aspectRatio);
if (session.cropRatio) setCropRatio(session.cropRatio);
if (session.cropRect && typeof session.cropRect === 'object') setCropRect({ ...DEFAULT_CROP_RECT, ...session.cropRect });
// Committed crop: only trust a value that still matches the restored
// ratio, so a stale snapshot can never show a crop it never framed.
if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);
// NOTE: meterMode/shutterSound/rawEnabled are NOT restored from the
// session snapshot here — the session file is only written on background,
// so it can be stale after a force-kill. CAMERA_SETTINGS_KEY above
@@ -540,6 +549,7 @@ export default function App() {
setPhotoStraighten(0);
setWallLandscape(false);
setCropRatio('none');
setCropApplied(null);
setCropRect(DEFAULT_CROP_RECT);
};
@@ -588,6 +598,7 @@ export default function App() {
// strip and lights the CROP chip on a frame the export never crops.
if (frameId !== 'none' && frameId !== 'classic-white' && frameId !== 'cinematic') {
setCropRatio('none');
setCropApplied(null);
setCropRect(DEFAULT_CROP_RECT);
}
if (selectedRecipe) {
@@ -923,14 +934,6 @@ export default function App() {
}
}
// Hardware AE bias for this shot (best-effort, bounded retries inside).
// Applied here — right before the capture — instead of live during EV
// slider drags, which re-locked AE on the Xiaomi and flashed preview.
try {
await viewfinderRef.current?.flushExposureBias();
} catch (e) {
console.warn('EV flush at capture failed:', e);
}
const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {});
const sourceUri = toUri(filePath);
@@ -971,12 +974,12 @@ export default function App() {
useGeotag,
};
const options = {
// 300dpi header + full-res screen sharpen. evFromCamera: the AE bias
// already applied exposure compensation at capture — don't add 2^EV.
// 300dpi header + full-res screen sharpen. No evFromCamera: the camera
// AE bias is never written, so the export applies the same software EV
// gain the preview shows.
// aspect: crop to the on-screen composition ratio (full = keep 4:3 native).
dpi: 300,
sharpen: true,
evFromCamera: true,
aspect: aspectRatio === 'full' ? undefined : aspectRatio,
watermark: wmForExport(),
watermarkRotation: wmRotation,
@@ -1286,11 +1289,11 @@ export default function App() {
rawEnabled={rawEnabled}
aspectRatio={aspectRatio}
cropRatio={cropRatio}
cropApplied={cropApplied}
cropRect={cropRect}
onCropRectChange={setCropRect}
onSessionError={handleCameraSessionError}
onExposureChange={(ev) => handleUpdateAdjustments({ exposureCompensation: ev })}
meteringEvOffset={meteringActive ? METER_HL_EV : 0}
meteringAdjustments={meteringAdj}
imageAdjustTarget={imageAdjustTarget}
customWm={customWm}
@@ -1349,6 +1352,8 @@ export default function App() {
selectedFrame={selectedFrame}
cropRatio={cropRatio}
onUpdateCropRatio={setCropRatio}
cropApplied={cropApplied}
onApplyCrop={() => setCropApplied(cropRatio)}
cropEnabled={
mode === 'library' &&
(selectedFrame === 'none' ||