From 6faef4aef7e94303c79dd3c2d732992071e65a08 Mon Sep 17 00:00:00 2001 From: locphamtran Date: Fri, 11 Sep 2026 17:29:51 +0700 Subject: [PATCH] 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. --- App.tsx | 37 +- PLAN-2026-09-09.md | 612 +++++++++++++++++++++++++++++ src/components/AdjustmentPanel.tsx | 294 +++++++------- src/components/Viewfinder.tsx | 235 ++++++----- 4 files changed, 904 insertions(+), 274 deletions(-) create mode 100644 PLAN-2026-09-09.md diff --git a/App.tsx b/App.tsx index 01f3a71..f54474a 100644 --- a/App.tsx +++ b/App.tsx @@ -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('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(null); const [cropRect, setCropRect] = useState(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 | 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' || diff --git a/PLAN-2026-09-09.md b/PLAN-2026-09-09.md new file mode 100644 index 0000000..e6c8862 --- /dev/null +++ b/PLAN-2026-09-09.md @@ -0,0 +1,612 @@ +# PLAN — 7 hạng mục UI: PHOTO STYLE · tên sim · WATERMARK→FRAME · TEMP · FAVORITED · RESET* · ROTATE + +Nhánh: `feat/vision-camera-v5` (RecipesCam, `C:/Users/locpham/RecipesCam` ↔ `/home/locpham/RecipesCam`) +Ngày: 2026-09-09. Trạng thái: **HOÀN TẤT cả 7 hạng mục** — F2 `6adf0b5`, F1 `d66ae3d`, F4 `7ed4301`, F6 `ba7e579`, F5 `ba971f1`, F3 `4131c9f` + `dcb74a7`, đổi tên chip frame `6788d51`, F7 `9ddaeac` + `0ce561c`. Mỗi commit đều qua typecheck (14), `assembleRelease` và verify trên `emulator-5554`; `git status --short` sạch (chỉ còn file plan này, untracked). **Follow-up 4 lỗi hành vi strip ROTATE đã sửa, verify trên máy và commit `da48cec` — xem mục 12.** **Toàn bộ câu hỏi mục 10 đã chốt** (Q1 `CLASSIC CHRIPES` giữ nguyên, Q4 chip không kèm tên sim, Q6 strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` — AUTO ngay sau RESET, Q7 ★ chỉ recipe). +Đây là plan riêng, **không** gộp vào `PLAN.md` (đang là kế hoạch native export engine). + +Baseline typecheck: `node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit` → **14 lỗi nền** (không được tăng). + +--- + +## 0. Hiện trạng (mốc trong code) + +| Thứ | Nơi | +|---|---| +| Tabs + nhãn | `src/components/ToolRail.tsx:5` (`TabId`), `:15-22` (`TOOLS`), `:26` (FRAME chỉ ở library) | +| Chip theo tab | `src/components/AdjustmentPanel.tsx:492` `case 'recipes'`, `:548` `case 'wb'`, `:587` `case 'frame'`, `:617` `case 'watermark'` | +| Cơ chế strip 1-hàng | `GroupKey` `:22`, `openGroup` `:217`, `groupDefs` `:415`, `groupChip` `:481`, `choiceChips` `:321` | +| `renderChip` (đã có `amberValue`, `trash`, `disabled`) | `:365` | +| Sims | `src/utils/defaultRecipes.ts:39-52` (`FILM_SIMS`), `filmSimToRecipe` `:55` | +| WB presets | `AdjustmentPanel.tsx:118` (`WB_PAIRS`), `wbActive()`/`wbApply` `:~380` | +| Recipes lưu trữ | `src/utils/storageUtils.ts` (`@camrecipe_pro:custom_recipes`) | +| `Recipe` type | `src/types/index.ts:87` | +| Reset | `App.tsx:506` `handleResetAll`, prop `onResetAll` (`App.tsx:1277`) | +| Watermark state | `App.tsx:86-102` (`DEFAULT_CUSTOM_WM`, `wmRotation`, `DEFAULT_GPS_WM`), `wmEditing` `App.tsx:1235` | +| Xoay ảnh (sau F7) | `App.tsx`: `photoRotation: 0\|90\|180\|270` + `photoStraighten` + `autoHorizon`/`horizonRoll`; strip `AdjustmentPanel.tsx` `groupDefs.rotate`; `rotateSkImage90` + `rotateSkImageBy` `src/utils/skiaImage.ts`, dùng ở `exportEngine.ts` + `Viewfinder.tsx` | +| Frame | `src/utils/frameUtils.ts:9` (`FRAMES`), `App.tsx:547` `handleUpdateFrame` | + +Quy ước chung: file **CRLF** → sửa bằng python (`io.open(p, encoding='utf-8').read()` … ghi `newline='\r\n'`) hoặc `edit_file` cẩn thận; mọi lệnh shell prefix `export MSYS_NO_PATHCONV=1`; `PYTHONIOENCODING=utf-8` khi có non-ASCII. + +--- + +## 1. F1 — Gom sim vào chip `PHOTO STYLE` + +**Việc:** trong tab PRESETS, 8 chip sim (`FILM_SIMS.map`, `AdjustmentPanel.tsx:497`) không còn nằm trực tiếp trên hàng chip mà nằm trong strip của một group chip `PHOTO STYLE`. + +**Cách làm** +1. `GroupKey` thêm `'style'`; `groupDefs.style`: + - `label: 'PHOTO STYLE'`, `off: 'sim-provia'`, + - `value`: id sim đang chọn (`currentRecipeId` nếu là sim, ngược lại `''`), + - `options`: `FILM_SIMS.map(s => ({ v: s.id, d: s.name }))`, + - `onPick`: `setShowRecipes(false); onSelectRecipe(filmSimToRecipe(sim))` (giữ nguyên hành vi cũ). +2. `case 'recipes'` (`:492`): thay khối `...FILM_SIMS.map(...)` bằng `renderChip(groupChip('style'))`; giữ `RECIPES` + `CREATE`. +3. Nhãn chip: **chỉ `PHOTO STYLE`**, không kèm tên sim (đã chốt — TopBar góc trái đã hiện `activeRecipe.name`). `groupDefs.style.value` vẫn dùng để chip sáng amber khi sim ≠ mặc định, nhưng không in value ra nhãn. +4. `amberValue` bật khi sim đang chọn ≠ sim khởi động (`sim-provia`). + +**Kiểm chứng:** PRESETS chỉ còn `RESET · PHOTO STYLE · RECIPES · CREATE`; tap `PHOTO STYLE` → strip 8 sim; tap một sim → áp đúng look, chip đổi nhãn; luật "1 strip/lần" giữ; `RECIPES`/`CREATE` không đổi. + +--- + +## 2. F2 — Đổi tên sim + +`src/utils/defaultRecipes.ts:40-51`: + +| id | tên mới | +|---|---| +| `sim-provia` | `PROVIPES` | +| `sim-velvia` | `VELVIPES` | +| `sim-astia` | `ASTIPES` | +| `sim-classic-chrome` | `CLASSIC CHRIPES` | +| `sim-classic-neg` | `CLASSIC NEGIPES` | +| `sim-eterna` | `ETERNIPES` | +| `sim-acros` | `ACRIPES` | +| `sim-leica` | `LEITZ STREETLIFE` | + +- **Chỉ đổi `name`**, giữ `id` + `baseFilter` → phiên đã lưu (`selectedRecipeId`) và các `find(f => f.id === 'sim-provia')` (`App.tsx:349,507`) không gãy. +- Tên tự lan ra: chip PHOTO STYLE, TopBar (`activeRecipe.name`), strip recipe, picker simulation trong `RecipeCreateModal.tsx:330`. +- Dọn nhắc tên cũ trong text UI: `SettingsModal.tsx:135` ("camera + PROVIA …"); comment `App.tsx:194,332`, `RecipeCreateModal.tsx:27,152`. +- Recipe **bundled cũng đổi tên theo** (đã chốt): `defaultRecipes.ts` — `PROVIA STD` → `PROVIPES STD` (`:117`), `VELVIA VIVID` → `VELVIPES VIVID` (`:93`), `ACROS MONO` → `ACRIPES MONO` (`:141`), `CLASSIC NEG.` → `CLASSIC NEGIPES.` (`:69`). **Chỉ đổi `name`, giữ `id`** (`provia-default`, `velvia-default`, `acros-default`, `classic-neg-default`) → recipe đã lưu trong AsyncStorage vẫn trỏ đúng object. +- **Không** đổi: `RETRO AMBER 🏮` (`:165`) và `CINEMA SPRING/SUMMER/AUTUMN/WINTER` (`:190-265`) — không gắn sim nào. + +**Kiểm chứng:** top bar + chip + modal theo tên mới; look (matrix) không đổi. + +--- + +## 3. F3 — WATERMARK thành chip trong tab FRAME + +**Chặn:** FRAME hiện chỉ có ở library (`ToolRail.tsx:26`) → ở camera watermark sẽ mất lối vào. Chọn (Q3): +- **(A, đề xuất)** bỏ filter `:26` → FRAME có ở cả 2 mode; frame picker cũng dùng được ở camera (hiện camera chỉ nhận frame từ recipe). +- (B) giữ FRAME library-only + camera vẫn có tab WATERMARK riêng → không đúng yêu cầu, loại. + +**Kiểm chứng code — viewfinder camera có hiển thị đúng phần trong suốt của frame không? CÓ, không cần vẽ thêm gì.** +- `Viewfinder.tsx:1937` `renderCameraOverlays()` được gọi trong nhánh camera (`:2102`), ngay trên `` (`:2025`) — layer này là `StyleSheet.absoluteFill`, tức feed phủ full màn hình và nằm **dưới** overlay. +- `:1974` `renderFrameOverlay()` gọi **chung** cho mọi frame, không phân nhánh theo mode: + - `:1778` polaroid: chỉ vẽ bar đen ngoài card + card trắng quanh cửa sổ; **cửa sổ để trống** → feed lộ qua (comment `:1782-1787` nói đúng điều này). + - `:1817` wallframe: bar đen ngoài frame rect + PNG mat qua `drawMatrix`; **cửa sổ trong suốt của PNG** cho feed lộ qua (cover-crop giữa, WYSIWYG với crop export — comment `:1821-1824`). + - `:1839` classic-white / `:1852` cinematic: viền trắng / bar đen theo `frameRect` = `region` (band tỉ lệ) → đã đúng ở camera. +- `polaroidGeo` `:1124`, `wallGeo` `:1131` **không** phụ thuộc mode (chỉ `selectedFrame` + `vw/vh`) → bản camera dùng đúng rect đó; hai nhánh library riêng (`:2162`, `:2242`) nằm **sau** `if (mode === 'camera') { … return }` (`:1995`) nên không ảnh hưởng. +- `ToolRail.tsx:10-11` đã ghi sẵn lý do: FRAME library-only vì "camera shows every frame WYSIWYG through the viewfinder overlays". +- Kết luận: **F3 thuần là việc UI** (cho tab FRAME hiện ở camera + đổi điều kiện `wmEditing`); export camera cũng đã dùng `frameId: selectedFrame` (`App.tsx:~906`, `~995`) nên không lệch WYSIWYG. +- Hai điểm nhỏ, **không phải bug**, chỉ để biết: (1) band tỉ lệ chỉ áp cho none/classic-white/cinematic (`:255`) — polaroid/wallframe bỏ qua ratio ở camera đúng như thiết kế; (2) wallframe đợi `wallImage` load mới vẽ (`:1819`) → thoáng một nhịp thấy feed trần. + + +**Kiểm chứng code 2 — anchor/crop live feed vào đúng cửa sổ trong suốt: ĐÃ CÓ SẴN, không phải việc mới.** +- `Viewfinder.tsx:1201` `cameraPhotoRect` = `polaroidGeo.window` → `wallGeo.window` → `region` (band tỉ lệ) → `null`; `:1211` đẩy rect đó (chuẩn hoá theo `vw/vh`) vào `feedRectSync` `:791` `[active, viewAspect, fx, fy, fw, fh]`. +- Worklet `handleFrame` `:903-960`: khi `feed[0] > 0` thì black surface + **cover-map cảm biến vào ĐÚNG rect đó** (nghịch đảo CTM `renderToTexture` để lấy vùng raw frame), thay vì cover-fit fullscreen. Tức là cửa sổ trong suốt của overlay và vùng feed hiển thị dùng **cùng một rect** → khớp pixel, không cần anchor tay. +- Khớp với export: `exportEngine.ts:155` crop tỉ lệ chạy **trước** bước frame, nên ảnh vào bước polaroid đã đúng aspect cửa sổ (`:462-500` `drawImageRect` lấp kín window); wallframe `:541-570` cover-crop giữa vào window 2:3. Camera capture truyền `aspect: aspectRatio === 'full' ? undefined : aspectRatio` (`App.tsx:~917`) → cùng công thức. Kết luận: **WYSIWYG ở cả 4 frame, 0 dòng code thêm.** +- **Pinch/drag cửa sổ frame ở CAMERA — ĐÃ CHỐT thuộc F3** (bước 7). Hiện `zoomWindowRect()` `:1139-1140` trả `null` khi `mode !== 'library'` và `handleCapture` camera không truyền `frameWindowZoom` (`App.tsx:~917`) → không làm bước 7 thì ảnh polaroid/wall chụp từ camera luôn center-crop 1x (vẫn WYSIWYG, chỉ mất tự do bố cục). + +**Cách làm (A)** +1. `ToolRail.tsx`: xoá `{ id: 'watermark' }` khỏi `TOOLS` `:21`; xoá `'watermark'` khỏi `TabId` `:5` (grep hết chỗ dùng). +2. `AdjustmentPanel.tsx` `case 'frame'` `:587`: thêm `renderChip(groupChip('wm'))` nhãn `WATERMARK` (+ ` ON` khi gps/custom đang bật), `amberValue` khi có watermark bật. +3. Strip `wm` là **sub-panel**: khi `openGroup === 'wm'`, hàng chip chính được thay bằng hàng watermark (breadcrumb `‹ WATERMARK` + các chip hiện có ở `:617-660`: GPS ON/OFF, gpsColor/gpsName/gpsTime, CUSTOM ON/OFF, ROTATE, ACCEPT/CANCEL, wmColor/wmSize/wmFont). Giữ luật "1 strip/lần", không cần level-3. +4. Ô nhập `TYPE WATERMARK TEXT` (`:764`, điều kiện `activeTab === 'watermark'`) → đổi thành "sub-panel watermark đang mở". +5. `App.tsx`: lift cờ `wmPanelOpen` (state mới) + prop `wmPanelOpen`/`onToggleWmPanel`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (`:1235`) để drag/pinch watermark trên ảnh (`Viewfinder.tsx:1280,1285,1526`) chỉ arm khi sub-panel mở. +6. `useEffect` reset `openGroup` khi đổi tab (`:229`) đã có → giữ. +7. **Pinch/drag nội dung trong cửa sổ frame ở CAMERA** (đã chốt; phần nặng nhất của F3): + - `Viewfinder.tsx` `zoomWindowRect()` `:1138`: bỏ `if (mode !== 'library') return null` → camera cũng trả cửa sổ (`polaroidGeo.window` / `wallGeo.window`). + - `feedRectSync` `:791` payload 6 → 9 số: `[active, viewAspect, fx, fy, fw, fh, s, u, v]` (`u,v` = tâm nguồn 0..1, cùng ngữ nghĩa `frameWindowZoom` của export); map trong worklet `:903-960` nhân scale + offset trước khi cover-map. Hook đẩy rect `:1211` truyền thêm `s,u,v` từ state zoom camera. + - Clamp như `clampZoomToWindow` `:1176` để cửa sổ không lộ nền; đổi frame/ratio → reset `s=1, u=v=0.5`. + - `App.tsx` nhánh camera `:917`: thêm `frameWindowZoom: viewfinderRef.current?.getFrameWindowZoom?.() ?? null` (đã có sẵn cho nhánh library `:~1011`, `Viewfinder.tsx:153/569`). + - Cử chỉ: gesture layer camera `:2041-2048` đang là responder 1 chạm của `onImageDrag*` (AE/AF + kéo tham số) → chỉ nhận 2 ngón khi cửa sổ frame đang bật, và phải nhường ngón 1 cho AE/AF; **xung đột cần xử lý**: pinch cạnh tranh với zoom preset native (`applyZoom`, `:2125`) và kéo EV/AE. + - Rủi ro: worklet + cử chỉ → **TB–cao**; verify riêng trên máy trước khi commit. + +**Kiểm chứng:** camera + library đều thấy tab FRAME; chọn polaroid ở camera → vùng feed trong cửa sổ khớp đúng ảnh export (đối chiếu 1 ảnh chụp thật); pinch/drag được nội dung trong cửa sổ ở camera → ảnh export giữ đúng bố cục đã chọn (không lộ nền card/mat); ở camera chọn lần lượt `POLAROID`/`WALLFRAME`/`CLASSIC WHITE`/`CINEMATIC` → cửa sổ frame trong suốt, feed lộ đúng vị trí, không cần sửa lớp vẽ; FRAME → `WATERMARK` → strip watermark; bật CUSTOM → ô nhập hiện, kéo/thả watermark chạy; đóng panel → drag hết arm; RESET vẫn tắt mọi watermark. + +**Đã làm (commit 1 `feat(frame): move the watermark controls into a FRAME sub-panel`) — 3 file:** +- `ToolRail.tsx`: `TabId` bỏ `'watermark'`, `TOOLS` bỏ mục WATERMARK; bỏ prop `mode` + filter library-only của FRAME → rail giống nhau ở cả camera lẫn library. +- `AdjustmentPanel.tsx`: `GroupKey` thêm `'wm'` (không phải strip thường, chỉ là cờ mở hàng phụ); `paramDefs` bỏ `watermark`; `case 'frame'`: `openGroup === 'wm'` → hàng **phụ** (`‹ FRAME` + GPS/CUSTOM/ROTATE/ACCEPT/CANCEL/COLOR/SIZE/FONT) **thay** hàng frame, ngược lại hàng frame + chip cuối `WATERMARK` (`active` khi sub-panel mở, `amberValue` khi có watermark bật); xoá hẳn `case 'watermark'` và các chip watermark cũ trong đó → chuyển thành `wmChip()`/`wmChips()`; ô nhập `TYPE WATERMARK TEXT` đổi điều kiện thành `activeTab === 'frame' && openGroup === 'wm' && customWm.enabled`; `useEffect(() => onWmPanelChange(openGroup === 'wm'), [openGroup])` để App biết lúc nào arm lớp drag của dấu. +- `App.tsx`: state `wmPanelOpen`; `wmEditing={openTab === 'frame' && wmPanelOpen}` (thay `openTab === 'watermark'`); `onWmEdit` (double-tap trên dấu trong khung hình) đổi từ `setOpenTab('watermark')` → `setOpenTab('frame')`; truyền `onWmPanelChange={setWmPanelOpen}`. +- Không đụng `Viewfinder.tsx`: đường vẽ watermark/WYSIWYG frame ở camera vốn đã đúng, F3 chỉ là chuyện điều hướng UI. + +**Đã verify trên emulator-5554 (camera):** +- Rail: `PRESETS · FAVORITED · LIGHT · WB · FX · FRAME` — không còn WATERMARK. +- Hàng FRAME ở camera: `RESET · NO FRAME · CLASSIC BORDER · RETRO INSTANT · WALL FRAME · ROTATE n · WATERMARK` (CROP vắng mặt đúng thiết kế); `WATERMARK` → hàng phụ `RESET · ‹ FRAME · GPS WATERMARK OFF · CUSTOM WATERMARK ON · ROTATE n° · ACCEPT · CANCEL · TEXT COLOR · TEXT SIZE · TEXT FONT`; `‹ FRAME` quay lại đúng hàng frame. +- WYSIWYG frame ở camera (quét pixel cột x=540 và hàng y=1000): RETRO POLAROID mat trắng `(250,249,246)`, cửa sổ x 55..1025, y ~430..1730, feed lọt trong đúng cửa sổ; WALL FRAME bar đen `(0,0,0)` + mat xám `(91,91,91)`; CLASSIC BORDER viền trắng `(255,255,255)`; NO FRAME feed ra tới mép. +- Export giữ đúng bố cục: `camrecipe_pro_export_1789094424013.jpg` 960×1455, mat trắng `(250,249,245)` ngoài + nội dung camera trong; cửa sổ đo được 858×1146 → tỉ lệ **0,749** so với cửa sổ trên màn hình **0,746**. +- Arm đúng cả hai chiều: sub-panel **đóng** + có chữ → kéo trên preview đổi EV `0.0 → +1.3` (diff toàn màn 1.146.032 px); sub-panel **mở** → kéo (540,1100)→(320,600) để EV **nguyên +1.3**, diff chỉ 8.203 px so với nền nhiễu feed tự nhiên 11.591 px. +- **Watermark chữ tự nhập HIỆN đúng trong viewfinder camera**: đổi `TEXT COLOR → BLUE` rồi quét `#3b82f6` (±45/kênh) trong vùng feed → **571 px**, bbox `(543,1191)-(641,1212)`, ô lưới 60px tập trung ở (9,19)-(10,20) — đúng `wmAnchor()` (ax = 540, baseline ay ≈ 1212, glyph nằm trên baseline). +- **Ghi chú phương pháp — kết luận "không thấy watermark" trước đó là do đo sai, không phải bug, cũng không phải regression của F3:** so 2 ảnh chụp (bật/tắt CUSTOM, hoặc đổi nội dung chữ) không kết luận được vì (a) hiện/ẩn ô nhập làm preview đổi chiều cao → diff toàn màn hình, (b) feed camera tự nhiễu ~1,6–2,7 % pixel ở ngưỡng 30–45, (c) chữ amber `#f59e0b` trên feed ấm không tách được khỏi nền. Cách đúng: chọn một màu không có trong cảnh (`BLUE`) và đếm pixel đúng màu trên **một** ảnh. Cũng xác nhận: dấu vẫn được vẽ khi sub-panel đã đóng (`wmEditing = false` chỉ ảnh hưởng việc arm, không ảnh hưởng việc vẽ). +**Đổi tên chip frame (commit riêng `6788d51 feat(frame): rename the RETRO POLAROID chip to RETRO INSTANT`) — 3 file, chỉ 1 dòng code:** +- `src/utils/frameUtils.ts`: `FRAMES` — `{ id: 'polaroid', name: 'RETRO INSTANT' }` (id giữ nguyên `polaroid` → session cũ + `frameId` đã lưu không gãy). +- `src/components/Viewfinder.tsx` + `src/utils/exportEngine.ts`: chỉ sửa **comment** cho khớp tên mới (không đổi hành vi). + +**Đã làm (commit 2 `feat(frame): pinch and drag the framed window on the camera`) — 2 file:** +- `Viewfinder.tsx`: `zoomWindowRect()` bỏ `if (mode !== 'library') return null` → camera cũng trả `polaroidGeo.window` / `wallGeo.window`; band tỉ lệ vẫn trả band nhưng export không crop qua cửa sổ nên không đụng. `feedRectSync` payload 6 → 9 số `[active, viewAspect, fx, fy, fw, fh, z, u, v]`; effect đẩy rect đọc thêm `frameZoomRef.current` và phụ thuộc thêm `libZoom`. +- Worklet `handleFrame`: chỉ khi `z > 1.001` mới crop — `k = s·z`, `canvas.translate(u0 − k·zwX, v0 − k·zwY)` + `scale(k)`. Tâm `(u,v)` là **toạ độ view** nên **phải** đi qua `toRaw()` giống rect cửa sổ; đưa thẳng vào mặt phẳng raw thì **đảo trục** khi sensor xoay (đúng lỗi đã gặp). `z = 1` → tâm ép về giữa, trùng nhánh cũ. +- Cử chỉ camera: `onImageDragStart` seed `panRef`/`pinchRef`; `onImageDragMove` nhánh mới `g.kind !== 'wm' && !wmArmed && zoomWindowRect()` — 2 ngón → pinch quanh trung điểm + `clampZoomToWindow`; 1 ngón → chỉ khi `g.kind === 'focus'` **và** đã vượt `TAP_SLOP` mới pan (nhường ngón 1 cho AE/AF + kéo tham số); `onImageDragEnd` dọn cả ba ref. +- `useEffect([mode, selectedFrame])`: ở camera → `setLibZoom({ s: 1, tx: 0, ty: 0 })` (đổi frame hoặc vào lại camera thì bắt đầu từ 1x giữa). +- `App.tsx` nhánh capture camera: `const frameZoom = viewfinderRef.current?.getFrameWindowZoom?.() ?? null` đọc **lúc bấm shutter** (hàng đợi render chạy sau cú chụp kế tiếp nên không đọc lúc render), truyền `frameWindowZoom: frameZoom`. + +**Kiểm chứng commit 2:** theo quy trình mục 9 (typecheck 14 → `assembleRelease` → install `emulator-5554`) rồi thử cử chỉ trong cửa sổ POLAROID / WALL FRAME ở camera (pinch 2 ngón đổi tỉ lệ, pan 1 ngón dịch nội dung, đều bị kẹp trong cửa sổ) và đối chiếu ảnh export với preview; 1 ngón ngoài cửa sổ vẫn là AE/AF + kéo EV như cũ. + + +--- + +## 4. F4 — Gom WB vào chip `TEMP` + +**Việc:** các chip `AUTO…FLUOR` (`:558-570`) **và** chip tham số `COLOR TEMP` (`paramDefs.wb[0]`) rời hàng chip, vào strip của group chip `TEMP`; chip `TEMP` hiển thị giá trị đang chọn: `TEMP AUTO`, `TEMP 5000K`, `TEMP FLUOR`. + +**Cách làm** +1. `GroupKey` thêm `'temp'`; `groupDefs.temp`: + - `off: 'auto'`, + - `value`: `wbChoice` ≠ null → preset đó; ngược lại `` `${temperature}K` ``, + - `label: 'TEMP'` + value, + - `options`: 7 preset + mục `COLOR TEMP` đặc biệt, + - `onPick`: preset → `wbApply(v)`; `COLOR TEMP` → `toggleParam('temperature')` để mở hàng slider phía trên. +2. `case 'wb'` `:548`: bỏ `choiceChips('wb', …)` và chip `temperature` khỏi `paramChips(paramDefs.wb)` (`TINT` + `WB SHIFT` vẫn ở hàng chính); thêm `renderChip(groupChip('temp'))`. +3. `amberValue` khi WB ≠ neutral/AUTO. +4. **Edge:** kéo slider COLOR TEMP phải `setWbChoice(null)` để chip rơi về `TEMP xxxxK` (hiện `wbChoice` chỉ set trong `wbApply`). + +**Kiểm chứng:** WB chỉ còn `RESET · TEMP … · TINT · WB SHIFT · COLOR CHROME · CHROME BLUE`; `TEMP` → strip 7 preset + COLOR TEMP; chọn `FLUOR` → `TEMP FLUOR`; kéo slider → `TEMP 4000K`; chọn `AUTO` → `TEMP AUTO`, nhiệt 5500K/tint 0. + +--- + +## 5. F5 — Tab `FAVORITED` + +**Cách làm** +1. `src/utils/storageUtils.ts`: key `@camrecipe_pro:favorites` — `getFavoriteIds(): Promise`, `setFavorite(id, on)`. Dùng **danh sách id** (không dùng cờ trên `Recipe`) vì `DEFAULT_RECIPES` không persist → cờ trên object bundled sẽ mất. Dùng cho sim id, recipe bundled và recipe custom. +2. `App.tsx`: state `favoriteIds: string[]`, hydrate cùng `recipes`; `handleToggleFavorite(id)` (optimistic + ghi AsyncStorage); truyền `favoriteIds` + handler xuống panel. +3. `ToolRail.tsx`: thêm `{ id: 'favorites', label: 'FAVORITED' }` ngay sau `PRESETS` (rail là ScrollView ngang nên đủ chỗ). +4. `AdjustmentPanel.tsx`: `case 'favorites'` — chip cho từng recipe yêu thích (`label` = `name`, `active` = đang chọn, `onPress` = `onSelectRecipe`), rỗng → chip disabled `NO FAVORITES YET - TAP ★ ON A RECIPE`; kèm nút bỏ yêu thích. +5. Đánh dấu ★ **chỉ cho recipe** (đã chốt Q7 — sim không cần, `Recipe` đã lưu sim qua `baseFilter`): `renderChip` có tiền lệ nút phụ (`trash` `:373`) → thêm biến thể `star`/`starred` + `onPressStar`, dùng trên chip trong strip `RECIPES` và trong FAVORITED; **không** thêm ★ vào strip `PHOTO STYLE`. +6. `resetEditsToRecipe` / `handleResetAll` **không** đụng `favoriteIds`. + +**Kiểm chứng:** ★ một recipe → hiện trong FAVORITED; tắt app mở lại → còn nguyên; bỏ ★ → biến mất; tap chip → áp đúng look; tab rỗng hiện đúng empty state. + +**Đã làm (commit `feat(panel): add a FAVORITED tab with a star on every recipe`)** — 4 file: +- `src/utils/storageUtils.ts`: `FAVORITES_KEY = '@camrecipe_pro:favorites'`, `getFavoriteIds(): Promise` (guard `Array.isArray` + lọc string, lỗi → `[]`) và `setFavoriteId(id, on)` (đọc lại rồi ghi, dedupe bằng `Set`). +- `src/components/ToolRail.tsx`: `TabId` + `'favorites'`, chip `FAVORITED` ngay sau `PRESETS`. +- `src/components/AdjustmentPanel.tsx`: `TabId` + `'favorites'`; `ChipDef` thêm `star`/`starred`/`onPressStar`; nhánh cuối của `renderChip` thành `c.trash || c.star` với **hai nút phụ độc lập** (recipe custom giữ cả ★ lẫn 🗑); `paramDefs.favorites = []`; `case 'favorites'` (chip = `recipes.filter(r => favoriteIds.includes(r.id))`, rỗng → chip disabled); ★ trên mọi chip của strip `RECIPES`; prop `favoriteIds` + `onToggleFavorite`. +- `App.tsx`: state `favoriteIds: string[]`, hydrate trong `initSession`, `handleToggleFavorite(id)` (optimistic `setFavoriteIds` + `setFavoriteId(...).catch(warn)`), truyền xuống panel. ★ **không** nằm trong `handleResetAll`/`resetDirty` nên RESET không đụng tới. +Khác plan: hàm tên `setFavoriteId` (không phải `setFavorite`); empty state viết `NO FAVORITES YET - TAP THE STAR ON A RECIPE` thay ký tự `★`. + +**Đã verify trên emulator-5554:** rail có `FAVORITED`; ★ `VELVIPES VIVID` trong strip RECIPES → icon xám `(161,161,170)` 191 px chuyển amber `#f59e0b` 396 px, top bar **không** đổi look, `RESET` vẫn xám (không tính vào dirty); tab FAVORITED liệt kê đúng chip + ★ amber; tap chip → top bar đổi thành `VELVIPES VIVID`; bỏ ★ → chip biến mất, hiện empty state; `force-stop` rồi mở lại → vẫn còn (AsyncStorage + hydrate trong `initSession`); strip `PHOTO STYLE` **không** có nút phụ; recipe custom (tạo thử `FAVTEST`) có **2** nút phụ cạnh nhau (w=59 + 59) và 🗑 xoá sạch. + +--- + +## 6. F6 — `RESET *` + amber khi có thay đổi + +**Cách làm** +1. `renderChip` đã hỗ trợ `amberValue` (chữ amber, không viền) → `{ label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty }`. +2. `resetDirty` tính ở `App.tsx` (nơi có `DEFAULT_ADJUSTMENTS`, `DEFAULT_CUSTOM_WM`, `DEFAULT_GPS_WM`, `DEFAULT_CROP_RECT`) và truyền xuống prop mới `resetDirty: boolean` — tránh export thêm hằng số. +3. Định nghĩa dirty: `selectedRecipe?.id !== 'sim-provia'` ∨ adjustments khác `DEFAULT_ADJUSTMENTS` ∨ `selectedFrame !== 'none'` ∨ `customWm`/`customWmApplied` khác default ∨ `gpsWm` khác default ∨ `useGeotag` ∨ `wmRotation !== 0` ∨ `photoRotation !== 0` ∨ `wallLandscape` ∨ crop khác default. +4. **Đồng bộ (đã chốt):** mọi thứ tính vào dirty phải bị `handleResetAll` (`App.tsx:506`) xoá. Các mục watermark/rotate/frame đã bổ sung ở commit `7454ea1`; **còn thiếu `cropRatio` → `'none'` và `cropRect` → `DEFAULT_CROP_RECT`** → bổ sung, nếu không `RESET *` không bao giờ tắt. Đã chốt **giữ nguyên** `mode` + `aspectRatio` khi reset (không tính vào dirty). + +**Kiểm chứng:** mặc định → `RESET` xám; đổi 1 thông số bất kỳ (kể cả chỉ kéo slider, đổi frame, đổi WB) → `RESET *` amber; tap → mọi thứ về mặc định (`PROVIPES`) và chip về `RESET` xám. + +--- + +## 7. F7 — Chip `ROTATE`: 0/90/180/270 + xoay tay + auto chân trời + +**Việc (đã chốt):** chip ROTATE hiện là toggle `ROTATE 90`/`ROTATE 0` (`AdjustmentPanel.tsx:600`, `App.tsx:63`) → group chip mở strip: **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`** — AUTO dời lên **ngay sau RESET** (yêu cầu giữa lúc làm, khác thứ tự `… · AUTO` ghi ban đầu ở Q6). +- `0/90/180/270` = xoay nhanh (quarter turn); `STRAIGHTEN` = slider ±45° chỉnh tay; `AUTO` = cân theo chân trời. +- **Auto và tay kết hợp được**: `AUTO` lấy chân trời làm mốc, sau đó kéo `STRAIGHTEN` để tinh chỉnh trên mốc đó (góc hiệu dụng = auto + offset). +- `RESET` trong strip: `quarter = 0`, `straighten = 0`, `autoHorizon = false` (RESET toàn cục ở `App.tsx:506` cũng vậy). + +**Phần không thêm dependency** +1. `App.tsx`: `photoRotation: 0|90` → `photoRotation: 0|90|180|270` + `photoStraighten: number` (−45..45, offset tay) + `autoHorizon: boolean`; map session cũ (`photoRotation 90` → `90`). **Góc nghiêng hiệu dụng = `photoStraighten + (autoHorizon ? roll : 0)`** — dấu là **`+roll`** (plan ban đầu ghi `−roll` là SAI, đã sửa và kiểm chứng: nghiêng máy **+10°** → ảnh xuất quay **10° theo chiều kim đồng hồ**, xem mục verify bên dưới); auto làm mốc, slider là tinh chỉnh cộng thêm, tắt AUTO thì về đúng `photoStraighten`. `horizonRoll` đo từ `Accelerometer` là số nguyên độ (lượng tử 1°). +2. `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`); giữ `rotateSkImage90` cho quarter-turn. +3. Áp ở `exportEngine.ts:142` + `Viewfinder.tsx:330`: quarter trước, straighten sau, kèm scale bù để không lộ góc trống. +4. Chip `ROTATE` ghép nhãn `parts.filter(Boolean).join(' · ')` từ `['ROTATE '+photoRotation` (bỏ số khi `photoRotation === 0 && autoHorizon`, khi đó chỉ còn `ROTATE`), `autoHorizon ? 'AUTO' : null`, `photoStraighten ? ±deg : null]`. Nhãn thực tế đã verify: `ROTATE 0`, `ROTATE 180` (chỉ số), `ROTATE · AUTO`, `ROTATE 180 · AUTO`, `ROTATE · AUTO · +5.5°`; `amberValue` bật khi `photoRotation || photoStraighten || autoHorizon`. +5. `handleResetAll` → `photoRotation=0`, `photoStraighten=0`, `autoHorizon=false`, `horizonRoll=0`; chip `RESET` trong strip cũng tắt AUTO. `resetDirty` (F6) thêm `autoHorizon` để `RESET *` vẫn tắt được. + +**Phần auto chân trời (Q5: đồng ý thêm dep) — đã làm ở commit (2)** — `expo-sensors: ~57.0.2`, cài bằng `MSYS_NO_PATHCONV=1 node node_modules/expo/bin/cli install expo-sensors` (**`npx` hỏng từ bash → gọi thẳng `node_modules/expo/bin/cli`**); autolink qua `expo-modules-autolinking` (log build có `expo-sensors (57.0.2)`), **không cần plugin/quyền nào trong `app.json`**; **bắt buộc build lại native/APK**, JS reload không đủ. +- Đo roll bằng `Accelerometer` (không `DeviceMotion`), mẫu 100 ms, **chỉ subscribe khi `autoHorizon && mode === 'library'`**; bỏ mẫu khi `hypot(x,y) < 0.2` (máy nằm phẳng) hoặc `|góc| > 45` (dựng đứng) để level không đuổi theo nhiễu; `horizonRoll = Math.round(atan2(x, y) * 180/π)` (lượng tử 1°). +- Level line trên preview: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null`; vạch rộng 55% (4 px nền `rgba(0,0,0,0.5)` + 2 px nét), `transform: [{ rotate: autoRoll + 'deg' }]`, **amber `#f59e0b` khi `|autoRoll| <= 1`**, trắng khi lệch; vẽ ở **cả 3 nhánh library** (polaroid, wall, mặc định). +- Góc bake vào export đi **cùng một đường** với straighten tay: `effectiveStraighten = photoStraighten + (autoHorizon && mode === 'library' ? horizonRoll : 0)` → `exportOptions.photoStraighten` + prop `photoStraighten` của `Viewfinder`. +- Hệ quả (đã thực hiện): F7 **tách 2 commit** — (1) quarter + straighten tay (không dep), (2) auto chân trời (kèm dep + build native). + +**Kiểm chứng:** xuất ảnh ở 90/180/270 → kích thước pixel + hướng đúng; straighten ±5° nghiêng đúng chiều; không lộ góc trong; frame/crop khớp preview; auto chân trời: nghiêng máy ~10° → ảnh xuất ra thẳng; bật AUTO rồi kéo STRAIGHTEN ±2° → tổng góc = auto + offset; chọn `180` khi auto bật → ảnh vẫn thẳng (quarter + auto độc lập). + +**Đã làm (commit 1 `9ddaeac feat(frame): give ROTATE quarter turns plus a fine straighten`) — 5 file:** +- `src/utils/skiaImage.ts`: thêm `rotateSkImageBy(image, deg)` (surface đủ lớn + `canvas.rotate`, crop giữa giữ tỉ lệ gốc → không lộ góc trống); giữ `rotateSkImage90` cho quarter-turn. +- `App.tsx`: `photoRotation` mở rộng `0|90|180|270` + thêm `photoStraighten`; `handleResetAll` xoá cả hai; `resetDirty` tính thêm chúng. +- `src/utils/exportEngine.ts` + `Viewfinder.tsx`: quarter trước, straighten sau. +- `AdjustmentPanel.tsx`: `photoRotation` thành `groupDefs.rotate` (strip `RESET · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`, lúc này chưa có AUTO) + `openParam === 'straighten'` mở slider ±45°. + +**Đã làm (commit 2 `0ce561c feat(frame): level the photo to the horizon with AUTO`) — 5 file:** `App.tsx` (subscribe `Accelerometer` + `horizonRoll` + `effectiveStraighten`), `AdjustmentPanel.tsx` (chip `AUTO` ngay sau `RESET` + nhãn ghép + `amberValue`), `Viewfinder.tsx` (level line), `package.json`/`package-lock.json` (`expo-sensors`). Chi tiết ở các gạch đầu dòng phía trên. + +**Verify commit 2 trên `emulator-5554`** (điều khiển cảm biến bằng `adb -s emulator-5554 emu sensor set acceleration x:y:z`, mặc định `0:9.77631:0.812349`): +- Strip đúng thứ tự `ROTATE · RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)`; nhãn chip lần lượt `ROTATE 0` → `ROTATE · AUTO` → `ROTATE · AUTO · +5.5°` → `ROTATE 180 · AUTO`; `RESET *` amber ở mọi trạng thái đã đổi. +- Dấu `+roll` chứng minh bằng ảnh thật: nghiêng `(1.703, 9.661, 0.81)` (= +10°) → khớp nhất `PIL rotate(-10)` (MAD **1.18**, so với **47.9** khi không xoay); nghiêng ngược `(-1.703, …)` → `PIL rotate(+10)` (MAD 1.16) ⇒ đối xứng hai chiều. +- AUTO + STRAIGHTEN +5.5 → tổng **+15.5** (MAD 1.24); quarter `180` + AUTO → `base.rotate(180)` rồi model(−10°) (MAD 1.13) ⇒ quarter và auto độc lập, cộng được. +- Level line: roll 0 → ~594 px amber (55% của 1080) ở hàng y≈1199–1203; roll +10 → vạch dốc **9,93°** trên màn hình (đúng bằng góc nghiêng máy). +- `RESET` toàn cục và `RESET` trong strip đều tắt AUTO (chip về `ROTATE 0`, `RESET` hết amber). Cảm biến emulator đã trả về giá trị mặc định sau khi test. +- Lưu ý khi đo: **cài lại APK sẽ reset look/session của app** → luôn xuất lại ảnh nền (base) mới sau mỗi lần install trước khi so ảnh. + +--- + +## 8. Thứ tự thực hiện & commit + +| Phase | Hạng mục | Rủi ro | Ghi chú | +|---|---|---|---| +| A | F2 đổi tên → F1 PHOTO STYLE → F4 TEMP → F6 RESET* | thấp | thuần UI, 1 commit/hạng mục | +| B | F5 FAVORITED | thấp–TB | thêm storage key, không đổi shape dữ liệu cũ | +| C | F3 watermark → FRAME **+ pinch/drag cửa sổ frame ở camera** | TB–cao | đụng `TabId`, `wmEditing`, `feedRectSync`/worklet, cử chỉ 2 ngón; tách 2 commit: UI trước, pinch/drag sau | +| D | F7 rotate | cao | đổi kiểu state + đường export; **auto: `expo-sensors` + build lại native** | + +Mỗi hạng mục: sửa → typecheck (14) → build → install → verify trên máy → commit riêng (`feat(...)`: mô tả hành vi). F7 và F3 mỗi hạng mục tách 2 commit (F3: UI trước → pinch/drag camera sau; F7: manual trước → auto sau). + +--- + +## 9. Quy trình kiểm chứng (dùng lại) + +```bash +export MSYS_NO_PATHCONV=1 +cd /c/Users/locpham/RecipesCam +node --stack-size=16000 ./node_modules/typescript/lib/tsc.js --noEmit 2>&1 | grep -c "error TS" # = 14 +cd android && cmd /c gradlew.bat assembleRelease # ~1 phút +adb -s emulator-5554 install -r app/build/outputs/apk/release/app-release.apk +``` + +Trên máy: `uiautomator dump` lấy toạ độ (helper scratchpad `ui.py`), `screencap` + lấy màu nền chip ở `(x1−4, cy)` để phát hiện chip active (amber ≈ `(63,50,32)`/`(48,35,15)`, neutral ≈ `(31,31,35)`/`(27,27,30)`); tap chỉ ăn khi IME đã đóng (tap lần 2 sau khi gõ); toạ độ tool-rail đổi theo tab → luôn dump lại; `which`/`locale`/`sleep` không có → dùng `powershell.exe -Command "Start-Sleep -Seconds N"`. + +--- + +## 10. Câu hỏi cần chốt trước khi làm + +1. ~~`ASTIPCES` hay `ASTIPES`?~~ → **`ASTIPES`**; ~~`CLASSIC CHRIPES` hay `CLASSIC CHROMES`?~~ → **giữ `CLASSIC CHRIPES`**. +2. ~~Recipe bundled có đổi tên theo không?~~ → **Có**: `PROVIPES STD`, `VELVIPES VIVID`, `ACRIPES MONO`, `CLASSIC NEGIPES.` (giữ nguyên id). +3. ~~Cho tab FRAME hiện ở cả camera?~~ → **Có**, và **kèm pinch/drag cửa sổ frame ở camera** (F3 bước 7). +4. ~~Chip `PHOTO STYLE` có hiện tên sim không?~~ → **Không** (TopBar góc trái đã hiện tên). +5. ~~Cho phép thêm `expo-sensors`?~~ → **Có**, chấp nhận build lại native. +6. ~~Xoay tay gồm những gì?~~ → **`RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN ±45°`** (AUTO dời lên ngay sau RESET khi làm), auto + tay kết hợp được. +7. ~~FAVORITED gồm cả sim không?~~ → **Chỉ recipe** (sim đã nằm trong recipe qua `baseFilter`). +8. ~~RESET có reset crop không?~~ → **Có, reset `cropRatio`/`cropRect`**; mode + aspect **vẫn giữ** như hiện tại. + +--- + +## 11. Đối chiếu 7 hạng mục yêu cầu → commit + +| # | Yêu cầu | Commit | +|---|---|---| +| 1 | Gom sim vào chip `PHOTO STYLE` | `d66ae3d` | +| 2 | Đổi tên sim (kèm recipe bundled) | `6adf0b5` | +| 3 | `WATERMARK` → chip trong `FRAME` (+ pinch/drag cửa sổ frame ở camera) | `4131c9f` + `dcb74a7` | +| 4 | WB presets vào chip `TEMP` | `7ed4301` | +| 5 | Tab `FAVORITED` | `ba971f1` | +| 6 | `RESET *` amber khi có thay đổi | `ba7e579` | +| 7 | Chip `ROTATE` (0/90/180/270 · STRAIGHTEN ±45° · AUTO) | `9ddaeac` + `0ce561c` | + +Phát sinh trong lúc làm: đổi tên chip frame `RETRO POLAROID` → `RETRO INSTANT` (`6788d51`). + +--- + +## 12. Follow-up — 4 lỗi hành vi của strip `ROTATE` (commit `da48cec`) + +Bốn lỗi phát hiện sau khi F7 xong, sửa chung một commit `fix(frame): make the ROTATE strip behave on switching parameters`: `App.tsx` + `src/components/AdjustmentPanel.tsx`. + +| # | Lỗi | Nguyên nhân | Cách sửa | +|---|---|---|---| +| 1 | Sau khi dùng AUTO straighten, chuyển sang tham số khác thì hàng slider STRAIGHTEN vẫn còn | chip frame (`choiceChips('fr', …)`) và chip `RESET` chỉ gọi handler của chúng, không đóng `openParam` | chip frame gọi `if (openParam) onOpenParamChange(null)` trước `onUpdateFrame`; chip `RESET` thêm `setOpenGroup(null)` + đóng `openParam` trước `onResetAll` | +| 2 | Mở ảnh khác từ thư viện phải reset straighten + AUTO về mặc định | `handlePickImage` chỉ `setPhotoRotation(0)` | thêm `setPhotoStraighten(0)` + `setAutoHorizon(false)` + `setHorizonRoll(0)` | +| 3 | Trong straighten thủ công, nút `<` không hiện lại strip | `toggleParam` đóng `openGroup`, `<` gọi `toggleParam` nên strip biến mất | thêm map `PARAM_GROUP = { straighten: 'rotate', temperature: 'temp' }`; `<` gọi `toggleParam` rồi `setOpenGroup(PARAM_GROUP[key])` (set sau thắng) | +| 4 | Đang straighten thủ công, nhấn AUTO không có tác dụng | `effectiveStraighten = photoStraighten + roll`; roll≈0 nên bật AUTO không đổi gì | nhánh `auto` trong `groupDefs.rotate.onPick`: khi **bật** (`!autoHorizon`) gọi `onStraighten(0)` để ép về đường chân trời sensor; nhấn lại vẫn tắt AUTO như cũ | + +Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK rồi mở một ảnh thư viện): + +- Đặt STRAIGHTEN thủ công **+22°** → chip `ROTATE 0 · +22°`; nhấn **AUTO** → chip còn `ROTATE · AUTO`, mở lại slider thấy **0°** (item 4). +- Mở slider rồi nhấn chip frame **CLASSIC BORDER** → hàng slider STRAIGHTEN biến mất (item 1); nhấn `RESET` cũng đóng hàng slider và đưa về `ROTATE 0` (item 1, phần RESET). +- Nhấn `<` trong hàng slider → strip `RESET · AUTO · 0 · 90 · 180 · 270 · STRAIGHTEN (±45°)` hiện lại (item 3). +- Mở ảnh thứ hai từ thư viện khi AUTO đang bật → chip về `ROTATE 0` (straighten 0, AUTO tắt) (item 2). + +Ghi chú: AUTO vẫn tắt được bằng cách nhấn lại chính chip AUTO; strip `RESET` xoá cả turn + góc + auto. + +--- + +## 13. Follow-up — vạch amber của bubble level còn đọng trên ảnh (commit `d6b3ebd`) + +Yêu cầu: sau khi nhấn `ROTATE` → `AUTO`, chuyển sang chip khác thì vạch amber vẫn nằm trên ảnh. + +Đó **không phải** hàng slider STRAIGHTEN mà là vạch bubble level vẽ đè lên ảnh trong `Viewfinder` (`levelOverlay`, hiện khi `autoRoll != null`, amber khi `|roll| <= 1`). Nguyên nhân: `autoRoll = autoHorizon && mode === 'library' ? horizonRoll : null` chỉ phụ thuộc AUTO, không biết strip `ROTATE` đã đóng hay chưa. + +| # | Lỗi | Cách sửa | +|---|---|---| +| 1 | Vạch amber đọng lại sau khi rời ngữ cảnh `ROTATE` | `App.tsx`: thêm `rotatePanelOpen`; `autoRoll` chỉ khác `null` khi `autoHorizon && mode === 'library' && openTab === 'frame' && rotatePanelOpen` | +| 2 | App không biết strip `ROTATE` đang mở | `AdjustmentPanel.tsx`: thêm prop `onRotatePanelChange`, effect `onRotatePanelChange(openGroup === 'rotate' \|\| openParam === 'straighten')` deps `[openGroup, openParam]` | +| 3 | Chip frame khác không đóng strip `ROTATE` | nhánh `choiceChips('fr', …)` thêm `setOpenGroup(null)` cạnh `onOpenParamChange(null)` sẵn có | + +Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, mở một ảnh thư viện, script `verify3.py`): + +- Đối chứng dương: strip mở + `AUTO` bật (`ROTATE · AUTO`) → dò được vạch amber ở hàng **y 1199–1203**, rộng **594 px**. +- Nhấn chip frame `NO FRAME` khi AUTO đang bật → strip đóng, hàng amber **`[]`**. +- Nhấn tab rail `LIGHT` → hàng amber **`[]`**; quay lại `FRAME` → vẫn **`[]`** (strip đóng nên vạch không tự hiện lại). + +Ngoài lề: `AUTO` chỉ là bubble level theo cảm biến gia tốc (`Accelerometer`), không dò đường chân trời trong ảnh, nên không nắn được đường chân trời nghiêng sẵn trong ảnh. + +--- + +## 14. Bỏ hẳn AUTO theo cảm biến, thay bằng auto-straighten dò đường chân trời trong ảnh + +Yêu cầu: EXIF ảnh trong app không lưu roll lúc chụp, nên AUTO theo gia tốc chỉ đúng khi đang chụp chứ vô nghĩa với ảnh mở từ thư viện. Bỏ hẳn cảm biến, tự dò độ nghiêng từ chính ảnh. + +| # | Việc | Nơi | +|---|---|---| +| 1 | Thêm bộ dò nghiêng | file mới `src/utils/horizon.ts`, `detectTilt(image: SkImage \| null, maxAngle = 45): number \| null` | +| 2 | Bỏ cảm biến, AUTO lấy từ ảnh | `App.tsx`: xoá import `expo-sensors`, state `autoHorizon`/`horizonRoll` + effect đọc `Accelerometer`, `effectiveStraighten`; `handleAutoStraighten` = `const tilt = viewfinderRef.current?.detectHorizon?.() ?? null; if (tilt == null) return; setPhotoStraighten(-tilt);` | +| 3 | Bỏ vạch bubble level | `Viewfinder.tsx`: xoá prop `autoRoll` + overlay; thêm `detectHorizon()` vào `ViewfinderHandle` (chạy `detectTilt(applyPhotoRotation(src.image, src.quarter, 0))` trên `horizonSrcRef`) | +| 4 | Chip | `AdjustmentPanel.tsx`: `autoHorizon`/`onAutoHorizon` → `onAutoStraighten`; nhánh AUTO gọi `onAutoStraighten()`; hàng slider `STRAIGHTEN` hiện `min -45 max 45 step 0.5` và đọc số đo | + +Chi tiết đã bỏ: prop `onRotatePanelChange` (mục 13 không còn cần vì không còn vạch đè lên ảnh), cặp `setAutoHorizon(false); setHorizonRoll(0);` trong `handleResetAll`. + +**Thuật toán `detectTilt` (shear projection).** Thuật toán ban đầu (histogram hướng gradient) sai với cạnh thật: đường 7° đi từng bậc 8 px nên gradient chỉ rơi vào bin 0 (đoạn phẳng) và 45 (bước nhảy) — mọi góc nhỏ đều đọc ra 0 (kiểm chứng số: app bin 0/ratio 0.207, mô phỏng 2-tap bilinear cho đúng (0, 0.206, 133044)). Cách làm đúng: + +1. Hạ mẫu chính xác 2× liên tục đến khi cạnh dài ≤ 256 (`Skia.Surface.Make` + `canvas.drawImageRectOptions(..., FilterMode.Linear, MipmapMode.Linear)` + `makeImageSnapshot`) — hạ một lần >2× sẽ alias. +2. `readPixels()` → mảng xám; liệt kê điểm cạnh `mag = sqrt(dx²+dy²)`, bỏ qua `mag < 20`, toạ độ x trừ tâm, bỏ nếu < 32 điểm. +3. `score(deg)` = cắt xiên theo phương pháp profile: `acc[round(py - px*tan(deg)) + limit] += pw`, trả `ss/(tot*tot)` (độ "nhọn" của profile). +4. Quét thô `-45..45` bước 1°, cổng tin cậy `bestScore < 3 * median → null`, rồi tinh chỉnh bước 0.5° trong `best-1..best+1`; trả về độ lẻ. + +Dấu: `rotateSkImageBy` dùng `canvas.rotate(deg)` (dương = cùng chiều kim đồng hồ, y hướng xuống) nên `photoStraighten = -tilt`. + +Kiểm chứng trên `emulator-5554` (typecheck vẫn **14**, `assembleRelease` OK, cài lại APK, script `autocheck.py` / `expexport.py`): + +- Ảnh nghiêng 7° → STRAIGHTEN **-7°**; ảnh 0° → **0°**; ảnh 12° có cấu trúc dọc → **-12°**; ảnh nhiễu đồng nhất (không có đường) → **0°** (cổng tin cậy chặn đúng). +- Export thật (không chỉ đọc slider): AUTO trên ảnh nghiêng 12°, SAVE, đo lại đường chân trời trong file xuất → **-0.85°**. Đo cùng thước đo trên ảnh gốc cho **-0.0° / 7.0° / 12.0°** (trên 1200 cột) ⇒ thước đo đủ nhạy, -0.85° là ảnh đã được nắn thật. + +Ghi chú: `expo-sensors` vẫn còn trong `package.json` (không dùng nữa) — cố ý không sửa deps. + +## 15. Điều tra "load camera bị chậm" + +Kết luận: **không có nút thắt nào trong code app**. Số đo trên `emulator-5554`, APK release tại `1b632ba`, **không** instrument (đo bằng logcat native + `screencap`): + +Cold start (`am force-stop` → `am start`, mốc 0 = dòng `START u0`): + +| Mốc | Thời điểm | +|---|---| +| `Displayed com.locphamtran.recipescamera` | 0.5 – 1.0 s | +| `HybridCameraSessionSpec: Reconfiguring CameraSession` | 1.2 – 1.7 s | +| `Camera #0 State changed! Type: OPEN` | 1.7 – 2.6 s | +| Preview sống hiện trên màn hình | **~2.5 s** | + +Preview đo bằng cách chụp màn hình tại các mốc cố định rồi tính độ sáng trung bình (54×120, thang xám): 1.5 s → mean 2.9; 2.0 s → 3.5 (đen); 2.5 s → 39.7 (có nội dung). Kết quả lặp lại y hệt sau `adb reboot`. + +Resume từ background (HOME → mở lại): `START u0` → `Camera #0 OPEN` = **282 ms**. + +**Vì sao các lần đo trước ra 5–7 s:** những lần đó chạy ngay sau `gradlew assembleRelease` (host bận + dexopt nền của APK mới). Chính APK đó, đo lại 12+ lần cold start (kể cả sau `adb reboot` và `install -r`) đều 1.7–2.6 s. Log của các lần chậm có `Davey! duration=2803ms` / `Choreographer Skipped 188 frames` ⇒ main thread bị đói; `session.configure()` của VisionCamera chạy trên `Dispatchers.Main` nên bị xếp hàng sau frame dài, kéo `Camera #0 OPEN` ra ~7 s. Bản thân đường camera rất nhanh: `createCameraSession` ~10 ms, thân `configure` ~25 ms. + +⇒ Không sửa code. Instrumentation tạm đã gỡ sạch (`git checkout -- App.tsx index.ts src/components/Viewfinder.tsx`, khôi phục `useCameraSession.ts` / `useCameraController.ts` từ `.bak` trong `node_modules`, xoá `.bak`). + +Ghi chú không sửa: APK release **172 MB**, trong đó 4 ABI (`x86` 41 MB, `x86_64` 41 MB, `arm64-v8a` 40 MB, `armeabi-v7a` 28 MB). Chỉ ảnh hưởng thời gian cài/first-run trên emulator, không phải thời gian load camera. + +Script đo: `cold.py` (timeline logcat) và `onset.py` (độ sáng màn hình) trong scratchpad. + +## 16. Sửa: preview khác ảnh export ở chế độ đo sáng Highlight-weighted + +**Triệu chứng (người dùng):** ở chế độ CAMERA với METERING MODE = HIGHLIGHT-WEIGHTED, lúc bấm máy ngắm sáng bừng lên toàn khung, rồi ảnh xuất ra không khớp với những gì đã thấy trên ngắm. + +**Nguyên nhân:** chế độ highlight cộng thêm **-0,5 EV** (`METER_HL_EV`). EV này được đẩy xuống **hardware AE bias** ngay trước khi chụp (`flushExposureBias()` trong `handleCapture`), còn export thì bỏ qua EV phần mềm (`evFromCamera: true`) vì tin rằng bias phần cứng đã gánh EV. + +- `Viewfinder.flushExposureBias()` gọi `setHwBiasStops(clamped / 8)` **trước khi** `setExposureBias()` thực sự landed. Preview render `evTarget − hwBiasStops`, nên ngay khi bấm máy gain phần mềm tụt từ -0,5 EV về 0 → cả khung sáng lên đúng 0,5 EV (= cái "flash" người dùng thấy). +- Bias phần cứng landed bất đồng bộ (một số máy/emulator không hề tôn trọng nó), nên JPEG có thể được chụp với EV = 0 → ảnh sáng hơn ngắm ~0,5 EV (+41% tuyến tính). + +**Cách sửa — EV thuần phần mềm, bỏ hẳn hardware bias:** + +| | Trước | Sau | +| --- | --- | --- | +| Preview EV | `clamp(user EV + metering offset) − hwBiasStops` | `clamp(adjustments.exposureCompensation)` | +| Export EV | bỏ qua (`evFromCamera: true`), trông vào AE bias | gain `2^EV` như preview (không còn `evFromCamera`) | +| Hardware AE bias | ghi lúc bấm máy | **không bao giờ ghi** | +| EV dip của highlight | prop `meteringEvOffset` riêng + `METER_HL_EV` | nằm luôn trong `meteringAdj.exposureCompensation` (dùng chung cho preview và export) | + +Xoá hẳn: `ViewfinderHandle.flushExposureBias()`, state `hwBiasStops`, `evStopsRef`, `meteringEvOffsetRef`, effect cảnh báo `supportsExposureBias`, prop `meteringEvOffset`, và lời gọi trong `handleCapture`. `evFromCamera` vẫn còn trong `exportEngine`/`nativeExport` nhưng không đường nào của app truyền nữa. Net **−71 dòng** (`App.tsx`, `src/components/Viewfinder.tsx`). + +**Đo trên `emulator-5554`, APK release, màn 1080×2400.** Vùng ảnh của preview xác định được bằng profile độ sáng: ảnh hiển thị vừa khung (*contain*) 1080×1440 tại `y 480..1920`, letterbox đen hai đầu (đúng 1280 × 1080/960 = 1440), nên so sánh được cùng trường nhìn: preview `[480:1920, 0:1080]` ↔ export 960×1280. + +Độ sáng trung bình vùng ảnh của preview, highlight-weighted, một lần bấm máy: + +| Mốc | pre | ngay sau bấm | +0,35 s | +1,35 s | lúc đã settle | +| --- | --- | --- | --- | --- | --- | +| Lần 1 | 46,40 | 46,74 | 46,68 | 46,59 | 46,80 | +| Lần 2 | 60,65 | 60,41 | 60,37 | 60,23 | 60,62 | + +Không còn nhảy sáng (dao động ±0,7%, dưới mức nhiễu của phép đo). + +Tỉ lệ export/preview (E/P) cùng trường nhìn: + +| Chế độ | preview | export | E/P | +| --- | --- | --- | --- | +| EVALUATIVE | 100,02 | 101,62 | **1,0160** | +| HIGHLIGHT-WEIGHTED (lần 1) | 60,38 | 62,34 | **1,0325** | +| HIGHLIGHT-WEIGHTED (lần 2) | 60,65 | 62,23 | **1,0260** | + +Hai chế độ lệch nhau 1,6% ≈ 0,023 EV (độ lệch hệ thống nhỏ của JPEG/resample). Nếu export vẫn bỏ EV như code cũ, E/P của highlight phải lớn hơn eval ≈ 1,414 lần (+41%). Sai số từng pixel `mean|diff|` ≈ 8,7–9,3 (thang 0–255). + +**Kiểm tra:** `tsc --noEmit` vẫn **14 lỗi** như baseline (không thêm lỗi nào). File test trên máy đã xoá (3 JPEG export + 3 dòng MediaStore 1000000266–268 + `/sdcard/ui.xml`); DCIM còn đúng 5 ảnh `IMG_*` gốc. + +Script đo: `measure.py` (chụp màn hình trước/sau khi bấm máy + pull JPEG export), `align.py` (dò căn chỉnh preview↔export) trong scratchpad. + +## 17. Sửa 3 lỗi hành vi chip/strip: EV bubble · cuộn chip đã sửa · strip làm mất chip + +Ba lỗi người dùng báo, đã sửa, build release, cài lên `emulator-5554` và đo lại. + +### 17.1 EV bubble hiện EV đã gộp thay vì EV người dùng nhập + +**Triệu chứng:** tab LIGHT để EV = 0 nhưng bubble quick-EV trên màn hình hiện **EV −0,5**. + +**Nguyên nhân:** `src/components/Viewfinder.tsx` (~dòng 504) đọc `adjustments.exposureCompensation`. Biến `adjustments` (~dòng 362) là `{...recipe.adjustments, ...meteringAdjustments}`, nên ở chế độ HIGHLIGHT-WEIGHTED nó đã cộng sẵn offset −0,5 EV của metering. Bubble/thumb/điểm neo kéo đều lấy từ đây → hiện `userEV − 0,5`. + +**Sửa:** cho phần hiển thị đọc thẳng `recipe.adjustments.exposureCompensation`; gain áp dụng vẫn dùng `adjustments.exposureCompensation` như cũ. + +```tsx +const evStops = recipe.adjustments.exposureCompensation ?? 0; +``` + +| | Trước | Sau | +| --- | --- | --- | +| Bubble / thumb / điểm neo kéo | `adjustments.exposureCompensation` (đã gộp metering) | `recipe.adjustments.exposureCompensation` (EV người dùng) | +| Gain áp dụng vào ảnh | `clampEvApplied(adjustments…)` | không đổi | + +**Đo trên máy** (HIGHLIGHT-WEIGHTED bật, đã chứng minh có áp dụng: độ sáng trung bình preview 92,74 → 58,47): + +| Thao tác | Bubble | Chip LIGHT | +| --- | --- | --- | +| Không kéo | `EV 0.0` tại `[687,1049][797,1095]` | `EV 0.0 EV` | +| Kéo +130 px | `EV +0.8` | `EV +0.8 EV` | +| Kéo về | `EV 0.0` | `EV 0.0 EV` | + +Code cũ khi kéo +130 px chỉ ghi `+0.3` (bị trừ 0,5). Kéo về 0 khôi phục đúng. + +### 17.2 Nhấn tab trên rail phải nhảy tới chip đầu tiên đã sửa + +**Yêu cầu:** nhấn tab → cuộn tới chip đầu tiên có thông số đã sửa; nếu tab không có chip nào sửa thì về chip đầu tiên. + +**Sửa trong `src/components/AdjustmentPanel.tsx`:** thêm hằng số cấp module để nhận diện cả 5 strip con của watermark là "cùng một panel": + +```tsx +const WM_SUBGROUPS: GroupKey[] = ['wmColor','wmSize','wmFont','gpsColor','gpsName','gpsTime']; +const isWmPanel = (g) => g === 'wm' || (g !== null && WM_SUBGROUPS.includes(g)); +``` + +Toàn bộ khối `switch (activeTab)` dựng hàng chip (~dòng 719–880) được viết lại quanh một helper duy nhất: + +```tsx +const chipsScrollRef = useRef(null); +const chipXRef = useRef>({}); +const firstAmberChipRef = useRef(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 ( + {chips.map(renderChip)}); +}; +``` + +- Mỗi `case` trả về `chipRow([...định nghĩa], 8|6)` (recipes/favorites/frame giữ padding 8, iq/wb/filters 6 — đúng như cũ). Bỏ `` bọc vô nghĩa quanh hàng WB. +- `renderChip` thêm `onLayout` trên View ngoài: `chipXRef.current[c.key] = e.nativeEvent.layout.x`. +- `paramChips` và `choiceChips` đổi kiểu trả về thành `ChipDef[]` (trước trả ReactElement); call site duy nhất còn lại thành `{choiceChips(openGroup, …).map(renderChip)}`. +- Độ trễ 80 ms vì hàng chip vừa remount khi đổi tab, `scrollTo` ngay sẽ trượt. + +**Đo trên máy:** + +| Kịch bản | Kết quả | +| --- | --- | +| LIGHT mới, chưa sửa gì | offset 0, `RESET` đầu hàng | +| Đặt COLOR +7, sang WB rồi quay lại LIGHT | `COLOR +7` nằm tại `[246,1921][384,1960]` | +| WB (không có chip nào sửa) | offset 0 | +| FRAME khi watermark đang ON | cuộn tới `WATERMARK ON` tại `[783,1916][1017,1955]` | + +### 17.3 Strip của watermark làm cả hàng chip biến mất + +**Triệu chứng:** đang ở chip watermark, bấm sang chip kế tiếp thì chỉ thấy strip, không thấy chip nào; phải bấm vào strip thì chip mới hiện. + +**Nguyên nhân:** hàng chip con của watermark trong `case 'frame'` bị gác bằng `if (openGroup === 'wm')`, nên mọi strip con (`wmColor`, `wmSize`, `wmFont`, `gpsColor`, `gpsName`, `gpsTime`) đều làm hàng chip gỡ khỏi cây. + +**Sửa:** + +- Cổng hiển thị `if (openGroup === 'wm')` → `if (isWmPanel(openGroup))` — hàng chip ở lại với mọi strip của watermark. +- `onWmPanelChange(openGroup === 'wm')` → `onWmPanelChange(isWmPanel(openGroup))` — giữ lớp bắt kéo ảnh. +- ScrollView của strip thêm `key={openGroup}` để remount về offset 0 mỗi nhóm. +- Strip `ROTATE`: chuyển `{ v: 'straighten', d: 'STRAIGHTEN (±45°)' }` lên **đầu** (trước bị cắt ở x≈1058). +- Strip `TEMP`: đổi option thành `[{ v: 'color-temp', d: 'COLOR TEMP' }, ...WB_PRESETS]`. + +**Đo trên bản build cuối:** + +| Kịch bản | Chip còn hiển thị? | Strip | +| --- | --- | --- | +| FRAME › WATERMARK › CUSTOM WATERMARK ON › TEXT COLOR | Có: `RESET / CUSTOM WATERMARK ON / ROTATE / ACCEPT / CANCEL / TEXT COLOR` | AMBER WHITE BLACK RED BLUE | +| TEXT SIZE | Có, nguyên vẹn | cỡ chữ | +| GPS ON › TIME | Có, nguyên vẹn | định dạng giờ | +| ROTATE | Có | `STRAIGHTEN (±45°)` tại `[258,1805][534,1844]`, một tap mở `STRAIGHTEN 0°` | +| TEMP | Có | `COLOR TEMP` đầu tiên tại `[233,1815][418,1854]` | + +Vào panel con từ hàng frame đã cuộn giờ nhắm lại offset 0 (`< FRAME` đầu hàng) thay vì thừa hưởng offset của hàng frame. + +### 17.4 Kiểm tra + +- `tsc --noEmit` (`node --stack-size=16000 ./node_modules/typescript/lib/tsc.js`) vẫn **14 lỗi** như baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`); `AdjustmentPanel.tsx` 0 lỗi. +- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success; khởi động lại app OK. +- Trạng thái máy đã reset: RESET chip đã bấm (GPS watermark OFF, custom watermark OFF, frame NO FRAME, COLOR 0, straighten 0). LIGHT: `RESET / EXPOSURE 0 / EV 0.0 EV / CONTRAST 0 / COLOR 0`; WB: `RESET / TEMP AUTO / TINT 0 / COLOR CHROME / CHROME BLUE`. Metering về EVALUATIVE (mặc định lúc khởi động). +- File test trên máy đã xoá (`/sdcard/*.xml` của phiên này). +- Diff chưa commit: `App.tsx`, `src/components/AdjustmentPanel.tsx`, `src/components/Viewfinder.tsx` + file kế hoạch này. + +## 18. CROP theo tỉ lệ: nút APPLY, ẩn viền amber, RESET về mặc định + +**Yêu cầu:** sau khi CROP với ratio thì cho phép APPLY; sau khi APPLY chỉ hiển thị ratio đó và ẩn viền amber; nhấn RESET thì trả ảnh về ratio mặc định lúc load. + +**Hai trạng thái:** + +| Trạng thái | Điều kiện | Preview | +| --- | --- | --- | +| Đang frame (band) | `cropRatio` là tỉ lệ, `cropApplied !== cropRatio` | viền amber quanh vùng giữ + mask đen opacity 0.55 bên ngoài | +| Đã áp (applied) | `cropApplied === cropRatio` | chỉ còn vùng tỉ lệ, mask đen opacity 1, không viền amber | + +Chọn tỉ lệ khác tự quay về trạng thái band (so sánh `cropApplied === cropRatio`, không cần reset thủ công). + +**Sửa:** + +- `App.tsx`: thêm state `cropApplied`; truyền `cropApplied` cho `Viewfinder` và `AdjustmentPanel`, thêm `onApplyCrop={() => setCropApplied(cropRatio)}`; `handleResetAll` thêm `setCropApplied(null)` cạnh `setCropRatio('none')`; `handleUpdateFrame` xoá `cropApplied` khi frame được chọn. +- `AdjustmentPanel.tsx`: chip `APPLY` nằm trong hàng chip của `case 'frame'`, chèn ngay sau chip nhóm `CROP`: + `cropEnabled && cropRatio !== 'none' && cropRatio !== 'free' && cropApplied !== cropRatio`. +- `Viewfinder.tsx`: `libCropView` (`k = min(vw/dw, vh/dh)`, rect căn giữa) + `cropScreenPx` (dùng làm vùng mask khi đã áp, vẫn `cropWinPx` cho frame/grain/vignette); `libViewMatrix` ghép ngoài·trong `[k·s, 0, k·tx + s·ox, 0, k·s, k·ty + s·oy, 0, 0, 1]` áp cho hai `` của ảnh library; `libCropTouchStyle` (translate rồi scale `k`, có hiệu chỉnh tâm) trên lớp bắt touch; mask `opacity={libCropView ? 1 : 0.55}`; Rect stroke `#f59e0b` bọc trong `{!libCropView && (...)}`. + +**Đo trên máy** (emulator-5554, 1080×2400, ảnh thư viện dọc): + +| Bước | Chip APPLY | Amber trong preview | Vùng nội dung | +| --- | --- | --- | --- | +| FRAME, CROP = NONE | không | 0 | ảnh đầy đủ (baseline) | +| chọn 1:1 | có | 14218 px, band 1080×1083 tại y 343–1426 | ảnh đầy đủ + dim | +| APPLY 1:1 | không | 0 | mask đen y 273–344 và 1425–1769; nội dung y 345–1424 | +| chọn 2:3 | có | band 928×1340 tại x 76–1003 | `k = 1,164` | +| APPLY 2:3 | không | 0 | nội dung phóng kín chiều ngang | +| RESET sau APPLY | không | 0 | trùng baseline: chỉ 30/288000 pixel khác, đều ở đồng hồ (99–118, 56–83) | +| đổi tỉ lệ sau APPLY (3:4) | có lại | band 17836 px | tự về trạng thái band | + +Với 1:1, `k = 1` nên pixel trong vùng crop của bản APPLY giống hệt bản band (max diff 0) — chỉ mask đổi 0.55 → 1 và viền amber mất. + +**Kiểm tra khác:** + +- `RESET *` → `RESET` sau khi reset (hết dấu *). +- Kéo 1 ngón ở preview (150 px và 500 px): ảnh không dịch — kể cả khi không crop, khi band, khi đã APPLY. Pan 1x vốn không chạy trong bản này nên không phải hồi quy của touch layer; `libCropTouchStyle` không gây lệch. +- `tsc --noEmit` vẫn **14 lỗi** đúng baseline (1 `App.tsx` + 13 `src/components/Viewfinder.tsx`). +- Build `gradlew.bat --offline assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success. + +## 19. Lưu `cropApplied` vào session (9/9/2026) + +Trước: APPLY chỉ là state trong bộ nhớ. Cold start / restore LAST SESSION thì mất, preview trở lại ảnh đầy đủ trong khi chip CROP vẫn giữ ratio cũ. + +**Sửa:** + +- `App.tsx`: thêm `cropApplied` vào snapshot session (cạnh `cropRect`), thêm `cropApplied` vào dependency array của effect lưu session, và khi restore: `if (session.cropApplied && session.cropApplied === session.cropRatio) setCropApplied(session.cropApplied);` (guard bước 2: chỉ nhận khi ratio khớp, tránh restore lệch cặp). +- Chuẩn hoá 4 dòng LF-only ở `Viewfinder.tsx` 512–515 (comment Task 2) về CRLF. + +**Đo trên máy** (emulator-5554, STARTUP MODE = LAST SESSION): + +| Bước | Chip APPLY | Amber trong preview | Preview | +| --- | --- | --- | --- | +| load ảnh → CROP 1:1 → APPLY → force-stop → mở lại | không | 0 px | mask đen đặc, nội dung y 398–1477 (viewport cao hơn khi deck đóng) | + +Rect nội dung khác lúc strip đang mở (y 345–1424) là do viewport cao hơn, không phải lỗi. + +- `tsc --noEmit` vẫn **14 lỗi** đúng baseline. +- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success. +- Đã trả STARTUP MODE về DEFAULT sau khi test. diff --git a/src/components/AdjustmentPanel.tsx b/src/components/AdjustmentPanel.tsx index 350717d..586d248 100644 --- a/src/components/AdjustmentPanel.tsx +++ b/src/components/AdjustmentPanel.tsx @@ -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 ( - + { + chipXRef.current[c.key] = e.nativeEvent.layout.x; + }} + > {c.trash || c.star ? ( { 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(null); + const chipXRef = useRef>({}); + const firstAmberChipRef = useRef(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 ( + + {chips.map(renderChip)} + + ); + }; + 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 ( - - {chips.map(renderChip)} - + { + 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 ( - - {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), - }) - )} - + }, + ] + : 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 ( - - {paramChips(paramDefs.iq)} - {renderChip(groupChip('dr'))} - - ); + return chipRow([...paramChips(paramDefs.iq), groupChip('dr')]); case 'wb': - return ( - - - {renderChip(tempChip())} - {paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature'))} - {(['cx', 'cxb'] as const).map((g) => renderChip(groupChip(g)))} - - - ); + return chipRow([ + tempChip(), + ...paramChips(paramDefs.wb.filter((p) => p.key !== 'temperature')), + ...(['cx', 'cxb'] as const).map(groupChip), + ]); case 'filters': - return ( - - {paramChips(paramDefs.filters)} - - ); + return chipRow(paramChips(paramDefs.filters)); case 'frame': - // FRAME → WATERMARK: the sub-panel row (breadcrumb back to the frames). - if (openGroup === 'wm') { - return ( - - {renderChip({ - key: 'wm-back', - label: '< FRAME', - active: false, - onPress: () => setOpenGroup(null), - })} - {wmChips().map(renderChip)} - + // 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 ( - - {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())} - + ), + ...(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' && ( {}, })} - {choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick)} + {choiceChips(openGroup, groupDefs[openGroup].options, groupDefs[openGroup].value, groupDefs[openGroup].onPick).map( + renderChip + )} )} diff --git a/src/components/Viewfinder.tsx b/src/components/Viewfinder.tsx index bda35bc..a31c522 100644 --- a/src/components/Viewfinder.tsx +++ b/src/components/Viewfinder.tsx @@ -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; // 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; /** * 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(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(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(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(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(null); const [cameraActive, setCameraActive] = useState(false); // Re-render kicker bumped on every onStarted. A flip remounts @@ -458,10 +440,6 @@ const Viewfinder = forwardRef(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(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(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(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(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(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(function Viewfi {libraryImageUri && skiaImage ? ( <> - + {useShaderPass ? ( // DR/Highlight/Shadow tone + Cinema seasonal grade, both live @@ -2513,7 +2500,7 @@ const Viewfinder = forwardRef(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. */} - + {renderFrameOverlay()} {renderGPSWatermark()} {renderCustomWatermark()} @@ -2521,44 +2508,50 @@ const Viewfinder = forwardRef(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 && ( - + - + {/* 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 && ( + + )} )} true} onMoveShouldSetResponder={() => true} onResponderGrant={onLibTouchStart}