fix(exif): write a complete, standard EXIF block so framing tools can read it
Match the layout the sample camera file uses: a fourth Interoperability IFD (0xa005 = R98/0100) and the baseline tags the emulator HAL omits (ExifVersion 0230, FlashPixVersion 0100, ComponentsConfiguration, ColorSpace), plus the OffsetTime/OffsetTimeOriginal/OffsetTimeDigitized tags for shots this app timestamps. Source-owned tags are still kept as-is. Also fix swapToLittleEndian: Buffer.slice() aliases, so the big-endian swap was mutating the caller's bytes (a Xiaomi source read back as ISO 36865 instead of 400). Use new Uint8Array(data).
This commit is contained in:
+59
-11
@@ -3,7 +3,9 @@
|
||||
// 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, GPS, pixel dimensions.
|
||||
// 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
|
||||
@@ -30,7 +32,7 @@ interface Entry {
|
||||
type: number;
|
||||
count: number;
|
||||
data: Uint8Array;
|
||||
ref?: 'exif' | 'gps'; // IFD pointer entry — value patched during layout
|
||||
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;
|
||||
@@ -79,6 +81,13 @@ function shortEntry(tag: number, v: number): Entry {
|
||||
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);
|
||||
@@ -114,7 +123,9 @@ function gpsCoordEntry(tag: number, value: number): Entry {
|
||||
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;
|
||||
const out = data.slice();
|
||||
// `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];
|
||||
@@ -138,16 +149,26 @@ function exifDate(d: Date): string {
|
||||
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)}`;
|
||||
}
|
||||
|
||||
// --- parse -----------------------------------------------------------------
|
||||
|
||||
interface ParsedTiff {
|
||||
ifd0: Entry[];
|
||||
exif: Entry[];
|
||||
gps: Entry[];
|
||||
interop: Entry[];
|
||||
}
|
||||
|
||||
function parseTiff(tiff: Uint8Array): ParsedTiff {
|
||||
const none: ParsedTiff = { ifd0: [], exif: [], gps: [] };
|
||||
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;
|
||||
@@ -169,8 +190,8 @@ function parseTiff(tiff: Uint8Array): ParsedTiff {
|
||||
const tag = u16(p);
|
||||
const type = u16(p + 2);
|
||||
const count = u32(p + 4);
|
||||
if (tag === 0x8769 || tag === 0x8825) {
|
||||
out.ptr[tag] = u32(p + 8);
|
||||
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];
|
||||
@@ -190,10 +211,12 @@ function parseTiff(tiff: Uint8Array): ParsedTiff {
|
||||
};
|
||||
|
||||
const a = read(u32(4));
|
||||
const exifRead = a.ptr[0x8769] ? read(a.ptr[0x8769]) : { list: [], ptr: {} };
|
||||
return {
|
||||
ifd0: a.list,
|
||||
exif: a.ptr[0x8769] ? read(a.ptr[0x8769]).list : [],
|
||||
exif: exifRead.list,
|
||||
gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).list : [],
|
||||
interop: exifRead.ptr[0xa005] ? read(exifRead.ptr[0xa005]).list : [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -227,7 +250,7 @@ function findExifSegment(jpeg: Uint8Array): { start: number; end: number; tiff:
|
||||
|
||||
// --- serialize -------------------------------------------------------------
|
||||
|
||||
function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[]): Uint8Array {
|
||||
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;
|
||||
@@ -241,7 +264,9 @@ function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[]): Uint8Array {
|
||||
const dE = oE + ifdLen(exif.length);
|
||||
const oG = dE + outLen(exif);
|
||||
const dG = oG + ifdLen(gps.length);
|
||||
const t = new Uint8Array(dG + outLen(gps));
|
||||
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);
|
||||
@@ -256,6 +281,7 @@ function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[]): Uint8Array {
|
||||
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);
|
||||
@@ -267,6 +293,7 @@ function serializeTiff(ifd0: Entry[], exif: Entry[], gps: Entry[]): Uint8Array {
|
||||
writeIfd(o0, ifd0, d0);
|
||||
writeIfd(oE, exif, dE);
|
||||
writeIfd(oG, gps, dG);
|
||||
writeIfd(oI, interop, dI);
|
||||
return t;
|
||||
}
|
||||
|
||||
@@ -282,10 +309,11 @@ export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: U
|
||||
// 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: [] };
|
||||
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;
|
||||
@@ -295,9 +323,26 @@ export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: U
|
||||
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));
|
||||
@@ -325,8 +370,11 @@ export function writeJpegExif(jpeg: Uint8Array, stamp: ExifStamp, sourceJpeg?: U
|
||||
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);
|
||||
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user