feat(exif): stamp RC_<ts> filenames + EXIF on capture and export

This commit is contained in:
2026-09-11 17:59:44 +07:00
parent 6faef4aef7
commit 62b9735867
6 changed files with 478 additions and 14 deletions
+11
View File
@@ -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_<yyyymmdd_hhmmss>.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_<ts>_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_<ts>_exported.jpg, and the loaded photo's
// EXIF (camera, dates, GPS) is carried into the file.
exported: true,
watermark: wmForExport(),
watermarkRotation: wmRotation,
gpsWatermark: gpsWm,
+26
View File
@@ -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_<ts>` khi chụp và khi export (9/9/2026)
Trước: ảnh lưu ra `camrecipe_pro_export_<epoch>.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_<yyyymmdd_hhmmss>[_exported].jpg`; `nextPhotoPath()` cộng 1000 ms khi tên đã tồn tại (tránh trùng tên và trùng cache `<Image>`).
- `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.
+349
View File
@@ -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<number, number> = {
[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<number>([
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<number, number> } => {
const out: { list: Entry[]; ptr: Record<number, number> } = { 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;
}
+28 -8
View File
@@ -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_<ts>_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 <Image> 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 <Image> 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) {
+17 -6
View File
@@ -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_<ts>_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 <Image> 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) {
+47
View File
@@ -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_<yyyymmdd_hhmmss>[_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 <Image> 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<Uint8Array | null> {
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<void> {
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);
}
}