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:
+264
@@ -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.
|
||||
Reference in New Issue
Block a user