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.
70 KiB
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
GroupKeythêm'style';groupDefs.style:label: 'PHOTO STYLE',off: 'sim-provia',value: id sim đang chọn (currentRecipeIdnế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ũ).
case 'recipes'(:492): thay khối...FILM_SIMS.map(...)bằngrenderChip(groupChip('style')); giữRECIPES+CREATE.- Nhãn chip: chỉ
PHOTO STYLE, không kèm tên sim (đã chốt — TopBar góc trái đã hiệnactiveRecipe.name).groupDefs.style.valuevẫn dùng để chip sáng amber khi sim ≠ mặc định, nhưng không in value ra nhãn. amberValuebậ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ácfind(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 trongRecipeCreateModal.tsx:330. - Dọn nhắc tên cũ trong text UI:
SettingsModal.tsx:135("camera + PROVIA …"); commentApp.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ỉ đổiname, 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:1937renderCameraOverlays()đượ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.:1974renderFrameOverlay()gọi chung cho mọi frame, không phân nhánh theo mode::1778polaroid: chỉ vẽ bar đen ngoài card + card trắng quanh cửa sổ; cửa sổ để trống → feed lộ qua (comment:1782-1787nói đúng điều này).:1817wallframe: bar đen ngoài frame rect + PNG mat quadrawMatrix; 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).:1839classic-white /:1852cinematic: viền trắng / bar đen theoframeRect=region(band tỉ lệ) → đã đúng ở camera.
polaroidGeo:1124,wallGeo:1131khô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 sauif (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ùngframeId: 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 đợiwallImageload 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:1201cameraPhotoRect=polaroidGeo.window→wallGeo.window→region(band tỉ lệ) →null;:1211đẩy rect đó (chuẩn hoá theovw/vh) vàofeedRectSync:791[active, viewAspect, fx, fy, fw, fh].- Worklet
handleFrame:903-960: khifeed[0] > 0thì black surface + cover-map cảm biến vào ĐÚNG rect đó (nghịch đảo CTMrenderToTextuređể 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:155crop tỉ lệ chạy trước bước frame, nên ảnh vào bước polaroid đã đúng aspect cửa sổ (:462-500drawImageRectlấp kín window); wallframe:541-570cover-crop giữa vào window 2:3. Camera capture truyềnaspect: 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-1140trảnullkhimode !== 'library'vàhandleCapturecamera không truyềnframeWindowZoom(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)
ToolRail.tsx: xoá{ id: 'watermark' }khỏiTOOLS:21; xoá'watermark'khỏiTabId:5(grep hết chỗ dùng).AdjustmentPanel.tsxcase 'frame':587: thêmrenderChip(groupChip('wm'))nhãnWATERMARK(+ONkhi gps/custom đang bật),amberValuekhi có watermark bật.- Strip
wmlà sub-panel: khiopenGroup === '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. - Ô nhập
TYPE WATERMARK TEXT(:764, điều kiệnactiveTab === 'watermark') → đổi thành "sub-panel watermark đang mở". App.tsx: lift cờwmPanelOpen(state mới) + propwmPanelOpen/onToggleWmPanel;wmEditing={openTab === 'frame' && wmPanelOpen}(:1235) để drag/pinch watermark trên ảnh (Viewfinder.tsx:1280,1285,1526) chỉ arm khi sub-panel mở.useEffectresetopenGroupkhi đổi tab (:229) đã có → giữ.- Pinch/drag nội dung trong cửa sổ frame ở CAMERA (đã chốt; phần nặng nhất của F3):
Viewfinder.tsxzoomWindowRect():1138: bỏif (mode !== 'library') return null→ camera cũng trả cửa sổ (polaroidGeo.window/wallGeo.window).feedRectSync:791payload 6 → 9 số:[active, viewAspect, fx, fy, fw, fh, s, u, v](u,v= tâm nguồn 0..1, cùng ngữ nghĩaframeWindowZoomcủa export); map trong worklet:903-960nhân scale + offset trước khi cover-map. Hook đẩy rect:1211truyền thêms,u,vtừ state zoom camera.- Clamp như
clampZoomToWindow:1176để cửa sổ không lộ nền; đổi frame/ratio → resets=1, u=v=0.5. App.tsxnhánh camera:917: thêmframeWindowZoom: 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ủaonImageDrag*(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:TabIdbỏ'watermark',TOOLSbỏ mục WATERMARK; bỏ propmode+ filter library-only của FRAME → rail giống nhau ở cả camera lẫn library.AdjustmentPanel.tsx:GroupKeythêm'wm'(không phải strip thường, chỉ là cờ mở hàng phụ);paramDefsbỏ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ốiWATERMARK(activekhi sub-panel mở,amberValuekhi có watermark bật); xoá hẳncase 'watermark'và các chip watermark cũ trong đó → chuyển thànhwmChip()/wmChips(); ô nhậpTYPE WATERMARK TEXTđổi điều kiện thànhactiveTab === '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: statewmPanelOpen;wmEditing={openTab === 'frame' && wmPanelOpen}(thayopenTab === 'watermark');onWmEdit(double-tap trên dấu trong khung hình) đổi từsetOpenTab('watermark')→setOpenTab('frame'); truyềnonWmPanelChange={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;‹ FRAMEquay 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.jpg960×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 → BLUErồ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) — đúngwmAnchor()(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
#f59e0btrê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 = falsechỉ ảnh hưởng việc arm, không ảnh hưởng việc vẽ). Đổi tên chip frame (commit riêng6788d51 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ênpolaroid→ 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.feedRectSyncpayload 6 → 9 số[active, viewAspect, fx, fy, fw, fh, z, u, v]; effect đẩy rect đọc thêmframeZoomRef.currentvà phụ thuộc thêmlibZoom.- Worklet
handleFrame: chỉ khiz > 1.001mớ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 quatoRaw()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:
onImageDragStartseedpanRef/pinchRef;onImageDragMovenhánh mớig.kind !== 'wm' && !wmArmed && zoomWindowRect()— 2 ngón → pinch quanh trung điểm +clampZoomToWindow; 1 ngón → chỉ khig.kind === 'focus'và đã vượtTAP_SLOPmới pan (nhường ngón 1 cho AE/AF + kéo tham số);onImageDragEnddọ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.tsxnhá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ềnframeWindowZoom: 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
GroupKeythêm'temp';groupDefs.temp:off: 'auto',value:wbChoice≠ null → preset đó; ngược lại`${temperature}K`,label: 'TEMP'+ value,options: 7 preset + mụcCOLOR TEMPđặc biệt,onPick: preset →wbApply(v);COLOR TEMP→toggleParam('temperature')để mở hàng slider phía trên.
case 'wb':548: bỏchoiceChips('wb', …)và chiptemperaturekhỏiparamChips(paramDefs.wb)(TINT+WB SHIFTvẫn ở hàng chính); thêmrenderChip(groupChip('temp')).amberValuekhi WB ≠ neutral/AUTO.- Edge: kéo slider COLOR TEMP phải
setWbChoice(null)để chip rơi vềTEMP xxxxK(hiệnwbChoicechỉ set trongwbApply).
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
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ênRecipe) vìDEFAULT_RECIPESkhông persist → cờ trên object bundled sẽ mất. Dùng cho sim id, recipe bundled và recipe custom.App.tsx: statefavoriteIds: string[], hydrate cùngrecipes;handleToggleFavorite(id)(optimistic + ghi AsyncStorage); truyềnfavoriteIds+ handler xuống panel.ToolRail.tsx: thêm{ id: 'favorites', label: 'FAVORITED' }ngay sauPRESETS(rail là ScrollView ngang nên đủ chỗ).AdjustmentPanel.tsx:case 'favorites'— chip cho từng recipe yêu thích (label=name,active= đang chọn,onPress=onSelectRecipe), rỗng → chip disabledNO FAVORITES YET - TAP ★ ON A RECIPE; kèm nút bỏ yêu thích.- Đánh dấu ★ chỉ cho recipe (đã chốt Q7 — sim không cần,
Recipeđã lưu sim quabaseFilter):renderChipcó tiền lệ nút phụ (trash:373) → thêm biến thểstar/starred+onPressStar, dùng trên chip trong stripRECIPESvà trong FAVORITED; không thêm ★ vào stripPHOTO STYLE. resetEditsToRecipe/handleResetAllkhông đụngfavoriteIds.
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[]>(guardArray.isArray+ lọc string, lỗi →[]) vàsetFavoriteId(id, on)(đọc lại rồi ghi, dedupe bằngSet).src/components/ToolRail.tsx:TabId+'favorites', chipFAVORITEDngay sauPRESETS.src/components/AdjustmentPanel.tsx:TabId+'favorites';ChipDefthêmstar/starred/onPressStar; nhánh cuối củarenderChipthànhc.trash || c.starvớ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 stripRECIPES; propfavoriteIds+onToggleFavorite.App.tsx: statefavoriteIds: string[], hydrate tronginitSession,handleToggleFavorite(id)(optimisticsetFavoriteIds+setFavoriteId(...).catch(warn)), truyền xuống panel. ★ không nằm tronghandleResetAll/resetDirtynên RESET không đụng tới. Khác plan: hàm tênsetFavoriteId(không phảisetFavorite); empty state viếtNO FAVORITES YET - TAP THE STAR ON A RECIPEthay 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
renderChipđã hỗ trợamberValue(chữ amber, không viền) →{ label: resetDirty ? 'RESET *' : 'RESET', amberValue: resetDirty }.resetDirtytính ởApp.tsx(nơi cóDEFAULT_ADJUSTMENTS,DEFAULT_CUSTOM_WM,DEFAULT_GPS_WM,DEFAULT_CROP_RECT) và truyền xuống prop mớiresetDirty: boolean— tránh export thêm hằng số.- Định nghĩa dirty:
selectedRecipe?.id !== 'sim-provia'∨ adjustments khácDEFAULT_ADJUSTMENTS∨selectedFrame !== 'none'∨customWm/customWmAppliedkhác default ∨gpsWmkhác default ∨useGeotag∨wmRotation !== 0∨photoRotation !== 0∨wallLandscape∨ crop khác default. - Đồ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 ở commit7454ea1; còn thiếucropRatio→'none'vàcropRect→DEFAULT_CROP_RECT→ bổ sung, nếu khôngRESET *không bao giờ tắt. Đã chốt giữ nguyênmode+aspectRatiokhi 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:
AUTOlấy chân trời làm mốc, sau đó kéoSTRAIGHTENđể tinh chỉnh trên mốc đó (góc hiệu dụng = auto + offset). RESETtrong strip:quarter = 0,straighten = 0,autoHorizon = false(RESET toàn cục ởApp.tsx:506cũng vậy).
Phần không thêm dependency
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−rolllà 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ề đúngphotoStraighten.horizonRollđo từAccelerometerlà số nguyên độ (lượng tử 1°).src/utils/skiaImage.ts: thêmrotateSkImageBy(image, deg)(surface đủ lớn +canvas.rotate); giữrotateSkImage90cho quarter-turn.- Áp ở
exportEngine.ts:142+Viewfinder.tsx:330: quarter trước, straighten sau, kèm scale bù để không lộ góc trống. - Chip
ROTATEghép nhãnparts.filter(Boolean).join(' · ')từ['ROTATE '+photoRotation(bỏ số khiphotoRotation === 0 && autoHorizon, khi đó chỉ cònROTATE),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°;amberValuebật khiphotoRotation || photoStraighten || autoHorizon. handleResetAll→photoRotation=0,photoStraighten=0,autoHorizon=false,horizonRoll=0; chipRESETtrong strip cũng tắt AUTO.resetDirty(F6) thêmautoHorizonđể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ôngDeviceMotion), mẫu 100 ms, chỉ subscribe khiautoHorizon && mode === 'library'; bỏ mẫu khihypot(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ềnrgba(0,0,0,0.5)+ 2 px nét),transform: [{ rotate: autoRoll + 'deg' }], amber#f59e0bkhi|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+ propphotoStraightencủaViewfinder. - 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êmrotateSkImageBy(image, deg)(surface đủ lớn +canvas.rotate, crop giữa giữ tỉ lệ gốc → không lộ góc trống); giữrotateSkImage90cho quarter-turn.App.tsx:photoRotationmở rộng0|90|180|270+ thêmphotoStraighten;handleResetAllxoá cả hai;resetDirtytính thêm chúng.src/utils/exportEngine.ts+Viewfinder.tsx: quarter trước, straighten sau.AdjustmentPanel.tsx:photoRotationthànhgroupDefs.rotate(stripRESET · 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ượtROTATE 0→ROTATE · AUTO→ROTATE · AUTO · +5.5°→ROTATE 180 · AUTO;RESET *amber ở mọi trạng thái đã đổi. - Dấu
+rollchứng minh bằng ảnh thật: nghiêng(1.703, 9.661, 0.81)(= +10°) → khớp nhấtPIL 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).
RESETtoàn cục vàRESETtrong strip đều tắt AUTO (chip vềROTATE 0,RESEThế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
→ASTIPCEShayASTIPES?ASTIPES;→ giữCLASSIC CHRIPEShayCLASSIC CHROMES?CLASSIC CHRIPES.Recipe bundled có đổi tên theo không?→ Có:PROVIPES STD,VELVIPES VIVID,ACRIPES MONO,CLASSIC NEGIPES.(giữ nguyên id).Cho tab FRAME hiện ở cả camera?→ Có, và kèm pinch/drag cửa sổ frame ở camera (F3 bước 7).Chip→ Không (TopBar góc trái đã hiện tên).PHOTO STYLEcó hiện tên sim không?Cho phép thêm→ Có, chấp nhận build lại native.expo-sensors?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.FAVORITED gồm cả sim không?→ Chỉ recipe (sim đã nằm trong recipe quabaseFilter).RESET có reset crop không?→ Có, resetcropRatio/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ònROTATE · 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
RESETcũng đóng hàng slider và đưa vềROTATE 0(item 1, phần RESET). - Nhấn
<trong hàng slider → stripRESET · 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ở +
AUTObật (ROTATE · AUTO) → dò được vạch amber ở hàng y 1199–1203, rộng 594 px. - Nhấn chip frame
NO FRAMEkhi AUTO đang bật → strip đóng, hàng amber[]. - Nhấn tab rail
LIGHT→ hàng amber[]; quay lạiFRAME→ 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:
- 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. readPixels()→ mảng xám; liệt kê điểm cạnhmag = sqrt(dx²+dy²), bỏ quamag < 20, toạ độ x trừ tâm, bỏ nếu < 32 điểm.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).- Quét thô
-45..45bước 1°, cổng tin cậybestScore < 3 * median → null, rồi tinh chỉnh bước 0.5° trongbest-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ọisetHwBiasStops(clamped / 8)trước khisetExposureBias()thực sự landed. Preview renderevTarget − 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
casetrả 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. renderChipthêmonLayouttrên View ngoài:chipXRef.current[c.key] = e.nativeEvent.layout.x.paramChipsvàchoiceChipsđổi kiểu trả về thànhChipDef[](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,
scrollTongay 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 (1App.tsx+ 13src/components/Viewfinder.tsx);AdjustmentPanel.tsx0 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/*.xmlcủ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 statecropApplied; truyềncropAppliedchoViewfindervàAdjustmentPanel, thêmonApplyCrop={() => setCropApplied(cropRatio)};handleResetAllthêmsetCropApplied(null)cạnhsetCropRatio('none');handleUpdateFramexoácropAppliedkhi frame được chọn.AdjustmentPanel.tsx: chipAPPLYnằm trong hàng chip củacase 'frame', chèn ngay sau chip nhómCROP: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ẫncropWinPxcho frame/grain/vignette);libViewMatrixghé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 scalek, có hiệu chỉnh tâm) trên lớp bắt touch; maskopacity={libCropView ? 1 : 0.55}; Rect stroke#f59e0bbọ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 *→RESETsau 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;
libCropTouchStylekhông gây lệch. tsc --noEmitvẫn 14 lỗi đúng baseline (1App.tsx+ 13src/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êmcropAppliedvào snapshot session (cạnhcropRect), thêmcropAppliedvà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.tsx512–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 --noEmitvẫ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: thaynextExportFile()bằngnextPhotoPath(options.exported), thêm bước 9c gọiwriteJpegExifvớicapturedAt/GPS/dims/dpi và byte của ảnh nguồn.src/utils/nativeExport.ts: cùng tên file,stampExifOnFile()sauprocessPhotoAsync(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: falsecho camera,exported: truecho 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 --noEmitvẫ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ỏ
0xa005trong Exif IFD; parse đọc Interop của nguồn qua0xa005. - Đ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:MMkhi 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:
swapToLittleEndiandùngdata.slice()— với NodeBuffer,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ùngnew 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 --noEmitvẫn 14 lỗi đúng baseline.- Build
assembleRelease→ BUILD SUCCESSFUL;adb install -r→ Success.
22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11)
Triệu chứng. Ảnh chụp RC_20260911_183114.jpg mở trong trình edit của Xiaomi, chọn tạo khung
dấu chìm → "không thể nhận dạng được thông số". Ảnh IMG_20260830_140429.jpg (app camera gốc) thì
mở bình thường. Hai ảnh cùng một máy (Xiaomi 14 Ultra, codename aurora).
Nguyên nhân. HyperOS Gallery đọc tag EXIF 0x889e (Exif IFD, ASCII) để dựng khung dấu
chìm — đây là JSON do app camera gốc ghi:
0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"",
"version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"}
Ảnh mẫu còn có 0x9999 ({"mirror":false,"sensorType":"rear","Hdr":"auto",...}) và MakerNote
0x927c (Xiaomi\x00\x01MM...). Ảnh của app không có 0x889e, 0x9999, 0x927c, cũng không
có GPS IFD.
Lý do không phải bug của module: nguồn chụp (CameraX/HAL) không bao giờ cấp các tag vendor này.
HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh
module giữ nguyên 0x889e/0x9999 khi nguồn có (chỉ cố ý bỏ 0x927c).
Diff tag-set (exifchk/cmp_exif.py):
| RC_183114 | IMG mẫu | |
|---|---|---|
| SOF0 (w,h) | (3072, 4096) | (4096, 3072) |
| byte order | II | MM |
| IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 |
| Exif chỉ mẫu có | — | 0x889e 0x9999 0x927c 0x8895 0x889a 0x889d 0x889f 0xa661 |
| IFD0 chỉ mẫu có | — | 0x8825 0x889a 0x889d 0x88a3 0x9999 0x9a00 |
xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, 0x110 Model 24030PN60G vs
Xiaomi 14 Ultra. Layout TIFF của ảnh app hợp lệ, không lỗi cấu trúc — Gallery chỉ thiếu blob.
Fix. Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob:
modules/recipescam-export/.../RecipescamExportModule.kt: thêmFunction("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). TrongwriteJpegExif, ghi0x889echỉ 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ảnullkhi module cũ.src/utils/exportEngine.ts,src/utils/photoMeta.ts: truyềndevicevà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.