web: the frame under the preview says where it was shot and what it was shot with

A negative is opened to be looked at, and the two things a photographer reads
off it first are the numbers the camera wrote and where it stood when it wrote
them. The stage gave the name, the folder, the date and the weight of the file,
which is what the catalogue knows; the rest was a trip into the studio.

Both are now under the picture. The numbers are the camera's own — ISO, focal
length, aperture, shutter, frame size — printed in the order a photographer
says them, and every one the file does not carry is left out rather than stood
in for: a Fuji RAF gets no line at all, a Panasonic RW2 gets the glass and the
shutter and no ISO, and a JPEG gets the lot. Where the frame was shot is the
GPS it carries, named by the geocoder when one answers and left as the
coordinates it holds when none does — a naming is a network round trip on a PRO
account, and the numbers do not wait for it, so a refusal or a miss costs the
reader nothing.

What this costs is one read of the frame's first few hundred kilobytes, the
same head a scan hands the parser, and only the frame that is up pays it: the
strip walks past a hundred negatives without reading one of them. The read is
held by the read itself and not by the frame object under it, because the
catalogue is read back on a timer while a scan runs, which hands the screen a
fresh object every few hundred milliseconds — a screen that went by the object
would read the same file again and again for as long as the scan lasts.

The check's stand-in folder had no frame of its own with a GPS tag on it, and
none of the samples carries one, so the check writes one: an APP1 segment
holding a GPS IFD and nothing else, spliced in right after the frame's SOI. It
is deliberately the first APP1 — a JPEG carries one EXIF segment and the
catalogue reads the first, so a file that has a place is a file that spent its
EXIF on the coordinates. That is also what makes the RAW the frame that shows a
spec line, and the two frames now check the two halves of the same feature.

The run counts what a reading costs, and a head is not what it counted before:
it counts the whole file a lane develops apart from the head a parser is handed,
because "the RAW was read" is a claim about the scan and the preview legitimately
reads the same file's head.

Verified:
  library-check.mjs — 47 steps, all passed, two of them new. The JPEG off the
    check's server carries a GPS tag and the page keeps 16.0544, 108.2022 under
    the frame, with no geocoder behind it to name them; the RAW prints
    "26.4mm · f/2.8 · 1/320s" — the glass and the shutter it has, no ISO and no
    frame size it does not. Every earlier step still holds, including the one
    that says a scan does not read a RAW whose size and write time have not
    moved: whole reads {}, heads {"P1010256.RW2":1,"P1010256.JPG":2}, the one
    RAW head being the frame that is up.
  scan-nav-check.mjs, roll-walk-check.mjs — all passed. frontend tsc --noEmit
    clean, vite build clean.

ponytail: the place is named by the API's geocoder and nothing else — no map, no
picker, no place a reader can type. The coordinates are what the file carries,
and a file that carries none shows no line, which is the honest answer and the
common case for a phone frame with location off. The line is read for the raised
frame only; a grid of hundreds is a list of names, and a row of ISO numbers
under each tile is not what it is for.

Co-authored-by: PenguinHarness <noreply@penguin.local>
This commit is contained in:
2026-09-29 07:57:36 +07:00
parent 753eea0d7b
commit 477c71d1f4
4 changed files with 213 additions and 9 deletions
+76 -8
View File
@@ -29,6 +29,51 @@ const APP_PORT = Number(process.env.APP_PORT ?? 4185);
const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4319);
const JPG = 'P1010256.JPG';
const RAW = 'P1010256.RW2';
// Where the served JPEG claims to have been shot. No sample frame carries a GPS
// tag, and a file that has one is the only way to check the place under the
// preview, so the check writes one: an APP1 segment holding a GPS IFD and
// nothing else, spliced in right after the frame's own SOI.
//
// It is deliberately the first APP1: a JPEG carries one EXIF segment, and the
// catalogue reads the first, so spending it on the coordinates is what makes
// this a file that has a place — the camera's own numbers go with the segment
// it replaced, which is why the RAW below is the one that shows a spec line.
const GPS = {
lat: [[16, 1], [3, 1], [1584, 100]], // 16° 3' 15.84"
lng: [[108, 1], [12, 1], [792, 100]], // 108° 12' 7.92"
text: '16.0544, 108.2022',
};
function withGps(jpg) {
const tiff = Buffer.alloc(128);
tiff.write('II', 0, 'latin1');
tiff.writeUInt16LE(42, 2);
tiff.writeUInt32LE(8, 4); // IFD0
const ifd = (at, entries) => {
tiff.writeUInt16LE(entries.length, at);
entries.forEach(([tag, type, count, value], i) => {
const e = at + 2 + i * 12;
tiff.writeUInt16LE(tag, e);
tiff.writeUInt16LE(type, e + 2);
tiff.writeUInt32LE(count, e + 4);
tiff.writeUInt32LE(value, e + 8);
});
};
ifd(8, [[0x8825, 4, 1, 26]]); // GPSInfoIFDPointer
ifd(26, [
[0x0001, 2, 2, 0x4e], // GPSLatitudeRef "N"
[0x0002, 5, 3, 80], // GPSLatitude
[0x0003, 2, 2, 0x45], // GPSLongitudeRef "E"
[0x0004, 5, 3, 104], // GPSLongitude
]);
for (const [at, parts] of [[80, GPS.lat], [104, GPS.lng]])
parts.forEach(([n, d], i) => {
tiff.writeUInt32LE(n, at + i * 8);
tiff.writeUInt32LE(d, at + i * 8 + 4);
});
const app1 = Buffer.concat([Buffer.from([0xff, 0xe1, 0, 0]), Buffer.from('Exif\0\0', 'latin1'), tiff]);
app1.writeUInt16BE(app1.length - 2, 2);
return Buffer.concat([jpg.subarray(0, 2), app1, jpg.subarray(2)]);
}
let failures = 0;
function step(name, ok, detail = '') {
@@ -59,15 +104,16 @@ const sampleServer = createServer(async (req, res) => {
return;
}
const bytes = await readFile(path.join(SAMPLES, name));
const served = name === JPG ? withGps(bytes) : bytes;
// A beat per frame: the catalogue reads two files faster than a poll, and the
// ring that says "this roll is being read" needs a window to be caught in.
await new Promise((resolve) => setTimeout(resolve, 250));
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': bytes.length,
'Content-Length': served.length,
'Access-Control-Allow-Origin': '*',
});
res.end(bytes);
res.end(served);
});
await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve));
@@ -113,10 +159,14 @@ await context.addInitScript(
};
// What a scan costs is the bytes it reads, so they are counted: a part cut
// off a file remembers the file it came off, and every `arrayBuffer` call on
// the whole or the part lands on that file's name. A frame the catalogue
// already holds is left on its size and its write time without a byte of it
// being read, which is the claim the run below makes.
// the whole or the part lands on that file's name. Whole reads and heads are
// counted apart, because they are two different things: a RAW read whole is
// the scan developing a frame, while a head is what gets handed to a parser —
// the catalogue's own date read, or the numbers the preview prints. A frame
// the catalogue already holds is left on its size and its write time without
// a byte of it being read, which is the claim the run below makes.
const reads = (window.__reads = {});
const heads = (window.__heads = {});
const off = new WeakMap();
const part = Blob.prototype.slice;
Blob.prototype.slice = function (...args) {
@@ -127,7 +177,10 @@ await context.addInitScript(
const pull = Blob.prototype.arrayBuffer;
Blob.prototype.arrayBuffer = function () {
const name = off.get(this) ?? this.name ?? '';
if (name) reads[name] = (reads[name] ?? 0) + 1;
if (name) {
const counter = off.has(this) ? heads : reads;
counter[name] = (counter[name] ?? 0) + 1;
}
return pull.apply(this);
};
const fileHandle = (name) => {
@@ -527,6 +580,14 @@ await page.waitForSelector(thumbs, { timeout: 120_000 });
const raised = await page.$eval('.adm-thumb.on', (e) => e.dataset.key);
step('the screen reopens on the frame that was raised', raised === `lib-thumb-${jpgId}`, raised);
// The frame's own file says two things under the picture: where it was shot and
// the numbers the camera recorded. The JPEG off this server is the one with a
// GPS tag on it, so it is the one that gets a place — and nothing here names it,
// the page has no geocoder behind it, so the coordinates stand as they were read.
await page.waitForSelector('[data-key="lib-stage-place"]', { timeout: 60_000, state: 'attached' });
const place = await page.locator('[data-key="lib-stage-place"]').innerText();
step('the frame says where it was shot, off its own EXIF', place.trim() === GPS.text, place.trim());
// A wheel tick over the frame magnifies it about the point under the pointer —
// the one gesture that makes a negative worth the whole window. The page under it
// holds still, and ticking back down fits the frame again.
@@ -553,6 +614,13 @@ step(
// (stand-in) handle and developed — so it is the one that gets opened.
const rawId = indexed.thumbs.find((t) => t.id.endsWith(`/${RAW}`)).id;
await page.click(`[data-key="lib-thumb-${rawId}"]`);
// The RAW is the frame that shows a spec line: a JPEG carries one EXIF segment
// and this server spends the stand-in's on the coordinates. Nothing is invented
// for the numbers a file does not carry, so only what the camera wrote is here —
// no ISO and no frame size, which is what this RW2 answers.
await page.waitForSelector('[data-key="lib-stage-specs"]', { timeout: 60_000, state: 'attached' });
const specs = (await page.locator('[data-key="lib-stage-specs"]').innerText()).trim();
step('and prints the numbers the camera recorded', specs === '26.4mm · f/2.8 · 1/320s', specs);
await page.click('[data-key="lib-open"]');
await page.waitForURL((url) => url.pathname.startsWith('/app'), { timeout: 120_000 });
await page.waitForFunction(() => !document.querySelector('.dropzone'), null, { timeout: 180_000 });
@@ -679,10 +747,10 @@ await page
.waitForFunction((n) => document.querySelectorAll('[data-key^="lib-thumb-"]').length === n, 3, { timeout: 60_000, polling: 50 })
.catch(() => {});
const restored = await page.$$eval(thumbs, (els) => els.length).catch(() => 0);
const read = await page.evaluate(() => window.__reads ?? {});
const read = await page.evaluate(() => ({ whole: window.__reads ?? {}, heads: window.__heads ?? {} }));
step(
'an interrupted reading goes on by itself, over the frames it still holds',
restored === 3 && !read[RAW] && read[JPG] > 0,
restored === 3 && !read.whole[RAW] && read.heads[JPG] > 0,
`${restored} tiles, ${JSON.stringify(read)}`
);
+74
View File
@@ -1,10 +1,13 @@
import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type CSSProperties } from 'react';
import { useI18n } from './i18n/I18nProvider';
import { go } from './nav';
import { api } from './api';
import { readGps, readSpecs, specsLine } from './engine/imageOps';
import {
canBrowseFolders,
ensurePermission,
getPhoto,
HEAD_BYTES,
jumpScan,
listEditedIds,
listFolders,
@@ -54,6 +57,24 @@ const COLUMN_DEFAULT = 118;
const ZOOM_MAX = 6;
const ZOOM_STEP = 1.15;
// What the frame's own file says about itself: the line of numbers the camera
// recorded, and where it was shot. Both sit in the EXIF at the head of the file,
// so the frame costs one read of its first few hundred kilobytes rather than the
// whole negative — and only the raised frame ever pays it.
interface Shot {
line: string;
lat: number | null;
lng: number | null;
// The geocoder's name for those coordinates, or null: naming a place is a
// network round trip on a PRO account, and a refusal leaves the numbers.
place: string | null;
}
// The frames already read this visit, by the read itself: the catalogue is read
// back on a timer while a scan runs, which hands this screen a new frame object
// every few hundred milliseconds, so what keeps the file from being read again is
// the read in flight rather than the object under it.
const shots = new Map<string, Promise<Shot | null>>();
// Private mode throws on localStorage rather than returning null: a screen that
// cannot remember is fine, a screen that cannot load is not.
function remembered(key: string): string | null {
@@ -430,6 +451,53 @@ export function Library() {
const frame = shown.find((p) => p.id === up) ?? shown[0] ?? null;
const urls = useThumbUrls(shown);
const [shot, setShot] = useState<Shot | null>(null);
// Where the frame was shot, as the coordinates it carries — the fallback for a
// file whose place the geocoder could not name.
const coords = shot && shot.lat !== null && shot.lng !== null ? `${shot.lat.toFixed(4)}, ${shot.lng.toFixed(4)}` : null;
// The two lines the stage prints under the frame. The numbers come off the
// file's own head, so what is on screen is what the camera wrote and nothing
// this page invented; the place name is a network round trip that lands after
// them, and one that is refused leaves the coordinates standing.
useEffect(() => {
if (!frame) return;
const id = frame.id;
let live = true;
let read = shots.get(id);
if (!read) {
read = (async () => {
try {
const head = new Uint8Array(await (await readPhotoFile(frame)).slice(0, HEAD_BYTES).arrayBuffer());
const [specs, gps] = await Promise.all([readSpecs(head), readGps(head)]);
const shot: Shot = { line: specsLine(specs), lat: gps?.latitude ?? null, lng: gps?.longitude ?? null, place: null };
if (shot.lat !== null && shot.lng !== null) {
// Naming a place is a round trip on a PRO account; the numbers do not
// wait for it, and a refusal or a miss simply leaves them alone.
void api
.place(shot.lat, shot.lng)
.then(({ place }) => {
if (!place) return;
shot.place = place;
if (live) setShot({ ...shot });
})
.catch(() => {});
}
return shot;
} catch {
// A file that cannot be read says nothing about itself.
return null;
}
})();
shots.set(id, read);
}
void read.then((shot) => {
if (live) setShot(shot);
});
return () => {
live = false;
};
}, [frame?.id]);
// A frame that has just come up is fitted: the zoom belonged to the negative
// that was up before it.
@@ -732,6 +800,12 @@ export function Library() {
{new Date(frame.taken).toLocaleString()} · {t('lib.size', { mb: (frame.size / 1048576).toFixed(1) })}
{edited.has(frame.id) ? ` · ${t('lib.edited')}` : ''}
</span>
{shot?.place || coords ? (
<span data-key="lib-stage-place" title={coords ?? undefined}>
{shot?.place ?? coords}
</span>
) : null}
{shot?.line ? <span data-key="lib-stage-specs">{shot.line}</span> : null}
</div>
<div className="chip-row">
<button type="button" className="btn primary" data-key="lib-open" onClick={() => void open(frame)}>
+62
View File
@@ -166,6 +166,68 @@ export async function readCapturedAt(bytes: Uint8Array): Promise<number | null>
return null;
}
// What the camera recorded about the shutter, the glass and the frame itself —
// the numbers a photographer reads first. All of them sit in the EXIF segment at
// the front of the file, so this costs the same few hundred kilobytes the date
// does. Each one is null where the file does not carry it, which is what a
// screenshot, a scan or a Fuji RAF answers: `exifr` reads the formats it knows
// and nothing about a file it does not know is invented here.
export interface ShotSpecs {
iso: number | null;
// A focal length in millimetres, an f-number, and an exposure in seconds.
focal: number | null;
aperture: number | null;
exposure: number | null;
// The frame's own size, which only a JPEG states outright. A RAW leaves it to
// the decoder, and half a number is worse than none.
width: number | null;
height: number | null;
}
export async function readSpecs(bytes: Uint8Array): Promise<ShotSpecs | null> {
try {
const tags = (await exifr.parse(bytes, {
pick: ['ISO', 'ISOSpeedRatings', 'FocalLength', 'FNumber', 'ExposureTime', 'ExifImageWidth', 'ExifImageHeight'],
})) as Record<string, unknown> | undefined;
if (!tags) return null;
const num = (v: unknown): number | null => {
const n = Number(Array.isArray(v) ? v[0] : v);
return Number.isFinite(n) && n > 0 ? n : null;
};
return {
iso: num(tags.ISO ?? tags.ISOSpeedRatings),
focal: num(tags.FocalLength),
aperture: num(tags.FNumber),
exposure: num(tags.ExposureTime),
// Null together: a frame with one edge and not the other has no size.
width: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageWidth) : null,
height: tags.ExifImageWidth && tags.ExifImageHeight ? num(tags.ExifImageHeight) : null,
};
} catch {
return null;
}
}
// The rounded number as it is printed: one decimal, and none where there is
// none — `f/2.8` rather than `f/2.8000000000000003`.
const round = (n: number) => String(Math.round(n * 10) / 10);
// Those numbers as the line under a frame. A shutter faster than a second is the
// fraction a camera prints (`1/320s`), and everything the file does not have is
// left out rather than stood in for — an empty line is what a file with no EXIF
// gets, and the caller prints nothing.
export function specsLine(specs: ShotSpecs | null): string {
if (!specs) return '';
const parts = [
specs.iso ? `ISO ${Math.round(specs.iso)}` : '',
specs.focal ? `${round(specs.focal)}mm` : '',
specs.aperture ? `f/${round(specs.aperture)}` : '',
specs.exposure ? (specs.exposure < 1 ? `1/${Math.round(1 / specs.exposure)}s` : `${round(specs.exposure)}s`) : '',
specs.width && specs.height ? `${specs.width}×${specs.height}` : '',
];
return parts.filter(Boolean).join(' · ');
}
// EXIF GPS of the loaded photo, in the shape the renderer + EXIF writer expect.
// Returns null when the photo has none — the caller then offers manual entry.
export async function readGps(bytes: Uint8Array): Promise<GPSInfo | null> {
+1 -1
View File
@@ -222,7 +222,7 @@ export async function ensurePermission(handle: FileSystemHandle, write = false):
// it in the first one, so the slice that is handed to the parser is a few hundred
// kilobytes rather than the whole frame. Reading 8MB through a JS array to find a
// date in the first kilobyte of it is most of what a scan of JPEGs costs.
const HEAD_BYTES = 256 * 1024;
export const HEAD_BYTES = 256 * 1024;
// The probe is decoded at this width, purely to learn which edge of the frame is
// the long one; the decoder scales the real one off that. A JPEG does not need
// it: its own header says how big the frame is, in the first few hundred bytes.