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:
@@ -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
@@ -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