From 82efc1f675b3bfc23c2c37f09fd28b02c2bf05e9 Mon Sep 17 00:00:00 2001 From: 3dtours Date: Fri, 18 Sep 2026 10:57:00 +0700 Subject: [PATCH] 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. --- 5_BURN_STRIP.md | 264 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 264 insertions(+) create mode 100644 5_BURN_STRIP.md diff --git a/5_BURN_STRIP.md b/5_BURN_STRIP.md new file mode 100644 index 0000000..a6cd99f --- /dev/null +++ b/5_BURN_STRIP.md @@ -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 +
+ + #ACROS_100 +
+
+ ACROS 100 + ISO 400 · GRAIN 4 · WARMTH +2 +
+``` + +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.