diff --git a/App.tsx b/App.tsx index f54474a..31a7295 100644 --- a/App.tsx +++ b/App.tsx @@ -934,6 +934,7 @@ export default function App() { } } + const capturedAt = Date.now(); // EXIF DateTimeOriginal / file name const { filePath } = await photoOutput.capturePhotoToFile({ enableShutterSound: shutterSound === 'on' }, {}); const sourceUri = toUri(filePath); @@ -974,6 +975,10 @@ export default function App() { useGeotag, }; const options = { + // Live capture: gallery name RC_.jpg and the + // shutter instant as the EXIF capture date. + exported: false, + capturedAt, // 300dpi header + full-res screen sharpen. No evFromCamera: the camera // AE bias is never written, so the export applies the same software EV // gain the preview shows. @@ -1072,6 +1077,9 @@ export default function App() { // the photo as seen (own aspect), polaroid/wall keep their ratio crop. { aspect: libraryAspect, + // Library export: RC__exported.jpg, and the loaded photo's + // EXIF (camera, dates, GPS) is carried into the file. + exported: true, watermark: wmForExport(), watermarkRotation: wmRotation, gpsWatermark: gpsWm, @@ -1093,6 +1101,9 @@ export default function App() { // the photo as seen (own aspect), polaroid/wall keep their ratio crop. { aspect: libraryAspect, + // Library export: RC__exported.jpg, and the loaded photo's + // EXIF (camera, dates, GPS) is carried into the file. + exported: true, watermark: wmForExport(), watermarkRotation: wmRotation, gpsWatermark: gpsWm, diff --git a/PLAN-2026-09-09.md b/PLAN-2026-09-09.md index e6c8862..9b85802 100644 --- a/PLAN-2026-09-09.md +++ b/PLAN-2026-09-09.md @@ -610,3 +610,29 @@ Rect nội dung khác lúc strip đang mở (y 345–1424) là do viewport cao h - `tsc --noEmit` vẫn **14 lỗi** đúng baseline. - Build `assembleRelease` → BUILD SUCCESSFUL; `adb install -r` → Success. - Đã trả STARTUP MODE về DEFAULT sau khi test. + +## 20. EXIF + tên file `RC_` khi chụp và khi export (9/9/2026) + +Trước: ảnh lưu ra `camrecipe_pro_export_.jpg`, không có EXIF nào ngoài thứ Skia còn giữ được sau re-encode (thực tế là mất sạch). + +**Sửa:** + +- `src/utils/exifWrite.ts` (mới, thuần, không import): dựng lại segment APP1/EXIF từ byte JPEG — đọc TIFF của ảnh nguồn, giữ mọi tag module không sở hữu, ghi đè `Software 0x0131` = `RecipesCam 1.2`, `DateTime 0x0132` / `DateTimeOriginal 0x9003` / `DateTimeDigitized 0x9004`, `Orientation 0x0112` = 1, `ImageWidth/Length 0x0100/0x0101`, `PixelX/YDimension 0xa002/0xa003`, resolution khi có dpi, GPS (0x0001–0x0004). Bỏ IFD1/thumbnail, MakerNote và con trỏ IFD cũ. Độ dài APP1 ghi **big-endian** (khác khối TIFF bên trong — bug đầu tiên của module). +- `src/utils/photoMeta.ts` (mới): `rcPhotoName(exported)` → `RC_[_exported].jpg`; `nextPhotoPath()` cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache ``). +- `src/utils/exportEngine.ts`: thay `nextExportFile()` bằng `nextPhotoPath(options.exported)`, thêm bước 9c gọi `writeJpegExif` với `capturedAt`/GPS/dims/dpi và byte của ảnh nguồn. +- `src/utils/nativeExport.ts`: cùng tên file, `stampExifOnFile()` sau `processPhotoAsync` (không truyền width/height — JS không biết kích thước Kotlin render). +- `App.tsx`: `capturedAt = Date.now()` ngay trước khi chụp; `exported: false` cho camera, `exported: true` cho cả 2 nhánh option của library. + +Ảnh camera truyền `dateTime` = thời điểm bấm shutter nên EXIF là lúc chụp; ảnh library truyền `null` nên giữ nguyên ngày gốc của file đã load. Khi `sourceJpeg` không đọc được (uri `content://`, lỗi IO) module lấy chính khối EXIF của file đang ghi làm nền, không xoá tag. + +**Đo trên máy** (emulator-5554, 1080×2400): + +| Thao tác | File | EXIF đọc lại bằng PIL | +| --- | --- | --- | +| chụp (GPS watermark OFF) | `RC_20260911_175607.jpg` | Software `RecipesCam 1.2`, DateTime/DateTimeOriginal 17:56:06, Orientation 1, 960×1280, giữ Make/Model của camera HAL | +| load `RC_libtest.jpg` → export | `RC_20260911_175905_exported.jpg` | giữ Make `OldCam`, Model `X100`, ISO 200, ExposureTime 1/125, FNumber 2.8, FocalLength 35, DateTime 2019:05:04 03:02:01, GPS 48.8584/2.2945; Software `RecipesCam 1.2`; Orientation 1 | + +Emulator không có fix GPS (`getCurrentGPS` → `ERR_CURRENT_LOCATION_IS_UNAVAILABLE` dù `adb emu geo fix` trả OK và `location_mode=3`), nên nhánh ghi GPS từ thiết bị chỉ kiểm được ở mức codec: `exifchk/verify.py` đọc lại đúng lat/lon do `writeJpegExif` ghi (10.776889/106.700806) và PASS. + +- `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 new file mode 100644 index 0000000..9024280 --- /dev/null +++ b/src/utils/exifWrite.ts @@ -0,0 +1,349 @@ +// 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, GPS, pixel dimensions. +// 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; + width?: number | null; + height?: number | null; + dpi?: number | null; +} + +interface Entry { + tag: number; + type: number; + count: number; + data: Uint8Array; + ref?: 'exif' | 'gps'; // 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 = { + [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([ + 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 -------------------------------------------------------- + +function asciiEntry(tag: number, text: string): Entry { + const data = new Uint8Array(text.length + 1); // NUL-terminated, as EXIF requires + for (let i = 0; i < text.length; i++) data[i] = text.charCodeAt(i) & 0xff; + return { tag, type: ASCII, count: data.length, data }; +} + +function shortEntry(tag: number, v: number): Entry { + const data = new Uint8Array(2); + u16w(data, 0, v); + return { tag, type: SHORT, count: 1, 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; + const out = data.slice(); + 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())}`; +} + +// --- parse ----------------------------------------------------------------- + +interface ParsedTiff { + ifd0: Entry[]; + exif: Entry[]; + gps: Entry[]; +} + +function parseTiff(tiff: Uint8Array): ParsedTiff { + const none: ParsedTiff = { ifd0: [], exif: [], gps: [] }; + 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 } => { + const out: { list: Entry[]; ptr: Record } = { 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) { + out.ptr[tag] = u32(p + 8); + 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)); + return { + ifd0: a.list, + exif: a.ptr[0x8769] ? read(a.ptr[0x8769]).list : [], + gps: a.ptr[0x8825] ? read(a.ptr[0x8825]).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[]): 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 t = new Uint8Array(dG + outLen(gps)); + + 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.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); + 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: [] }; + 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 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)); + ifd0.set(0x0131, asciiEntry(0x0131, stamp.software ?? SOFTWARE)); + ifd0.set(0x0112, shortEntry(0x0112, 1)); // upright — the pixels are already straight + + 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 + } + + 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); + + const tiff = serializeTiff(list0, listE, listG); + 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; +} diff --git a/src/utils/exportEngine.ts b/src/utils/exportEngine.ts index a16ea18..93cc73d 100644 --- a/src/utils/exportEngine.ts +++ b/src/utils/exportEngine.ts @@ -9,6 +9,8 @@ import { TONE_SKSL, getToneUniforms, toneIsActive, toneUniformArray } from './to import { CINEMA_SKSL, getCinemaUniforms, cinemaIsActive } from './cinemaShader'; import { drawFrameOnCanvas, polaroidLayout, POLAROID_CARD, POLAROID_WIN_W, wallframeLayout, WALLFRAME_W, WALLFRAME_H, WALLFRAME_LAND_W, WALLFRAME_LAND_H } from './frameUtils'; import { patchJpegDpi } from './jpegDpi'; +import { writeJpegExif } from './exifWrite'; +import { nextPhotoPath, readPhotoBytes } from './photoMeta'; import { applyPhotoRotation } from './skiaImage'; export interface ExportResult { @@ -76,6 +78,13 @@ export interface ExportOptions { // see exactly the orientation the preview showed. photoRotation?: 0 | 90 | 180 | 270; photoStraighten?: number; + // This export came from a photo loaded out of the library (not a live + // capture): the file is named RC__exported.jpg and the loaded photo's + // own EXIF lives on in the output. + exported?: boolean; + // Shutter instant of a live capture (ms). Absent = keep the loaded photo's + // own DateTimeOriginal. + capturedAt?: number | null; } // Lightroom-style "Screen" output sharpen: subtle unsharp mask. A 3x3 @@ -111,12 +120,6 @@ function screenSharpenImage(image: SkImage, amount = 0.5): SkImage { return sharpened; } -// Unique filename per export. A fixed path made every export share one URI, and -// RN's cache (keyed by URI) then kept showing the previous photo in the -// thumbnail after a new capture overwrote the file. A fresh URI per export busts -// that cache. -const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`; - export async function processAndExportPhoto( sourceUri: string, recipe: Recipe, @@ -706,10 +709,27 @@ export async function processAndExportPhoto( bytes = patchJpegDpi(bytes, options.dpi); } + // 9c. EXIF. Skia's re-encode dropped the loaded photo's metadata block, so + // rebuild it here: the source photo donates every tag this app does not + // own (camera, lens, exposure) and the fields it is responsible for are + // stamped on top — software + version, capture date, GPS, pixel size. + bytes = writeJpegExif( + bytes, + { + dateTime: options?.capturedAt ? new Date(options.capturedAt) : null, + gps: useGeotag && gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null, + width: resultImage.width(), + height: resultImage.height(), + dpi: options?.dpi ?? null, + }, + await readPhotoBytes(sourceUri) + ); + // 10. Write binary bytes to a unique temporary local file. The unique name // is load-bearing: a fixed URI would be served from RN's cache and - // the thumbnail/preview would keep showing the previous export. - const exportFile = nextExportFile(); + // the thumbnail/preview would keep showing the previous export (and the + // gallery shows this basename). + const exportFile = nextPhotoPath(options?.exported === true); try { new File(exportFile).write(bytes); } catch (e) { diff --git a/src/utils/nativeExport.ts b/src/utils/nativeExport.ts index aae052b..16b0ff1 100644 --- a/src/utils/nativeExport.ts +++ b/src/utils/nativeExport.ts @@ -7,12 +7,12 @@ // Deliberately does NOT import exportEngine or Skia: when the native path is // active the Skia engine must not even load. import RecipescamExport, { PhotoAdjust } from '../../modules/recipescam-export'; -import * as FileSystem from 'expo-file-system/legacy'; import * as MediaLibrary from 'expo-media-library/legacy'; import { Recipe, GPSInfo, FrameId, AspectRatio, ColorAdjustments } from '../types'; import { getSkiaColorMatrix, applyExposureGain } from './colorUtils'; import { getToneUniforms } from './toneShader'; import { getCinemaUniforms } from './cinemaShader'; +import { nextPhotoPath, stampExifOnFile } from './photoMeta'; export interface NativeExportResult { uri: string; @@ -37,15 +37,15 @@ export interface NativeExportOptions { // module renders geo captions but has no text-watermark draw (ponytail: add // it when native parity with the Skia engine is completed). watermark?: { text: string; x: number; y: number } | null; + // Gallery filename + EXIF, same contract as ExportOptions.exported / + // .capturedAt: RC__exported.jpg keeps the loaded photo's own EXIF. + exported?: boolean; + capturedAt?: number | null; } // The Kotlin module reads plain absolute paths, not file:// URIs. const stripScheme = (uri: string) => uri.replace(/^file:\/\//, ''); -// Unique filename per export (same cache-bust rationale as exportEngine: a fixed -// path would serve stale cache entries). -const nextExportFile = () => `${FileSystem.cacheDirectory}camrecipe_pro_export_${Date.now()}.jpg`; - export async function processAndExportPhotoNative( sourceUri: string, recipe: Recipe, @@ -99,9 +99,20 @@ export async function processAndExportPhotoNative( aspect, }; - const exportFile = nextExportFile(); // file:// URI (cacheDirectory) + const exportFile = nextPhotoPath(options?.exported === true); // file:// URI (cacheDirectory) const dstPath = stripScheme(exportFile); await RecipescamExport.processPhotoAsync(stripScheme(sourceUri), dstPath, matrix, null, adjust); + // Same EXIF contract as the Skia engine (step 9c). ponytail: pixel + // dimensions are left out here — Kotlin knows the rendered size, JS does not. + await stampExifOnFile( + exportFile, + { + dateTime: options?.capturedAt ? new Date(options.capturedAt) : null, + gps: useGeotag && gpsInfo ? { latitude: gpsInfo.latitude, longitude: gpsInfo.longitude } : null, + dpi: options?.dpi ?? null, + }, + sourceUri + ); const mediaPermission = await MediaLibrary.requestPermissionsAsync(); if (mediaPermission.granted) { diff --git a/src/utils/photoMeta.ts b/src/utils/photoMeta.ts new file mode 100644 index 0000000..55196b0 --- /dev/null +++ b/src/utils/photoMeta.ts @@ -0,0 +1,47 @@ +// Gallery-facing photo metadata: the RC_ filename convention and the file-level +// EXIF helpers both export engines share (the codec itself lives in exifWrite.ts). +import { File } from 'expo-file-system'; +import * as FileSystem from 'expo-file-system/legacy'; +import { writeJpegExif, SOFTWARE, type ExifStamp } from './exifWrite'; + +// RC_[_exported].jpg — the name the gallery shows for a saved +// photo. The exported variant marks a photo re-exported from the library. +export function rcPhotoName(exported: boolean, date = new Date()): string { + const p = (n: number) => String(n).padStart(2, '0'); + const stamp = `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}_${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`; + return `RC_${stamp}${exported ? '_exported' : ''}.jpg`; +} + +// Cache path for the next export. Two renders landing in the same second would +// collide on the name AND on RN's cache (keyed by URI), so the +// timestamp walks forward a second at a time instead of taking a suffix. +export function nextPhotoPath(exported: boolean): string { + let ms = Date.now(); + for (;;) { + const path = `${FileSystem.cacheDirectory}${rcPhotoName(exported, new Date(ms))}`; + if (!new File(path).exists) return path; + ms += 1000; + } +} + +export async function readPhotoBytes(uri: string): Promise { + try { + return await new File(uri).bytes(); + } catch (e) { + console.warn('EXIF source read failed:', e); + return null; + } +} + +// Stamp EXIF into a file already on disk (the native engine writes the JPEG +// itself, so those bytes never pass through JS). Never throws — a metadata +// failure must not cost the user the photo. +export async function stampExifOnFile(fileUri: string, stamp: ExifStamp, sourceUri?: string | null): Promise { + try { + const target = new File(fileUri); + const source = sourceUri ? await readPhotoBytes(sourceUri) : null; + target.write(writeJpegExif(await target.bytes(), { software: SOFTWARE, ...stamp }, source)); + } catch (e) { + console.warn('EXIF stamp failed:', e); + } +}