fix(library): read a frame's date with one set of options

exifr keeps a page-long cache of the options object it is handed, keyed by
that object itself, so a fresh literal per frame pinned a frame's worth of
tag sets (about thirty Sets) for as long as the tab lived: 3601 frames left
~16MB of heap behind, 9601 left ~50MB, and a roll of a hundred thousand
frames is what a tab cannot hold.

One options object per call site now, which is what that cache is for.
Measured on the deployed bundle: 3601 frames rest at 8MB, not 23MB.

scripts/ram-check.mjs reads a roll of many folders, queues folders behind
the reading in front of them and reports the heap left after a forced
collection, which is how those numbers were taken.
This commit is contained in:
2026-10-09 14:52:28 +07:00
parent 9fc7b89e74
commit 29759721cb
2 changed files with 363 additions and 7 deletions
+25 -7
View File
@@ -151,11 +151,31 @@ async function shrinkOrKeep(bytes: Uint8Array, maxDim: number, maxBytes: number)
// hands it back as a Date in ours — which is what a camera prints on a stamp, and
// what someone opening a 2019 photo today expects to read under the coordinates.
// Null when the file carries no date at all; the caller falls back to its clock.
// `exifr` keeps a private cache of the parsed options it is handed, keyed by the
// options object itself, and every entry in it lives as long as the page. A
// fresh literal per frame is therefore a frame's worth of tag sets held forever:
// reading a roll of a hundred thousand frames is what fills the tab. So the
// options are written once here and the same object is handed over every time —
// which is also what the cache is for.
const DATE_OPTIONS = { pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'] };
const SPEC_OPTIONS = {
pick: [
'Make',
'Model',
'ISO',
'ISOSpeedRatings',
'FocalLength',
'FNumber',
'ExposureTime',
'ExifImageWidth',
'ExifImageHeight',
],
};
const ISO_OPTIONS = { pick: ['ISO', 'ISOSpeedRatings'] };
export async function readCapturedAt(bytes: Uint8Array): Promise<number | null> {
try {
const tags = (await exifr.parse(bytes, {
pick: ['DateTimeOriginal', 'CreateDate', 'ModifyDate'],
})) as Record<string, unknown> | undefined;
const tags = (await exifr.parse(bytes, DATE_OPTIONS)) as Record<string, unknown> | undefined;
for (const tag of ['DateTimeOriginal', 'CreateDate', 'ModifyDate']) {
const value = tags?.[tag];
if (value instanceof Date && Number.isFinite(value.getTime())) return value.getTime();
@@ -187,9 +207,7 @@ export interface ShotSpecs {
export async function readSpecs(bytes: Uint8Array): Promise<ShotSpecs | null> {
try {
const tags = (await exifr.parse(bytes, {
pick: ['Make', 'Model', 'ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
})) as Record<string, unknown> | undefined;
const tags = (await exifr.parse(bytes, SPEC_OPTIONS)) as Record<string, unknown> | undefined;
if (!tags) return null;
const num = (v: unknown): number | null => {
const n = Number(Array.isArray(v) ? v[0] : v);
@@ -263,7 +281,7 @@ export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
// the caller then prints "AUTO" instead of inventing a number.
export async function readIso(bytes: Uint8Array): Promise<number | null> {
try {
const tags = (await exifr.parse(bytes, { pick: ['ISO', 'ISOSpeedRatings'] })) as
const tags = (await exifr.parse(bytes, ISO_OPTIONS)) as
| { ISO?: unknown; ISOSpeedRatings?: unknown }
| undefined;
const raw = tags?.ISO ?? tags?.ISOSpeedRatings;