// 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; // What the studio would once have burned into the pixels: the frame's tagline // as ImageDescription (0x010e) and the whole look as JSON in UserComment // (0x9286). Kept in metadata only — an exported file must stay clean. imageDescription?: string | null; userComment?: string | 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 = { [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([ 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 }; } // UserComment (0x9286) must open with an 8-byte character-code header; the rest // is UTF-8, which every reader that accepts "ASCII" here decodes correctly. function userCommentEntry(text: string): Entry { const body = new TextEncoder().encode(text); const data = new Uint8Array(8 + body.length); data.set([0x41, 0x53, 0x43, 0x49, 0x49, 0, 0, 0]); // "ASCII\0\0\0" data.set(body, 8); return { tag: 0x9286, 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 } => { const out: { list: Entry[]; ptr: Record } = { 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 // The frame's own labels, metadata-only: ImageDescription names the look and // UserComment carries the settings so the file reopens as it was saved. const desc = stamp.imageDescription?.trim(); if (desc) ifd0.set(0x010e, asciiEntry(0x010e, desc)); const comment = stamp.userComment?.trim(); if (comment) exif.set(0x9286, userCommentEntry(comment)); // 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; }