# 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. ## 20. EXIF + tên file `RC_` khi chụp và khi export (9/9/2026) Trước: ảnh lưu ra `camrecipe_pro_export_.jpg`, không có EXIF nào ngoài thứ Skia còn giữ được sau re-encode (thực tế là mất sạch). **Sửa:** - `src/utils/exifWrite.ts` (mới, thuần, không import): dựng lại segment APP1/EXIF từ byte JPEG — đọc TIFF của ảnh nguồn, giữ mọi tag module không sở hữu, ghi đè `Software 0x0131` = `RecipesCam 1.2`, `DateTime 0x0132` / `DateTimeOriginal 0x9003` / `DateTimeDigitized 0x9004`, `Orientation 0x0112` = 1, `ImageWidth/Length 0x0100/0x0101`, `PixelX/YDimension 0xa002/0xa003`, resolution khi có dpi, GPS (0x0001–0x0004). Bỏ IFD1/thumbnail, MakerNote và con trỏ IFD cũ. Độ dài APP1 ghi **big-endian** (khác khối TIFF bên trong — bug đầu tiên của module). - `src/utils/photoMeta.ts` (mới): `rcPhotoName(exported)` → `RC_[_exported].jpg`; `nextPhotoPath()` cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache ``). - `src/utils/exportEngine.ts`: thay `nextExportFile()` bằng `nextPhotoPath(options.exported)`, thêm bước 9c gọi `writeJpegExif` với `capturedAt`/GPS/dims/dpi và byte của ảnh nguồn. - `src/utils/nativeExport.ts`: cùng tên file, `stampExifOnFile()` sau `processPhotoAsync` (không truyền width/height — JS không biết kích thước Kotlin render). - `App.tsx`: `capturedAt = Date.now()` ngay trước khi chụp; `exported: false` cho camera, `exported: true` cho cả 2 nhánh option của library. Ảnh camera truyền `dateTime` = thời điểm bấm shutter nên EXIF là lúc chụp; ảnh library truyền `null` nên giữ nguyên ngày gốc của file đã load. Khi `sourceJpeg` không đọc được (uri `content://`, lỗi IO) module lấy chính khối EXIF của file đang ghi làm nền, không xoá tag. **Đo trên máy** (emulator-5554, 1080×2400): | Thao tác | File | EXIF đọc lại bằng PIL | | --- | --- | --- | | chụp (GPS watermark OFF) | `RC_20260911_175607.jpg` | Software `RecipesCam 1.2`, DateTime/DateTimeOriginal 17:56:06, Orientation 1, 960×1280, giữ Make/Model của camera HAL | | load `RC_libtest.jpg` → export | `RC_20260911_175905_exported.jpg` | giữ Make `OldCam`, Model `X100`, ISO 200, ExposureTime 1/125, FNumber 2.8, FocalLength 35, DateTime 2019:05:04 03:02:01, GPS 48.8584/2.2945; Software `RecipesCam 1.2`; Orientation 1 | Emulator không có fix GPS (`getCurrentGPS` → `ERR_CURRENT_LOCATION_IS_UNAVAILABLE` dù `adb emu geo fix` trả OK và `location_mode=3`), nên nhánh ghi GPS từ thiết bị chỉ kiểm được ở mức codec: `exifchk/verify.py` đọc lại đúng lat/lon do `writeJpegExif` ghi (10.776889/106.700806) và PASS. - `tsc --noEmit` vẫn **14 lỗi** đúng baseline. - Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success. ## 21. EXIF chuẩn theo ảnh mẫu IMG_20260830_140429.jpg (2026-09-11) Ảnh mẫu (Xiaomi 14 Ultra, 4096×3072, 7 165 975 B) đọc bằng parser tự viết: byte-order `MM` (big-endian), APP1/Exif là segment **đầu tiên** ngay sau SOI (không có APP0/JFIF), TIFF gồm 4 IFD: IFD0 (17 entry) → Exif IFD (43 entry) → GPS IFD (11 entry) → Interop IFD (`0xa005` = {`0x1` `'R98'`, `0x2` `'0100'`}), rồi IFD1 (thumbnail) nối sau IFD0. Tag nằm rải ở cả IFD0 lẫn Exif IFD chứ không gom một chỗ, và **không sắp theo thứ tự tăng dần**. Đối chiếu file app ghi ra: tập tag IFD0/GPS khớp mẫu, nhưng thiếu Interop IFD, thiếu `OffsetTime`/`OffsetTimeOriginal`/`OffsetTimeDigitized`, và khi HAL nghèo tag thì thiếu `ExifVersion`/`FlashPixVersion`/`ComponentsConfiguration`/`ColorSpace` — đúng nhóm mà phần mềm tạo khung dùng để nhận dạng thông số. `src/utils/exifWrite.ts`: - Thêm IFD thứ 4 (Interop) vào layout + con trỏ `0xa005` trong Exif IFD; parse đọc Interop của nguồn qua `0xa005`. - Điền mặc định khi nguồn thiếu: `ExifVersion 0x9000` = `'0230'`, `FlashPixVersion 0xa000` = `'0100'`, `ComponentsConfiguration 0x9101` = {1,2,3,0}, `ColorSpace 0xa001` = 65535, Interop `'R98'`/`'0100'`. Nguồn có sẵn thì giữ nguyên, không ghi đè. - Ghi `OffsetTime 0x9010` (IFD0) + `OffsetTimeOriginal 0x9011` + `OffsetTimeDigitized 0x9012` (Exif IFD) bằng offset cục bộ `±HH:MM` khi app sở hữu mốc thời gian (ảnh chụp). Ảnh export giữ ngày của file nguồn thì không bịa offset. - Fix bug thật: `swapToLittleEndian` dùng `data.slice()` — với Node `Buffer`, `slice()` trả về *view* chứ không copy, nên hàm swap ngược byte của chính buffer nguồn (đọc ra ISO 36865 thay vì 400 khi nguồn big-endian). Nay dùng `new Uint8Array(data)`. - Vẫn chủ động bỏ IFD1/thumbnail và MakerNote (ponytail comment: offset tuyệt đối không sống sót khi TIFF bị dời; thumbnail mang pixel trước khi export). **Đo lại** (codec Node + PIL + máy thật): | Kiểm chứng | Kết quả | | --- | --- | | `exifchk/run.js` | SELF-CHECK PASSED, gồm check "source bytes left untouched" | | `exifchk/verify.py` | PIL VERIFY PASSED (thêm Interop/ExifVersion/OffsetTime/ColorSpace/ComponentsConfiguration) | | round-trip ảnh mẫu qua writer | mọi tag Xiaomi giữ nguyên: ISO 400, ExposureProgram 2, MeteringMode 2, FocalLengthIn35mmFilm 75, GPS 15°23'54.67"N 108°19'22.47"E; thêm Interop | | chụp `RC_20260911_182721.jpg` (emulator) | Interop `R98`/`'0100'`; `0x9010`/`0x9011`/`0x9012` = `+07:00`; `ComponentsConfiguration`, `FlashPixVersion`, `ColorSpace` có mặt; Software `RecipesCam 1.2`; Orientation 1; 960×1280; giữ Make/Model HAL | `ExifVersion` của ảnh chụp trên emulator vẫn là `'0210'` vì HAL đã cung cấp — đúng thiết kế: chỉ điền khi nguồn thiếu. **Không thể khắc phục**: nếu phần mềm tạo khung cần tag riêng của nhà sản xuất (MakerNote) hoặc tra cứu cơ sở dữ liệu thiết bị theo Make/Model, app không giả được — MakerNote của máy khác chứa offset tuyệt đối không dùng lại được, còn Make/Model là do HAL trả về. - `tsc --noEmit` vẫn **14 lỗi** đúng baseline. - Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success. --- ## 22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11) **Triệu chứng.** Ảnh chụp `RC_20260911_183114.jpg` mở trong trình edit của Xiaomi, chọn tạo khung dấu chìm → "không thể nhận dạng được thông số". Ảnh `IMG_20260830_140429.jpg` (app camera gốc) thì mở bình thường. Hai ảnh **cùng một máy** (Xiaomi 14 Ultra, codename `aurora`). **Nguyên nhân.** HyperOS Gallery đọc tag EXIF **`0x889e`** (Exif IFD, ASCII) để dựng khung dấu chìm — đây là JSON do app camera gốc ghi: ``` 0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"", "version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"} ``` Ảnh mẫu còn có `0x9999` (`{"mirror":false,"sensorType":"rear","Hdr":"auto",...}`) và MakerNote `0x927c` (`Xiaomi\x00\x01MM...`). **Ảnh của app không có** `0x889e`, `0x9999`, `0x927c`, cũng không có GPS IFD. Lý do không phải bug của module: nguồn chụp (CameraX/HAL) **không bao giờ** cấp các tag vendor này. HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh module **giữ nguyên** `0x889e`/`0x9999` khi nguồn có (chỉ cố ý bỏ `0x927c`). **Diff tag-set** (`exifchk/cmp_exif.py`): | | RC_183114 | IMG mẫu | | --- | --- | --- | | SOF0 (w,h) | (3072, 4096) | (4096, 3072) | | byte order | II | MM | | IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 | | Exif chỉ mẫu có | — | `0x889e` `0x9999` `0x927c` `0x8895` `0x889a` `0x889d` `0x889f` `0xa661` | | IFD0 chỉ mẫu có | — | `0x8825` `0x889a` `0x889d` `0x88a3` `0x9999` `0x9a00` | xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, `0x110` Model `24030PN60G` vs `Xiaomi 14 Ultra`. Layout TIFF của ảnh app **hợp lệ**, không lỗi cấu trúc — Gallery chỉ thiếu blob. **Fix.** Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob: - `modules/recipescam-export/.../RecipescamExportModule.kt`: thêm `Function("deviceInfo")` trả `device` (`ro.product.device`) · `marketName` (`ro.product.marketname` → `ro.product.odm.marketname` → `ro.product.vendor.marketname`) · `manufacturer` · `model`. Đọc prop hệ thống ẩn bằng reflection (`android.os.SystemProperties.get`), lỗi thì trả chuỗi rỗng. - `src/utils/exifWrite.ts`: `watermarkBlob(device)` dựng JSON **đúng thứ tự field** của ảnh gốc (`waterName` = marketName bỏ tiền tố brand: `Xiaomi 14 Ultra` → `14 Ultra`). Trong `writeJpegExif`, ghi `0x889e` **chỉ khi** nguồn thiếu blob và máy là Xiaomi/Redmi/POCO — **không bao giờ ghi đè** blob nguồn. - `src/utils/photoMeta.ts`: `getDeviceIdentity()` cache một lần, trả `null` khi module cũ. - `src/utils/exportEngine.ts`, `src/utils/photoMeta.ts`: truyền `device` vào stamp. **Kiểm chứng.** | Kiểm chứng | Kết quả | | --- | --- | | blob sinh ra vs blob gốc (`0x889e`) | **IDENTICAL** từng byte, 146 B + NUL = count 147 | | `exifchk/run.js` | SELF-CHECK PASSED (11 ok) — có check "blob nguồn không bị ghi đè", "không ghi blob trên máy non-Xiaomi" | | `exifchk/verify.py` (PIL) | PIL VERIFY PASSED | | `tsc --noEmit` | 14 lỗi, đúng baseline | | `gradlew --offline assembleRelease` | BUILD SUCCESSFUL | | cài + mở app trên emulator | PID sống, 0 FATAL EXCEPTION | **Giới hạn.** Emulator không chạy HyperOS Gallery nên **không** xác nhận trực tiếp được Gallery đã hết lỗi; cũng **không** xác nhận được `deviceInfo()` trả gì trên máy thật (chỉ suy ra từ tên prop chuẩn của Xiaomi). Máy non-Xiaomi sẽ không có blob → vẫn đúng như trước.