diff --git a/PLAN-2026-09-09.md b/PLAN-2026-09-09.md index 9b85802..d67aa03 100644 --- a/PLAN-2026-09-09.md +++ b/PLAN-2026-09-09.md @@ -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. diff --git a/src/utils/exifWrite.ts b/src/utils/exifWrite.ts index 9024280..544e38a 100644 --- a/src/utils/exifWrite.ts +++ b/src/utils/exifWrite.ts @@ -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