Files
RecipesCam/5_BURN_STRIP.md
T
3dtours 82efc1f675 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.
2026-09-18 10:57:00 +07:00

265 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.