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:
2026-09-11 18:47:01 +07:00
parent 33123d47fd
commit 17ee01b62a
6 changed files with 163 additions and 3 deletions
+42
View File
@@ -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'));
+2 -1
View File
@@ -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
View File
@@ -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);
}