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

12 KiB
Raw Blame History

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.
  • 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):

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)

  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.