feat(exif): stamp RC_<ts> filenames + EXIF on capture and export
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user