docs: note the strip that gets burned into the export

Spec for porting the web's caption band onto this branch: the amber #TAG
over the photo's top-left plus the dark band below carrying the recipe
name and the ISO / grain / warmth line. Values, geometry, colours and the
three draw paths are pinned to recipes-web @ b4d5d29, with the preview
explicitly left clean and the ISO taken from the source EXIF.
This commit is contained in:
2026-09-18 10:57:00 +07:00
parent 2bfe8f1211
commit 82efc1f675
+264
View File
@@ -0,0 +1,264 @@
# 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
<div class="lp-shot">
<img … />
<span class="lp-tagline">#ACROS_100</span> <!-- amber, góc trên-trái -->
</div>
<div class="lp-frame-meta">
<b>ACROS 100</b> <!-- tên recipe -->
<span>ISO 400 · GRAIN 4 · WARMTH +2</span> <!-- dòng kỹ thuật, mono -->
</div>
```
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.