Files
RecipesCam/PLAN-2026-09-09.md
T

63 KiB
Raw Blame History

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 <SkiaCamera> (: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<string[]>, 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<string[]> (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)

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ũ.

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":

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:

const chipsScrollRef = useRef<ScrollView>(null);
const chipXRef = useRef<Record<string, number>>({});
const firstAmberChipRef = useRef<string | null>(null);
useEffect(() => {
  const id = setTimeout(() => {
    const key = firstAmberChipRef.current;
    const x = key ? chipXRef.current[key] ?? 0 : 0;
    chipsScrollRef.current?.scrollTo({ x: Math.max(0, x - 8), animated: false });
  }, 80);
  return () => clearTimeout(id);
}, [activeTab, isWmPanel(openGroup)]);

const chipRow = (chips: ChipDef[], padV = 6) => {
  firstAmberChipRef.current = chips.find((c) => c.amberValue)?.key ?? null;
  return (<ScrollView ref={chipsScrollRef} horizontal showsHorizontalScrollIndicator={false}
    contentContainerStyle={{ alignItems: 'center', paddingHorizontal: 8, paddingVertical: padV }}>
    {chips.map(renderChip)}</ScrollView>);
};
  • 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ỏ <View> 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 <Group> 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_<ts> khi chụp và khi export (9/9/2026)

Trước: ảnh lưu ra camrecipe_pro_export_<epoch>.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_<yyyymmdd_hhmmss>[_exported].jpg; nextPhotoPath() cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache <Image>).
  • 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.