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.
12 KiB
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:
<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):
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.isolà ISO đọc từ EXIF của ảnh nguồn; không có thì inAUTO(xem §5).grainlấy thẳng từ knobadjustments.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.warmthlà bước nhảy 250K quanh mốc 5500K, luôn kèm dấu (+2/-3).baseFilter === 'monochrome'thì thay hẳnWARMTH …bằngMONO.
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ự:
- Tạo surface mới
(W, H + band)— ảnh cao thêm đúngband. Vẽ ảnh cũ vào(0, 0). - Tagline trên ảnh, góc trên-trái, font mono:
- bản
#000000tạ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).
- bản
- Dải nền dưới ảnh: hình chữ nhật
(0, H, W, band)tô#0b0b0b. - Tên recipe
#f2f2f2, baseline(pad, H + pad + titleSize). - Dòng kỹ thuật
#9a9a9a, baseline(pad, H + pad + titleSize + round(metaSize * 1.7))— tức cách baseline tênround(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):
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:
// 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):
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:
// 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)
- Xuất ảnh có recipe đặt tên, grain > 0, temperature ≠ 5500.
- 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. - Đ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/#9a9a9anằm trong dải dưới. - 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. - Preview / viewfinder: khẳng định không xuất hiện dải (không có pixel amber
#f59e0bngoài watermark vốn có). - Đối chiếu web:
docker/frontend/src/engine/exportEngine.tsbướ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ẽ.
bandtí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.