# 5 — Đốt dải thông tin ("strip") vào ảnh xuất Nhánh: `feat/vision-camera-v5`. Ngày: 2026-09-06. Nguồn đối chiếu: nhánh `recipes-web`, commit **`b4d5d29`** — bản web đã làm và đo được. --- ## 1. Mục tiêu Ảnh **xuất ra** phải mang đúng dải thông tin mà app đang hiển thị trên thẻ recipe: một **tagline amber** `#TAG` nằm góc trên-trái **trên chính bức ảnh**, và một **dải caption nền tối** treo **bên dưới** ảnh, gồm **tên recipe** (đậm, sáng) và **dòng kỹ thuật** `ISO · GRAIN · WARMTH/MONO` (mono, xám). Đây là bản sao của khối `.lp-shot` + `.lp-frame-meta` trên landing: ```html
#ACROS_100
ACROS 100 ISO 400 · GRAIN 4 · WARMTH +2
``` Thuộc tính web (để đối chiếu màu/kích thước): `docker/frontend/src/styles/landing.css` dòng 288–291 — tagline `color: var(--lp-amber)`, meta `font-family: var(--lp-mono)`. **Ranh giới quan trọng:** dải này CHỈ có trong **file xuất**. Preview / viewfinder / màn hình chỉnh sửa **không** được vẽ nó — nếu không, người dùng thấy hai lần. --- ## 2. Giá trị lấy từ đâu Bản web tính trong một `useMemo` duy nhất (`recipes-web` @ `b4d5d29`, `docker/frontend/src/App.tsx`, dòng ~658–671): ```ts const name = (recipe.name || 'RECIPE').trim(); const slug = name.replace(/[^\p{L}\p{N}]+/gu, '_').replace(/^_+|_+$/g, '').toUpperCase(); const grain = Math.max(0, Math.round(recipe.adjustments.grain ?? 0)); const warmth = Math.round((recipe.adjustments.temperature - 5500) / 250); const tail = recipe.baseFilter === 'monochrome' ? 'MONO' : `WARMTH ${warmth >= 0 ? '+' : ''}${warmth}`; const strip = { tag: `#${slug || 'RECIPE'}`, // UPPERCASE, khoảng trắng -> '_' title: name, // giữ nguyên hoa/thường meta: `ISO ${iso ?? 'AUTO'} · GRAIN ${grain} · ${tail}`, }; ``` - `·` là **U+00B7 MIDDLE DOT**, có dấu cách hai bên. - `iso` là ISO đọc từ **EXIF của ảnh nguồn**; không có thì in `AUTO` (xem §5). - `grain` lấy thẳng từ knob `adjustments.grain` (0..10), **không** cộng phần re-grain của NOISE REDUCTION âm — dòng kỹ thuật mô tả *công thức*, không mô tả pass xuất. - `warmth` là bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (`+2` / `-3`). - `baseFilter === 'monochrome'` thì thay hẳn `WARMTH …` bằng `MONO`. --- ## 3. Hình học & màu (chính xác từng pixel) Đặt `W`, `H` = chiều rộng/cao của ảnh **sau khi đã ghép frame** (ảnh export cuối cùng, trước encode). Mọi kích thước suy từ `W`: | Đại lượng | Công thức | |---|---| | `band` | `round(W * 0.155)` | | `pad` | `round(W * 0.03)` | | `tagSize` | `round(W * 0.026)` | | `titleSize` | `round(W * 0.042)` | | `metaSize` | `round(W * 0.026)` | Các bước vẽ, **theo đúng thứ tự**: 1. Tạo surface mới `(W, H + band)` — **ảnh cao thêm đúng `band`**. Vẽ ảnh cũ vào `(0, 0)`. 2. **Tagline** trên ảnh, góc trên-trái, font mono: - bản `#000000` tại `(pad + 1, pad + tagSize + 1)` (drop shadow, để tag đọc được trên trời sáng), - bản `#f59e0b` (amber) tại `(pad, pad + tagSize)`. 3. **Dải nền** dưới ảnh: hình chữ nhật `(0, H, W, band)` tô `#0b0b0b`. 4. **Tên recipe** `#f2f2f2`, baseline `(pad, H + pad + titleSize)`. 5. **Dòng kỹ thuật** `#9a9a9a`, baseline `(pad, H + pad + titleSize + round(metaSize * 1.7))` — tức cách baseline tên `round(metaSize * 1.7)`. Font: **Cousine Regular** — cùng typeface app đã bundle (`assets/Cousine-Regular.ttf`; engine đã nạp sẵn trong `watermarkFonts.typeface`). Ví dụ đo được (web): nguồn 1200×900 → file xuất **1200×1086** (`band = 186`). --- ## 4. Ba đường phải sửa (parity gate) Bản web chỉ có một engine; bản app có **ba** đường vẽ, cả ba phải ra **cùng một file**: ### 4.1 `src/utils/exportEngine.ts` — engine Skia (bước 9b) Chèn ngay **trước** dòng encode (`let bytes = resultImage.encodeToBytes(...)`, ~dòng 917), sau bước snapshot + sharpen (bước 9 hiện tại, ~dòng 896): ```ts const caption = options?.caption ?? null; // thêm vào ExportOptions if (caption) { const W = resultImage.width(); const band = Math.round(W * 0.155); const capSurface = createSurface(W, resultImage.height() + band); const loaded = watermarkFonts; // cùng typeface Cousine đã nạp if (capSurface && loaded) { own(capSurface); const cc = capSurface.getCanvas(); cc.drawImage(resultImage, 0, 0, own(Skia.Paint())); const pad = Math.round(W * 0.03); const tagSize = Math.round(W * 0.026); const tagFont = own(Skia.Font(loaded.typeface, tagSize)); const shadow = own(Skia.Paint()); shadow.setColor(Skia.Color('#000000')); const amber = own(Skia.Paint()); amber.setColor(Skia.Color('#f59e0b')); cc.drawText(caption.tag, pad + 1, pad + tagSize + 1, shadow, tagFont); cc.drawText(caption.tag, pad, pad + tagSize, amber, tagFont); const bandTop = resultImage.height(); const bandPaint = own(Skia.Paint()); bandPaint.setColor(Skia.Color('#0b0b0b')); cc.drawRect(Skia.XYWHRect(0, bandTop, W, band), bandPaint); const titleSize = Math.round(W * 0.042); const metaSize = Math.round(W * 0.026); const titleFont = own(Skia.Font(loaded.typeface, titleSize)); const metaFont = own(Skia.Font(loaded.typeface, metaSize)); const titlePaint = own(Skia.Paint()); titlePaint.setColor(Skia.Color('#f2f2f2')); const metaPaint = own(Skia.Paint()); metaPaint.setColor(Skia.Color('#9a9a9a')); cc.drawText(caption.title, pad, bandTop + pad + titleSize, titlePaint, titleFont); cc.drawText(caption.meta, pad, bandTop + pad + titleSize + Math.round(metaSize * 1.7), metaPaint, metaFont); const composed = own(capSurface.makeImageSnapshot()); release(owned, resultImage); // đăng ký `own`/`release` như mọi buffer khác resultImage = composed; } else if (capSurface) { capSurface.dispose(); } } ``` `ExportOptions` thêm: ```ts // Dải file mang theo: tagline amber trên ảnh + caption band bên dưới. // Để trống cho preview/thumbnail — chỉ đường xuất truyền vào. caption?: { tag: string; title: string; meta: string } | null; ``` Và tại call-site xuất (App.tsx) truyền `caption: strip` (đúng object §2) — **không** truyền vào đường preview. ### 4.2 `src/utils/nativeExport.ts` + `RecipescamExportModule.kt` — đường native Đường native không đi qua Skia, nên phải **vẽ band trong Kotlin**. Thêm `caption` vào `NativeExportOptions` và đẩy xuống map `adjust` (cùng chỗ với `geo`, `frameId`): ```ts caption: options?.caption ?? null, // tag/title/meta — null khi không xuất file ``` Trong `RecipescamExportModule.kt`, chèn **giữa bước 7 (sharpen) và bước 8 (encode)** (~dòng 568, sau khối `if (sharpen > 0) { … }`), vẽ lên chính `render`: ```kotlin // 7b. Strip (parity với exportEngine #9b): band mới cao hơn ảnh. val cap = adjust["caption"] as? Map<*, *> if (cap != null) { val tag = cap["tag"] as? String val title = cap["title"] as? String val meta = cap["meta"] as? String if (tag != null && title != null && meta != null) { val W = render.width; val H = render.height val band = Math.round(W * 0.155f) val taller = Bitmap.createBitmap(W, H + band, Bitmap.Config.ARGB_8888) val cc = Canvas(taller) cc.drawBitmap(render, 0f, 0f, null) val pad = Math.round(W * 0.03f) val tagSize = Math.round(W * 0.026f) val titleSize = Math.round(W * 0.042f) val metaSize = Math.round(W * 0.026f) // Cùng typeface Cousine mà stampGeo đang dùng (raw assets_cousineregular). val face = geoTypefaceRes(ctx, "assets_cousineregular") // tagline: shadow đen lệch 1px rồi amber val shadow = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.BLACK; typeface = face; textSize = tagSize.toFloat() } val amber = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f59e0b"); typeface = face; textSize = tagSize.toFloat() } cc.drawText(tag, (pad + 1).toFloat(), (pad + tagSize + 1).toFloat(), shadow) cc.drawText(tag, pad.toFloat(), (pad + tagSize).toFloat(), amber) // dải nền tối cc.drawRect(0f, H.toFloat(), W.toFloat(), (H + band).toFloat(), Paint().apply { color = Color.parseColor("#0b0b0b") }) val titlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#f2f2f2"); typeface = face; textSize = titleSize.toFloat() } val metaPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.parseColor("#9a9a9a"); typeface = face; textSize = metaSize.toFloat() } cc.drawText(title, pad.toFloat(), (H + pad + titleSize).toFloat(), titlePaint) cc.drawText(meta, pad.toFloat(), (H + pad + titleSize + Math.round(metaSize * 1.7)).toFloat(), metaPaint) render.recycle() render = taller } } ``` Lưu ý: band nằm **dưới frame** — RETRO POLAROID / WALL FRAME đã ghép xong trước đó (bước 6b), nên `H` lúc này là chiều cao tấm card/khung, không phải ảnh gốc. Web làm y hệt: bước 9b chạy sau khi frame đã ghép. ### 4.3 `src/components/Viewfinder.tsx` — preview **Không** vẽ gì. Đây là điểm dễ sai nhất: dải thuộc về *file*, không thuộc về màn hình. --- ## 5. ISO từ EXIF Bản web: `exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })` → `number | null`; `null` in `AUTO` (`recipes-web` @ `b4d5d29`, `docker/frontend/src/engine/imageOps.ts`). App hiện **chưa đọc tag ISO** (chỉ có GPS trong `src/utils/exifGps.ts` và ghi EXIF trong `exifWrite.ts`). Thêm một helper nhỏ cạnh `exifGps.ts` đọc APP1/TIFF tag `0x8827` (ISOSpeedRatings) từ bytes ảnh nguồn, hoặc lấy từ EXIF mà asset của ảnh đã nạp sẵn có. Chưa có thì trả `null` → dòng kỹ thuật in `ISO AUTO`. Đừng bịa ISO từ knob: build này `ISO_SUPPORTED = false` (`src/components/RecipeCreateModal.tsx` dòng 74) — ISO trong dòng kỹ thuật là ISO **của máy ảnh lúc chụp**, không phải một lựa chọn trong app. --- ## 6. Kiểm thử (bắt buộc trước khi coi là xong) 1. Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500. 2. Kỳ vọng hình học: file cao hơn ảnh gốc đúng `round(W * 0.155)`; ví dụ nguồn 1200×900 → **1200×1086**. 3. Đo pixel: hàng trong dải nền tối (độ sáng < 0.2); **tagline amber** có pixel `#f59e0b` (đếm > 20 pixel) ở góc trên-trái *trên ảnh*; chữ `#f2f2f2` / `#9a9a9a` nằm trong dải dưới. 4. Chạy **cả ba đường** — engine Skia, native Kotlin, và (đối chiếu) web @ `b4d5d29`. Band phải trùng khít giữa ba đường: cùng chiều cao, cùng màu, cùng baseline. 5. Preview / viewfinder: khẳng định **không** xuất hiện dải (không có pixel amber `#f59e0b` ngoài watermark vốn có). 6. Đối chiếu web: `docker/frontend/src/engine/exportEngine.ts` bước **9b** (`recipes-web` @ `b4d5d29`). --- ## 7. Ghi chú phụ - Band đặt **sau** frame và **trước** encode — nếu vẽ trước frame, khung sẽ đè mất chữ; nếu vẽ sau encode, file không còn pixel để vẽ. - `band` tính từ **chiều rộng ảnh cuối**, không phải chiều rộng màn hình: ảnh 12MP và ảnh 1200px ra tỉ lệ chữ khác nhau, đúng như mong đợi. - Khi ảnh đã có watermark người dùng / GPS stamp, dải vẫn treo ngoài ảnh — không chồng lên các lớp đó. - Nếu sau này app có "lưu vào bộ sưu tập" tách khỏi "tải xuống": bản web cố ý lưu bản **sạch** (không đốt dải) cho lưới cộng đồng và chỉ đốt dải vào file tải xuống, để khung của lưới tự vẽ tagline từ nhãn đã lưu — tránh tag hai lần. Nếu app không có lưới cộng đồng thì cứ đốt thẳng vào file xuất.