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:
@@ -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