diff --git a/docker/frontend/scripts/library-check.mjs b/docker/frontend/scripts/library-check.mjs index 88669b7..46cec19 100644 --- a/docker/frontend/scripts/library-check.mjs +++ b/docker/frontend/scripts/library-check.mjs @@ -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)}` ); diff --git a/docker/frontend/src/Library.tsx b/docker/frontend/src/Library.tsx index e8094c2..c1bff86 100644 --- a/docker/frontend/src/Library.tsx +++ b/docker/frontend/src/Library.tsx @@ -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>(); + // 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(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')}` : ''} + {shot?.place || coords ? ( + + {shot?.place ?? coords} + + ) : null} + {shot?.line ? {shot.line} : null}