feat(exif): write the Xiaomi watermark blob (0x889e) so HyperOS Gallery can frame our captures
HyperOS Gallery reads EXIF 0x889e (a JSON blob) to build its watermark frame; without it a photo fails with "cannot recognize the parameters". CameraX/HAL never supplies that vendor tag, so a capture from this app lacked it while a stock-camera photo carried it. Read back the same props the stock camera derives the blob from (ro.product.device / ro.product.marketname / Build.MANUFACTURER) via the native module, synthesize the blob in exifWrite, and write it only when the source has none and the device is Xiaomi/Redmi/POCO - a source blob is never overwritten.
This commit is contained in:
@@ -682,3 +682,69 @@ tuyệt đối không dùng lại được, còn Make/Model là do HAL trả v
|
||||
|
||||
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
|
||||
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 22. Khung dấu chìm Xiaomi: vì sao Gallery báo "không thể nhận dạng được thông số" (2026-09-11)
|
||||
|
||||
**Triệu chứng.** Ảnh chụp `RC_20260911_183114.jpg` mở trong trình edit của Xiaomi, chọn tạo khung
|
||||
dấu chìm → "không thể nhận dạng được thông số". Ảnh `IMG_20260830_140429.jpg` (app camera gốc) thì
|
||||
mở bình thường. Hai ảnh **cùng một máy** (Xiaomi 14 Ultra, codename `aurora`).
|
||||
|
||||
**Nguyên nhân.** HyperOS Gallery đọc tag EXIF **`0x889e`** (Exif IFD, ASCII) để dựng khung dấu
|
||||
chìm — đây là JSON do app camera gốc ghi:
|
||||
|
||||
```
|
||||
0x889e {"buildDevice":"aurora","customize":"","cvLensName":"","filterName":"","livephotoInfo":"",
|
||||
"version":2,"waterLogo":"XIAOMI","waterName":"14 Ultra"}
|
||||
```
|
||||
|
||||
Ảnh mẫu còn có `0x9999` (`{"mirror":false,"sensorType":"rear","Hdr":"auto",...}`) và MakerNote
|
||||
`0x927c` (`Xiaomi\x00\x01MM...`). **Ảnh của app không có** `0x889e`, `0x9999`, `0x927c`, cũng không
|
||||
có GPS IFD.
|
||||
|
||||
Lý do không phải bug của module: nguồn chụp (CameraX/HAL) **không bao giờ** cấp các tag vendor này.
|
||||
HAL chỉ cấp tag chuẩn (ISO 2151, 1/50 s, f/2.0, 8.72 mm, FocalLengthIn35mm 23…). Đã chứng minh
|
||||
module **giữ nguyên** `0x889e`/`0x9999` khi nguồn có (chỉ cố ý bỏ `0x927c`).
|
||||
|
||||
**Diff tag-set** (`exifchk/cmp_exif.py`):
|
||||
|
||||
| | RC_183114 | IMG mẫu |
|
||||
| --- | --- | --- |
|
||||
| SOF0 (w,h) | (3072, 4096) | (4096, 3072) |
|
||||
| byte order | II | MM |
|
||||
| IFD0 / Exif / GPS / Interop | 13 / 36 / 0 / 2 | 17 / 43 / 11 / 2 |
|
||||
| Exif chỉ mẫu có | — | `0x889e` `0x9999` `0x927c` `0x8895` `0x889a` `0x889d` `0x889f` `0xa661` |
|
||||
| IFD0 chỉ mẫu có | — | `0x8825` `0x889a` `0x889d` `0x88a3` `0x9999` `0x9a00` |
|
||||
|
||||
xRes/yRes 300 vs 72, Orientation 1 vs 0, ColorSpace 1 vs 65535, `0x110` Model `24030PN60G` vs
|
||||
`Xiaomi 14 Ultra`. Layout TIFF của ảnh app **hợp lệ**, không lỗi cấu trúc — Gallery chỉ thiếu blob.
|
||||
|
||||
**Fix.** Đọc lại đúng các prop mà app camera gốc dùng rồi tự tổng hợp blob:
|
||||
|
||||
- `modules/recipescam-export/.../RecipescamExportModule.kt`: thêm `Function("deviceInfo")` trả
|
||||
`device` (`ro.product.device`) · `marketName` (`ro.product.marketname` → `ro.product.odm.marketname`
|
||||
→ `ro.product.vendor.marketname`) · `manufacturer` · `model`. Đọc prop hệ thống ẩn bằng reflection
|
||||
(`android.os.SystemProperties.get`), lỗi thì trả chuỗi rỗng.
|
||||
- `src/utils/exifWrite.ts`: `watermarkBlob(device)` dựng JSON **đúng thứ tự field** của ảnh gốc
|
||||
(`waterName` = marketName bỏ tiền tố brand: `Xiaomi 14 Ultra` → `14 Ultra`). Trong
|
||||
`writeJpegExif`, ghi `0x889e` **chỉ khi** nguồn thiếu blob và máy là Xiaomi/Redmi/POCO — **không
|
||||
bao giờ ghi đè** blob nguồn.
|
||||
- `src/utils/photoMeta.ts`: `getDeviceIdentity()` cache một lần, trả `null` khi module cũ.
|
||||
- `src/utils/exportEngine.ts`, `src/utils/photoMeta.ts`: truyền `device` vào stamp.
|
||||
|
||||
**Kiểm chứng.**
|
||||
|
||||
| Kiểm chứng | Kết quả |
|
||||
| --- | --- |
|
||||
| blob sinh ra vs blob gốc (`0x889e`) | **IDENTICAL** từng byte, 146 B + NUL = count 147 |
|
||||
| `exifchk/run.js` | SELF-CHECK PASSED (11 ok) — có check "blob nguồn không bị ghi đè", "không ghi blob trên máy non-Xiaomi" |
|
||||
| `exifchk/verify.py` (PIL) | PIL VERIFY PASSED |
|
||||
| `tsc --noEmit` | 14 lỗi, đúng baseline |
|
||||
| `gradlew --offline assembleRelease` | BUILD SUCCESSFUL |
|
||||
| cài + mở app trên emulator | PID sống, 0 FATAL EXCEPTION |
|
||||
|
||||
**Giới hạn.** Emulator không chạy HyperOS Gallery nên **không** xác nhận trực tiếp được Gallery đã
|
||||
hết lỗi; cũng **không** xác nhận được `deviceInfo()` trả gì trên máy thật (chỉ suy ra từ tên prop
|
||||
chuẩn của Xiaomi). Máy non-Xiaomi sẽ không có blob → vẫn đúng như trước.
|
||||
|
||||
+27
@@ -58,6 +58,17 @@ private class FramedResult(val bmp: Bitmap, val window: RectF?)
|
||||
// Font temp files are tiny and reused across exports — cache the Typefaces.
|
||||
private val typefaceCache = HashMap<String, Typeface>()
|
||||
|
||||
// android.os.SystemProperties is hidden API: reflection keeps this compiling
|
||||
// without stubs and degrades to null if a future SDK moves it.
|
||||
private fun systemProp(key: String): String? = try {
|
||||
val get = Class.forName("android.os.SystemProperties")
|
||||
.getMethod("get", String::class.java, String::class.java)
|
||||
(get.invoke(null, key, "") as String).ifEmpty { null }
|
||||
} catch (e: Throwable) {
|
||||
Log.w(TAG, "systemProp($key) unavailable: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
class RecipescamExportModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("RecipesCamExport")
|
||||
@@ -104,6 +115,22 @@ class RecipescamExportModule : Module() {
|
||||
"pong"
|
||||
}
|
||||
|
||||
// Raw device identity the stock Xiaomi camera folds into EXIF 0x889e —
|
||||
// the watermark blob HyperOS Gallery reads before it will open its
|
||||
// watermark editor. CameraX/HAL never writes that tag, so Gallery reports
|
||||
// it cannot recognize the parameters on our captures. JS formats the JSON
|
||||
// (see exifWrite.watermarkBlob); native only reads the props.
|
||||
Function("deviceInfo") { ->
|
||||
mapOf(
|
||||
"device" to (systemProp("ro.product.device") ?: ""),
|
||||
"marketName" to (systemProp("ro.product.marketname")
|
||||
?: systemProp("ro.product.odm.marketname")
|
||||
?: systemProp("ro.product.vendor.marketname") ?: ""),
|
||||
"manufacturer" to (android.os.Build.MANUFACTURER ?: ""),
|
||||
"model" to (android.os.Build.MODEL ?: ""),
|
||||
)
|
||||
}
|
||||
|
||||
// P0 spike harness: copy a bundled drawable asset (e.g. "wallframe", the
|
||||
// 3117x4000 artwork — representative 12MP decode) into cacheDir and hand the
|
||||
// real file path back to JS, which then runs decodeEncodeAsync on it. This
|
||||
|
||||
@@ -91,6 +91,13 @@ export interface RecipescamExportModule {
|
||||
processPhotoAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, adjust?: PhotoAdjust): Promise<ColorTimings>;
|
||||
/** P1 alias: matrix + crop, no tone/cinema/enhance passes. */
|
||||
processColorAsync(srcPath: string, dstPath: string, matrix: number[], crop: CropRect | null, quality: number): Promise<ColorTimings>;
|
||||
/**
|
||||
* Raw device identity the stock Xiaomi camera folds into EXIF 0x889e (the
|
||||
* watermark blob HyperOS Gallery reads) but CameraX/HAL never supplies:
|
||||
* codename, marketing name, manufacturer, model. Empty strings when absent.
|
||||
*/
|
||||
deviceInfo(): { device: string; marketName: string; manufacturer: string; model: string };
|
||||
|
||||
/** P0 probe helpers — removed before ship. */
|
||||
ping(): Promise<string>;
|
||||
materializeBenchAsset(assetName: string): Promise<string>;
|
||||
|
||||
@@ -25,6 +25,17 @@ export interface ExifStamp {
|
||||
width?: number | null;
|
||||
height?: number | null;
|
||||
dpi?: number | null;
|
||||
// Device identity for the Gallery watermark blob (ref. watermarkBlob). Only
|
||||
// used when the source photo carries no blob of its own.
|
||||
device?: DeviceIdentity | null;
|
||||
}
|
||||
|
||||
/** Raw device identity for the HyperOS Gallery watermark tag (0x889e). */
|
||||
export interface DeviceIdentity {
|
||||
device: string; // ro.product.device codename, e.g. "aurora"
|
||||
marketName: string; // ro.product.marketname, e.g. "Xiaomi 14 Ultra"
|
||||
manufacturer: string; // Build.MANUFACTURER, e.g. "Xiaomi"
|
||||
model: string; // Build.MODEL — fallback when marketName is empty
|
||||
}
|
||||
|
||||
interface Entry {
|
||||
@@ -158,6 +169,31 @@ function tzOffset(d: Date): string {
|
||||
return `${mins < 0 ? '-' : '+'}${p(Math.floor(a / 60))}:${p(a % 60)}`;
|
||||
}
|
||||
|
||||
// The watermark blob HyperOS Gallery reads back (EXIF tag 0x889e) before it
|
||||
// will open its watermark editor: without it, a photo edited here fails with
|
||||
// "cannot recognize the parameters". Only the stock camera app writes this tag
|
||||
// — CameraX/HAL never does — so a camera capture has to synthesize it from the
|
||||
// same props the stock app derives it from. Shape matches the stock bytes:
|
||||
// {"buildDevice":"aurora",...,"waterLogo":"XIAOMI","waterName":"14 Ultra"}.
|
||||
function watermarkBlob(d: DeviceIdentity): string {
|
||||
const brand = d.manufacturer.trim();
|
||||
const market = d.marketName.trim();
|
||||
// "Xiaomi 14 Ultra" -> "14 Ultra": Gallery prints waterName, not the brand.
|
||||
const waterName = market.toLowerCase().startsWith(`${brand.toLowerCase()} `)
|
||||
? market.slice(brand.length + 1)
|
||||
: market || d.model;
|
||||
return JSON.stringify({
|
||||
buildDevice: d.device,
|
||||
customize: '',
|
||||
cvLensName: '',
|
||||
filterName: '',
|
||||
livephotoInfo: '',
|
||||
version: 2,
|
||||
waterLogo: brand.toUpperCase(),
|
||||
waterName,
|
||||
});
|
||||
}
|
||||
|
||||
// --- parse -----------------------------------------------------------------
|
||||
|
||||
interface ParsedTiff {
|
||||
@@ -356,6 +392,12 @@ export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: U
|
||||
ifd0.set(0x0128, shortEntry(0x0128, 2)); // resolution unit = inch
|
||||
}
|
||||
|
||||
// HyperOS Gallery refuses to open its watermark editor without 0x889e, and a
|
||||
// CameraX capture never carries it. Never overwrite a blob the source has.
|
||||
if (stamp.device && !exif.has(0x889e) && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer)) {
|
||||
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
|
||||
}
|
||||
|
||||
if (stamp.gps) {
|
||||
const { latitude: lat, longitude: lon } = stamp.gps;
|
||||
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
|
||||
|
||||
@@ -10,7 +10,7 @@ import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader';
|
||||
import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils';
|
||||
import { patchJpegDpi } from './jpegDpi';
|
||||
import { writeJpegExif } from './exifWrite';
|
||||
import { nextPhotoPath, readPhotoBytes } from './photoMeta';
|
||||
import { nextPhotoPath, readPhotoBytes, getDeviceIdentity } from './photoMeta';
|
||||
import { applyPhotoRotation } from './skiaImage';
|
||||
|
||||
export interface ExportResult {
|
||||
@@ -721,6 +721,7 @@ export async function processAndExportPhoto(
|
||||
width: resultImage.width(),
|
||||
height: resultImage.height(),
|
||||
dpi: options?.dpi ?? null,
|
||||
device: getDeviceIdentity(),
|
||||
},
|
||||
await readPhotoBytes(sourceUri)
|
||||
);
|
||||
|
||||
+19
-2
@@ -2,7 +2,8 @@
|
||||
// EXIF helpers both export engines share (the codec itself lives in exifWrite.ts).
|
||||
import { File } from 'expo-file-system';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { writeJpegExif, SOFTWARE, type ExifStamp } from './exifWrite';
|
||||
import RecipescamExport from '../../modules/recipescam-export';
|
||||
import { writeJpegExif, SOFTWARE, type DeviceIdentity, type ExifStamp } from './exifWrite';
|
||||
|
||||
// RC_<yyyymmdd_hhmmss>[_exported].jpg — the name the gallery shows for a saved
|
||||
// photo. The exported variant marks a photo re-exported from the library.
|
||||
@@ -33,6 +34,22 @@ export async function readPhotoBytes(uri: string): Promise<Uint8Array | null> {
|
||||
}
|
||||
}
|
||||
|
||||
// Device identity for the Gallery watermark blob (exifWrite.watermarkBlob).
|
||||
// Read once — the props cannot change while the app runs. null when the native
|
||||
// module is older than this call, so stamping still works without it.
|
||||
let deviceIdentity: DeviceIdentity | null | undefined;
|
||||
export function getDeviceIdentity(): DeviceIdentity | null {
|
||||
if (deviceIdentity === undefined) {
|
||||
try {
|
||||
deviceIdentity = RecipescamExport.deviceInfo();
|
||||
} catch (e) {
|
||||
console.warn('deviceInfo unavailable:', e);
|
||||
deviceIdentity = null;
|
||||
}
|
||||
}
|
||||
return deviceIdentity;
|
||||
}
|
||||
|
||||
// Stamp EXIF into a file already on disk (the native engine writes the JPEG
|
||||
// itself, so those bytes never pass through JS). Never throws — a metadata
|
||||
// failure must not cost the user the photo.
|
||||
@@ -40,7 +57,7 @@ export async function stampExifOnFile(fileUri: string, stamp: ExifStamp, sourceU
|
||||
try {
|
||||
const target = new File(fileUri);
|
||||
const source = sourceUri ? await readPhotoBytes(sourceUri) : null;
|
||||
target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, ...stamp }, source));
|
||||
target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, device: getDeviceIdentity(), ...stamp }, source));
|
||||
} catch (e) {
|
||||
console.warn('EXIF stamp failed:', e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user