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:
2026-09-11 18:28:02 +07:00
parent 62b9735867
commit 33123d47fd
2 changed files with 105 additions and 11 deletions
+46
View File
@@ -636,3 +636,49 @@ Emulator không có fix GPS (`getCurrentGPS` → `ERR_CURRENT_LOCATION_IS_UNAVAI
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
## 21. EXIF chuẩn theo ảnh mẫu IMG_20260830_140429.jpg (2026-09-11)
Ảnh mẫu (Xiaomi 14 Ultra, 4096×3072, 7 165 975 B) đọc bằng parser tự viết: byte-order `MM`
(big-endian), APP1/Exif là segment **đầu tiên** ngay sau SOI (không có APP0/JFIF), TIFF gồm 4 IFD:
IFD0 (17 entry) → Exif IFD (43 entry) → GPS IFD (11 entry) → Interop IFD (`0xa005` =
{`0x1` `'R98'`, `0x2` `'0100'`}), rồi IFD1 (thumbnail) nối sau IFD0. Tag nằm rải ở cả IFD0 lẫn
Exif IFD chứ không gom một chỗ, và **không sắp theo thứ tự tăng dần**.
Đối chiếu file app ghi ra: tập tag IFD0/GPS khớp mẫu, nhưng thiếu Interop IFD, thiếu
`OffsetTime`/`OffsetTimeOriginal`/`OffsetTimeDigitized`, và khi HAL nghèo tag thì thiếu
`ExifVersion`/`FlashPixVersion`/`ComponentsConfiguration`/`ColorSpace` — đúng nhóm mà phần mềm
tạo khung dùng để nhận dạng thông số.
`src/utils/exifWrite.ts`:
- Thêm IFD thứ 4 (Interop) vào layout + con trỏ `0xa005` trong Exif IFD; parse đọc Interop của
nguồn qua `0xa005`.
- Điền mặc định khi nguồn thiếu: `ExifVersion 0x9000` = `'0230'`, `FlashPixVersion 0xa000` =
`'0100'`, `ComponentsConfiguration 0x9101` = {1,2,3,0}, `ColorSpace 0xa001` = 65535, Interop
`'R98'`/`'0100'`. Nguồn có sẵn thì giữ nguyên, không ghi đè.
- Ghi `OffsetTime 0x9010` (IFD0) + `OffsetTimeOriginal 0x9011` + `OffsetTimeDigitized 0x9012`
(Exif IFD) bằng offset cục bộ `±HH:MM` khi app sở hữu mốc thời gian (ảnh chụp). Ảnh export giữ
ngày của file nguồn thì không bịa offset.
- Fix bug thật: `swapToLittleEndian` dùng `data.slice()` — với Node `Buffer`, `slice()` trả về
*view* chứ không copy, nên hàm swap ngược byte của chính buffer nguồn (đọc ra ISO 36865 thay vì
400 khi nguồn big-endian). Nay dùng `new Uint8Array(data)`.
- Vẫn chủ động bỏ IFD1/thumbnail và MakerNote (ponytail comment: offset tuyệt đối không sống sót
khi TIFF bị dời; thumbnail mang pixel trước khi export).
**Đo lại** (codec Node + PIL + máy thật):
| Kiểm chứng | Kết quả |
| --- | --- |
| `exifchk/run.js` | SELF-CHECK PASSED, gồm check "source bytes left untouched" |
| `exifchk/verify.py` | PIL VERIFY PASSED (thêm Interop/ExifVersion/OffsetTime/ColorSpace/ComponentsConfiguration) |
| round-trip ảnh mẫu qua writer | mọi tag Xiaomi giữ nguyên: ISO 400, ExposureProgram 2, MeteringMode 2, FocalLengthIn35mmFilm 75, GPS 15°23'54.67"N 108°19'22.47"E; thêm Interop |
| chụp `RC_20260911_182721.jpg` (emulator) | Interop `R98`/`'0100'`; `0x9010`/`0x9011`/`0x9012` = `+07:00`; `ComponentsConfiguration`, `FlashPixVersion`, `ColorSpace` có mặt; Software `RecipesCam 1.2`; Orientation 1; 960×1280; giữ Make/Model HAL |
`ExifVersion` của ảnh chụp trên emulator vẫn là `'0210'` vì HAL đã cung cấp — đúng thiết kế: chỉ
điền khi nguồn thiếu.
**Không thể khắc phục**: nếu phần mềm tạo khung cần tag riêng của nhà sản xuất (MakerNote) hoặc tra
cứu cơ sở dữ liệu thiết bị theo Make/Model, app không giả được — MakerNote của máy khác chứa offset
tuyệt đối không dùng lại được, còn Make/Model là do HAL trả về.
- `tsc --noEmit` vẫn **14 lỗi** đúng baseline.
- Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success.
+59 -11
View File
@@ -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