Files
RecipesCam/docker/frontend/shared/utils/exifWrite.ts
T
3dtours 0627f8dd91 web: keep a saved photo's look in EXIF and its own row
EXPORT no longer burns the caption strip: the pixels stay the photo's own
and the look travels as metadata — ImageDescription (0x010e) for the tag,
UserComment (0x9286, ASCII header) for the recipe JSON.

SAVE PHOTO now stores the look with the frame (photos.recipe) and the
uploader's consent for the community film strip (photos.consent, PATCH
/api/photos/:id for the owner). The landing reel skips non-consented frames,
and a new MY PHOTOS tab lists the account's saves, reopens one with the
settings it was stored with, and carries the two consent switches.
2026-09-18 12:49:05 +07:00

498 lines
21 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;
// 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<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 };
}
// 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<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
// 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;
}