Files
RecipesCam/PLAN-2026-09-09.md
T
admin 17ee01b62a feat(exif): write the Xiaomi watermark blob (0x889e) so HyperOS Gallery can frame our captures
HyperOS Gallery reads EXIF 0x889e (a JSON blob) to build its watermark
frame; without it a photo fails with "cannot recognize the parameters".
CameraX/HAL never supplies that vendor tag, so a capture from this app
lacked it while a stock-camera photo carried it.

Read back the same props the stock camera derives the blob from
(ro.product.device / ro.product.marketname / Build.MANUFACTURER) via the
native module, synthesize the blob in exifWrite, and write it only when
the source has none and the device is Xiaomi/Redmi/POCO - a source blob
is never overwritten.
2026-09-11 18:47:44 +07:00

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

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.

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.