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:
@@ -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' ||
|
||||
|
||||
Reference in New Issue
Block a user