8c6e7930db
`docker/` now holds the whole web build — frontend (Vite + React + CanvasKit),
backend (Fastify + SQLite) and the compose file — so the folder can be moved to
another machine and run without the React Native project:
cd docker && cp .env.example .env && docker compose up -d --build
Only `${WEB_PORT:-8090}` is published; nginx serves the SPA and proxies /api to
the `api` container over Docker's DNS. Photos never reach the server.
The shared render code is vendored into `docker/frontend/shared/` and aliased to
a CanvasKit shim, so the app's own frameUtils/toneShader/jpegDpi run unchanged.
Fix the all-black render on GPU surfaces: `MakeWebGLCanvasSurface` creates a
separate WebGL context per call, and a texture from one context cannot be
sampled by a surface on another — so any pass that drew a snapshot onto a second
surface (output sharpen, screen sharpen, polaroid/wallframe cards) came out
solid black, while the raster fallback was correct. Use one shared
GrDirectContext + MakeRenderTarget instead.
Verified in headless Chromium against the running stack: 12MP JPEG in, preview
mean=120.5 sd=60.5, export 2048x1536 mean=107.2 sd=62.1, JFIF density 300/300,
EXIF present, no console errors; health/signup/login/me/recipes all 2xx through
the nginx proxy.
476 lines
20 KiB
TypeScript
476 lines
20 KiB
TypeScript
// Pure JPEG/EXIF writer — no imports, so the codec can be exercised directly.
|
|
//
|
|
// Skia re-encodes the photo, which drops the original EXIF block (see
|
|
// jpegDpi.ts). This rebuilds the APP1/EXIF segment: it reads the SOURCE
|
|
// photo's TIFF, keeps every tag it does not own, and stamps the fields this app
|
|
// is responsible for — software + version, capture date + zone, GPS, pixel
|
|
// dimensions — plus the baseline tags a conforming EXIF block must carry
|
|
// (ExifVersion, FlashPixVersion, ComponentsConfiguration, ColorSpace, Interop).
|
|
// Same in-place byte approach as jpegDpi.ts: no dependency, no native module.
|
|
//
|
|
// ponytail: IFD1 (the embedded thumbnail) and MakerNote are dropped. A carried
|
|
// thumbnail would show the PRE-export pixels, and MakerNote holds absolute
|
|
// offsets that a relocated TIFF invalidates. Read them back when a use case
|
|
// needs them.
|
|
|
|
// Stamped into every file this app writes (kept in step with app.json).
|
|
export const SOFTWARE = 'RecipesCam 1.2';
|
|
|
|
export interface ExifStamp {
|
|
software?: string;
|
|
// Shutter instant. null/undefined = keep the loaded photo's own
|
|
// DateTimeOriginal and only fill it in when the source has none.
|
|
dateTime?: Date | null;
|
|
gps?: { latitude: number; longitude: number } | null;
|
|
// Place name shown on the Gallery frame (EXIF 0x889d). Stock photos carry it
|
|
// whenever the capture was geotagged; ours have to state it from the same
|
|
// info the watermark prints.
|
|
locationName?: string | null;
|
|
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 {
|
|
tag: number;
|
|
type: number;
|
|
count: number;
|
|
data: Uint8Array;
|
|
ref?: 'exif' | 'gps' | 'interop'; // IFD pointer entry — value patched during layout
|
|
}
|
|
|
|
const BYTE = 1, ASCII = 2, SHORT = 3, LONG = 4, RATIONAL = 5, UNDEFINED = 7, SLONG = 9, SRATIONAL = 10;
|
|
|
|
const TYPE_SIZE: Record<number, number> = {
|
|
[BYTE]: 1, [ASCII]: 1, [SHORT]: 2, [LONG]: 4, [RATIONAL]: 8,
|
|
[UNDEFINED]: 1, [SLONG]: 4, [SRATIONAL]: 8,
|
|
};
|
|
|
|
// Tags this module owns, or that would dangle once the TIFF is rebuilt.
|
|
const DROP = new Set<number>([
|
|
0x0112, // Orientation — the exported pixels are already upright (stamped back as 1)
|
|
0x0100, 0x0101, // ImageWidth / ImageLength (stamped from the real export size)
|
|
0x011a, 0x011b, 0x0128, // XResolution / YResolution / ResolutionUnit (stamped when dpi is set)
|
|
0x0131, 0x0132, // Software / DateTime
|
|
0x8769, 0x8825, 0xa005, 0x014a, // Exif / GPS / Interop / SubIFD pointers (rebuilt)
|
|
0x0200, 0x0201, 0x0202, // thumbnail offset/length
|
|
0x927c, // MakerNote — internal offsets do not survive relocation
|
|
]);
|
|
|
|
// --- little-endian writers -------------------------------------------------
|
|
|
|
function u16w(b: Uint8Array, p: number, v: number): void {
|
|
b[p] = v & 0xff;
|
|
b[p + 1] = (v >> 8) & 0xff;
|
|
}
|
|
|
|
function u32w(b: Uint8Array, p: number, v: number): void {
|
|
b[p] = v & 0xff;
|
|
b[p + 1] = (v >> 8) & 0xff;
|
|
b[p + 2] = (v >> 16) & 0xff;
|
|
b[p + 3] = (v >> 24) & 0xff;
|
|
}
|
|
|
|
// --- entry builders --------------------------------------------------------
|
|
|
|
// ASCII-typed byte string, NUL-terminated as EXIF requires. The bytes are UTF-8:
|
|
// a place name ("TAM KỲ, ĐÀ NẴNG") or a market name can carry accents, and the
|
|
// charCodeAt & 0xff shortcut would mangle them into latin-1. Pure-ASCII text
|
|
// encodes byte-for-byte the same as before.
|
|
function asciiEntry(tag: number, text: string): Entry {
|
|
const bytes: number[] = [];
|
|
for (const ch of text) {
|
|
const cp = ch.codePointAt(0) as number;
|
|
if (cp < 0x80) bytes.push(cp);
|
|
else if (cp < 0x800) bytes.push(0xc0 | (cp >> 6), 0x80 | (cp & 0x3f));
|
|
else if (cp < 0x10000) bytes.push(0xe0 | (cp >> 12), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
|
else bytes.push(0xf0 | (cp >> 18), 0x80 | ((cp >> 12) & 0x3f), 0x80 | ((cp >> 6) & 0x3f), 0x80 | (cp & 0x3f));
|
|
}
|
|
bytes.push(0);
|
|
return { tag, type: ASCII, count: bytes.length, data: new Uint8Array(bytes) };
|
|
}
|
|
|
|
function shortEntry(tag: number, v: number): Entry {
|
|
const data = new Uint8Array(2);
|
|
u16w(data, 0, v);
|
|
return { tag, type: SHORT, count: 1, data };
|
|
}
|
|
|
|
// UNDEFINED value: a raw byte string whose length IS the count (ExifVersion etc).
|
|
function undefinedEntry(tag: number, text: string): Entry {
|
|
const data = new Uint8Array(text.length);
|
|
for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff;
|
|
return { tag, type: UNDEFINED, count: data.length, data };
|
|
}
|
|
|
|
function longEntry(tag: number, v: number): Entry {
|
|
const data = new Uint8Array(4);
|
|
u32w(data, 0, v);
|
|
return { tag, type: LONG, count: 1, data };
|
|
}
|
|
|
|
function rationalEntry(tag: number, num: number, den: number, type = RATIONAL): Entry {
|
|
const data = new Uint8Array(8);
|
|
u32w(data, 0, num);
|
|
u32w(data, 4, den);
|
|
return { tag, type, count: 1, data };
|
|
}
|
|
|
|
// GPS coordinate: 3 RATIONALs (degrees, minutes, seconds), denominator 10000
|
|
// for the seconds so ~0.1 m survives.
|
|
function gpsCoordEntry(tag: number, value: number): Entry {
|
|
const abs = Math.abs(value);
|
|
let deg = Math.floor(abs);
|
|
let min = Math.floor((abs - deg) * 60);
|
|
let sec = Math.round(((abs - deg) * 60 - min) * 60 * 10000);
|
|
if (sec >= 600000) { sec = 0; min += 1; }
|
|
if (min >= 60) { min = 0; deg += 1; }
|
|
const data = new Uint8Array(24);
|
|
u32w(data, 0, deg); u32w(data, 4, 1);
|
|
u32w(data, 8, min); u32w(data, 12, 1);
|
|
u32w(data, 16, sec); u32w(data, 20, 10000);
|
|
return { tag, type: RATIONAL, count: 3, data };
|
|
}
|
|
|
|
// A big-endian source TIFF hands us big-endian value bytes; they are re-emitted
|
|
// into a little-endian TIFF, so every numeric value must be swapped first
|
|
// (ASCII/BYTE/UNDEFINED are byte strings and stay as they are).
|
|
function swapToLittleEndian(data: Uint8Array, type: number): Uint8Array {
|
|
const unit = type === SHORT ? 2 : type === LONG || type === SLONG ? 4 : type === RATIONAL || type === SRATIONAL ? 4 : 0;
|
|
if (!unit) return data;
|
|
// `new Uint8Array(data)` (not data.slice()) — the input may be a Node Buffer,
|
|
// whose slice() aliases the source and would corrupt the caller's bytes.
|
|
const out = new Uint8Array(data);
|
|
for (let p = 0; p + unit <= out.length; p += unit) {
|
|
for (let i = 0; i < unit >> 1; i++) {
|
|
const a = out[p + i];
|
|
out[p + i] = out[p + unit - 1 - i];
|
|
out[p + unit - 1 - i] = a;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function asciiValue(e: Entry | undefined): string | null {
|
|
if (!e || e.type !== ASCII || e.data.length === 0) return null;
|
|
let s = '';
|
|
for (let i = 0; i < e.data.length && e.data[i] !== 0; i++) s += String.fromCharCode(e.data[i]);
|
|
return s || null;
|
|
}
|
|
|
|
// "YYYY:MM:DD HH:MM:SS" — the EXIF date format (colons, not dashes).
|
|
function exifDate(d: Date): string {
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
return `${d.getFullYear()}:${p(d.getMonth() + 1)}:${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
|
|
}
|
|
|
|
// "±HH:MM" — the OffsetTime* tag format. Framing tools read it to place a
|
|
// capture correctly, and it is what makes the date unambiguous without GPS.
|
|
function tzOffset(d: Date): string {
|
|
const mins = -d.getTimezoneOffset();
|
|
const a = Math.abs(mins);
|
|
const p = (n: number) => String(n).padStart(2, '0');
|
|
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 {
|
|
ifd0: Entry[];
|
|
exif: Entry[];
|
|
gps: Entry[];
|
|
interop: Entry[];
|
|
}
|
|
|
|
function parseTiff(tiff: Uint8Array): ParsedTiff {
|
|
const none: ParsedTiff = { ifd0: [], exif: [], gps: [], interop: [] };
|
|
if (tiff.length < 8) return none;
|
|
const le = tiff[0] === 0x49 && tiff[1] === 0x49;
|
|
const be = tiff[0] === 0x4d && tiff[1] === 0x4d;
|
|
if (!le && !be) return none;
|
|
|
|
const u16 = (p: number): number => (le ? tiff[p] | (tiff[p + 1] << 8) : (tiff[p] << 8) | tiff[p + 1]);
|
|
const u32 = (p: number): number =>
|
|
le
|
|
? (tiff[p] | (tiff[p + 1] << 8) | (tiff[p + 2] << 16) | (tiff[p + 3] << 24)) >>> 0
|
|
: ((tiff[p] << 24) | (tiff[p + 1] << 16) | (tiff[p + 2] << 8) | tiff[p + 3]) >>> 0;
|
|
|
|
const read = (off: number): { list: Entry[]; ptr: Record<number, number> } => {
|
|
const out: { list: Entry[]; ptr: Record<number, number> } = { list: [], ptr: {} };
|
|
if (off <= 0 || off + 2 > tiff.length) return out;
|
|
const n = u16(off);
|
|
for (let i = 0; i < n; i++) {
|
|
const p = off + 2 + i * 12;
|
|
if (p + 12 > tiff.length) break;
|
|
const tag = u16(p);
|
|
const type = u16(p + 2);
|
|
const count = u32(p + 4);
|
|
if (tag === 0x8769 || tag === 0x8825 || tag === 0xa005) {
|
|
out.ptr[tag] = u32(p + 8); // sub-IFD pointers are rebuilt, never copied
|
|
continue;
|
|
}
|
|
const size = TYPE_SIZE[type];
|
|
if (!size || DROP.has(tag)) continue;
|
|
const len = size * count;
|
|
if (len <= 0 || len > 4 * 1024 * 1024) continue; // ignore absurd/overflowing counts
|
|
if (len <= 4) {
|
|
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(tiff.slice(p + 8, p + 8 + len), type) : tiff.slice(p + 8, p + 8 + len) });
|
|
} else {
|
|
const o = u32(p + 8);
|
|
if (o + len > tiff.length) continue;
|
|
const raw = tiff.slice(o, o + len);
|
|
out.list.push({ tag, type, count, data: be ? swapToLittleEndian(raw, type) : raw });
|
|
}
|
|
}
|
|
return out;
|
|
};
|
|
|
|
const a = read(u32(4));
|
|
const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} };
|
|
return {
|
|
ifd0: a.list,
|
|
exif: exifRead.list,
|
|
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
|
|
interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [],
|
|
};
|
|
}
|
|
|
|
// Locate the APP1/EXIF segment: start/end bound the whole segment, tiff the
|
|
// payload after the "Exif\0\0" signature.
|
|
function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff: Uint8Array } | null {
|
|
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return null;
|
|
let i = 2;
|
|
while (i + 1 < jpeg.length && jpeg[i] === 0xff) {
|
|
const start = i;
|
|
while (jpeg[i] === 0xff) i++;
|
|
const marker = jpeg[i];
|
|
i++;
|
|
if (marker === 0xd9 || marker === 0xda) return null; // EOI / SOS — headers only
|
|
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) continue;
|
|
if (i + 2 > jpeg.length) return null;
|
|
const len = (jpeg[i] << 8) | jpeg[i + 1];
|
|
if (len < 2 || i + len > jpeg.length) return null;
|
|
const dataStart = i + 2;
|
|
if (
|
|
marker === 0xe1 && len >= 8 &&
|
|
jpeg[dataStart] === 0x45 && jpeg[dataStart + 1] === 0x78 && jpeg[dataStart + 2] === 0x69 &&
|
|
jpeg[dataStart + 3] === 0x66 && jpeg[dataStart + 4] === 0x00 && jpeg[dataStart + 5] === 0x00
|
|
) {
|
|
return { start, end: i + len, tiff: jpeg.subarray(dataStart + 6, i + len) };
|
|
}
|
|
i += len;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// --- serialize -------------------------------------------------------------
|
|
|
|
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[], interop: Entry[]): Uint8Array {
|
|
const ifdLen = (n: number) => 2 + n * 12 + 4;
|
|
const outLen = (es: Entry[]) => {
|
|
let s = 0;
|
|
for (const e of es) if (e.data.length > 4) s += (e.data.length + 1) & ~1;
|
|
return s;
|
|
};
|
|
|
|
const o0 = 8;
|
|
const d0 = o0 + ifdLen(ifd0.length);
|
|
const oE = d0 + outLen(ifd0);
|
|
const dE = oE + ifdLen(exif.length);
|
|
const oG = dE + outLen(exif);
|
|
const dG = oG + ifdLen(gps.length);
|
|
const oI = dG + outLen(gps);
|
|
const dI = oI + ifdLen(interop.length);
|
|
const t = new Uint8Array(dI + outLen(interop));
|
|
|
|
t[0] = 0x49; t[1] = 0x49; t[2] = 0x2a; t[3] = 0x00; // "II", 42
|
|
u32w(t, 4, o0);
|
|
|
|
const writeIfd = (off: number, es: Entry[], dataOff: number): void => {
|
|
u16w(t, off, es.length);
|
|
let c = dataOff;
|
|
es.forEach((e, i) => {
|
|
const p = off + 2 + i * 12;
|
|
u16w(t, p, e.tag);
|
|
u16w(t, p + 2, e.type);
|
|
u32w(t, p + 4, e.count);
|
|
if (e.ref === 'exif') { u32w(t, p + 8, oE); return; }
|
|
if (e.ref === 'gps') { u32w(t, p + 8, oG); return; }
|
|
if (e.ref === 'interop') { u32w(t, p + 8, oI); return; }
|
|
if (e.data.length <= 4) { t.set(e.data, p + 8); return; }
|
|
u32w(t, p + 8, c);
|
|
t.set(e.data, c);
|
|
c += (e.data.length + 1) & ~1; // values start on a word boundary
|
|
});
|
|
u32w(t, off + 2 + es.length * 12, 0); // no IFD1
|
|
};
|
|
|
|
writeIfd(o0, ifd0, d0);
|
|
writeIfd(oE, exif, dE);
|
|
writeIfd(oG, gps, dG);
|
|
writeIfd(oI, interop, dI);
|
|
return t;
|
|
}
|
|
|
|
/**
|
|
* Rebuilds the JPEG's APP1/EXIF block: `sourceJpeg` (the loaded photo) donates
|
|
* every tag this module does not own; `stamp` sets the ones the app owns.
|
|
* Returns a new array; input is never modified. A JPEG that cannot take the
|
|
* segment (not a JPEG, EXIF larger than 64 KB) comes back untouched.
|
|
*/
|
|
export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: Uint8Array | null): Uint8Array {
|
|
if (jpeg.length < 4 || jpeg[0] !== 0xff || jpeg[1] !== 0xd8) return jpeg;
|
|
|
|
// An unreadable source (content:// uri, IO error) must not wipe the tags the
|
|
// jpeg already carries, so fall back to the jpeg's own EXIF block.
|
|
const src = findExifSegment(sourceJpeg && sourceJpeg.length > 4 ? sourceJpeg : jpeg);
|
|
const parsed = src ? parseTiff(src.tiff) : { ifd0: [], exif: [], gps: [], interop: [] };
|
|
const ifd0 = new Map(parsed.ifd0.map((e) => [e.tag, e]));
|
|
const exif = new Map(parsed.exif.map((e) => [e.tag, e]));
|
|
const gps = new Map(parsed.gps.map((e) => [e.tag, e]));
|
|
const interop = new Map(parsed.interop.map((e) => [e.tag, e]));
|
|
|
|
const now = exifDate(stamp.dateTime ?? new Date());
|
|
const keepSourceDate = !stamp.dateTime;
|
|
const sourceDate = asciiValue(ifd0.get(0x0132)) ?? asciiValue(exif.get(0x9003));
|
|
const dateStr = keepSourceDate ? sourceDate ?? now : now;
|
|
|
|
if (!keepSourceDate || !ifd0.has(0x0132)) ifd0.set(0x0132, asciiEntry(0x0132, dateStr));
|
|
if (!keepSourceDate || !exif.has(0x9003)) exif.set(0x9003, asciiEntry(0x9003, dateStr));
|
|
if (!keepSourceDate || !exif.has(0x9004)) exif.set(0x9004, asciiEntry(0x9004, dateStr));
|
|
if (!keepSourceDate) {
|
|
// We own the timestamp for camera shots, so we also state its zone.
|
|
const off = asciiEntry(0x9010, tzOffset(stamp.dateTime as Date));
|
|
ifd0.set(0x9010, off); // OffsetTime (0th IFD)
|
|
exif.set(0x9011, { ...off, tag: 0x9011 }); // OffsetTimeOriginal
|
|
exif.set(0x9012, { ...off, tag: 0x9012 }); // OffsetTimeDigitized
|
|
}
|
|
ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE));
|
|
ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight
|
|
|
|
// Baseline tags a conforming EXIF block is expected to carry. Framing apps
|
|
// reject a file that omits them, and HALs (the emulator's especially) do not
|
|
// always supply them, so fill the gaps and keep whatever the source had.
|
|
if (!exif.has(0x9000)) exif.set(0x9000, undefinedEntry(0x9000, '0230')); // ExifVersion
|
|
if (!exif.has(0xa000)) exif.set(0xa000, undefinedEntry(0xa000, '0100')); // FlashPixVersion
|
|
if (!exif.has(0x9101)) exif.set(0x9101, { tag: 0x9101, type: UNDEFINED, count: 4, data: new Uint8Array([1, 2, 3, 0]) }); // YCbCr
|
|
if (!exif.has(0xa001)) exif.set(0xa001, shortEntry(0xa001, 65535)); // ColorSpace = uncalibrated
|
|
if (!interop.has(0x0001)) interop.set(0x0001, asciiEntry(0x0001, 'R98')); // InteropIndex
|
|
if (!interop.has(0x0002)) interop.set(0x0002, undefinedEntry(0x0002, '0100')); // InteropVersion
|
|
|
|
if (stamp.width && stamp.height) {
|
|
ifd0.set(0x0100, longEntry(0x0100, stamp.width));
|
|
ifd0.set(0x0101, longEntry(0x0101, stamp.height));
|
|
exif.set(0xa002, longEntry(0xa002, stamp.width)); // PixelXDimension
|
|
exif.set(0xa003, longEntry(0xa003, stamp.height)); // PixelYDimension
|
|
}
|
|
|
|
if (stamp.dpi && stamp.dpi > 0) {
|
|
ifd0.set(0x011a, rationalEntry(0x011a, stamp.dpi, 1));
|
|
ifd0.set(0x011b, rationalEntry(0x011b, stamp.dpi, 1));
|
|
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.
|
|
const xiaomi = !!stamp.device && /xiaomi|redmi|poco/i.test(stamp.device.manufacturer);
|
|
if (stamp.device && xiaomi && !exif.has(0x889e)) {
|
|
exif.set(0x889e, asciiEntry(0x889e, watermarkBlob(stamp.device)));
|
|
}
|
|
|
|
// Stock Xiaomi photos name the device in 0x9a00 and in Model (0x0110) as the
|
|
// MARKET name ("Xiaomi 14 Ultra"); the framing tool matches those against its
|
|
// known-device list and reports an unknown device otherwise. A CameraX capture
|
|
// only has the raw codename, so state the market name. A source that already
|
|
// carries 0x9a00 is a stock photo of some other device — leave it alone.
|
|
if (stamp.device && xiaomi && !ifd0.has(0x9a00)) {
|
|
const market = (stamp.device.marketName || stamp.device.model).trim();
|
|
if (market) {
|
|
ifd0.set(0x9a00, asciiEntry(0x9a00, market));
|
|
ifd0.set(0x0110, asciiEntry(0x0110, market));
|
|
}
|
|
}
|
|
|
|
// Place name the frame prints (0x889d) — stock writes it in both IFDs.
|
|
const place = stamp.locationName?.trim();
|
|
if (place) {
|
|
if (!ifd0.has(0x889d)) ifd0.set(0x889d, asciiEntry(0x889d, place));
|
|
if (!exif.has(0x889d)) exif.set(0x889d, asciiEntry(0x889d, place));
|
|
}
|
|
|
|
if (stamp.gps) {
|
|
const { latitude: lat, longitude: lon } = stamp.gps;
|
|
gps.set(0x0001, asciiEntry(0x0001, lat >= 0 ? 'N' : 'S'));
|
|
gps.set(0x0002, gpsCoordEntry(0x0002, lat));
|
|
gps.set(0x0003, asciiEntry(0x0003, lon >= 0 ? 'E' : 'W'));
|
|
gps.set(0x0004, gpsCoordEntry(0x0004, lon));
|
|
}
|
|
|
|
const list0 = [...ifd0.values()];
|
|
if (exif.size > 0) list0.push({ tag: 0x8769, type: LONG, count: 1, data: new Uint8Array(4), ref: 'exif' });
|
|
if (gps.size > 0) list0.push({ tag: 0x8825, type: LONG, count: 1, data: new Uint8Array(4), ref: 'gps' });
|
|
list0.sort((a, b) => a.tag - b.tag);
|
|
const listE = [...exif.values()].sort((a, b) => a.tag - b.tag);
|
|
const listG = [...gps.values()].sort((a, b) => a.tag - b.tag);
|
|
if (interop.size > 0) listE.push({ tag: 0xa005, type: LONG, count: 1, data: new Uint8Array(4), ref: 'interop' });
|
|
listE.sort((a, b) => a.tag - b.tag);
|
|
const listI = [...interop.values()].sort((a, b) => a.tag - b.tag);
|
|
|
|
const tiff = serializeTiff(list0, listE, listG, listI);
|
|
const payload = 2 + 6 + tiff.length;
|
|
if (payload > 0xffff) return jpeg; // APP1 length is a 16-bit field
|
|
|
|
const app1 = new Uint8Array(2 + payload);
|
|
app1[0] = 0xff; app1[1] = 0xe1;
|
|
// APP1 length is a big-endian 16-bit field (the TIFF block after it is little-endian)
|
|
app1[2] = (payload >> 8) & 0xff;
|
|
app1[3] = payload & 0xff;
|
|
app1.set([0x45, 0x78, 0x69, 0x66, 0x00, 0x00], 4); // "Exif\0\0"
|
|
app1.set(tiff, 10);
|
|
|
|
const old = findExifSegment(jpeg);
|
|
const head = old ? jpeg.subarray(0, old.start) : jpeg.subarray(0, 2);
|
|
const tail = old ? jpeg.subarray(old.end) : jpeg.subarray(2);
|
|
const out = new Uint8Array(head.length + app1.length + tail.length);
|
|
out.set(head, 0);
|
|
out.set(app1, head.length);
|
|
out.set(tail, head.length + app1.length);
|
|
return out;
|
|
}
|