diff --git a/docker/frontend/scripts/studio-export-check.mjs b/docker/frontend/scripts/studio-export-check.mjs new file mode 100644 index 0000000..649b794 --- /dev/null +++ b/docker/frontend/scripts/studio-export-check.mjs @@ -0,0 +1,374 @@ +// The studio's filing clock, checked where it actually runs: in a browser. +// +// What it holds down is the lag a reader feels while grading a catalogue frame +// in the studio. The look a frame is left at goes back to the shelf as two +// things — the look itself (the `edits` row the studio reopens it from) and the +// tile the shelf draws for it (a develop of that look, written into the store +// the shelf reads) — and both used to be filed behind *every* frame of a slider +// drag. A drag is a preview per frame; a develop and a store write behind each +// of those is the drag. +// +// So they go back on an idle clock instead: ten seconds with nothing asked of +// the stage. What this check holds is that clock and the two ways it is cut +// short — a frame left behind and a page left behind, both of which must file +// the waiting look at once rather than throw the last seconds of it away. +// +// npm run build && node scripts/studio-export-check.mjs +// PLAYWRIGHT_CORE= node scripts/studio-export-check.mjs +// CHROME= PLAYWRIGHT_CORE=... node scripts/studio-export-check.mjs +// BASE=http://127.0.0.1:8090 node scripts/studio-export-check.mjs # a server already up +// +// Playwright is not a dependency of this package (a browser run needs it, a +// build does not), so a run without it says SKIP and exits 0. Two real frames +// are served off a local server and `showDirectoryPicker` is answered with a +// folder made of them, the same stand-in `library-check.mjs` uses. +import { spawn } from 'node:child_process'; +import { createServer } from 'node:http'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const FRONTEND = fileURLToPath(new URL('..', import.meta.url)); +const SAMPLES = process.env.SAMPLE_DIR ?? path.resolve(FRONTEND, '..'); +const APP_PORT = Number(process.env.APP_PORT ?? 4225); +const SAMPLE_PORT = Number(process.env.SAMPLE_PORT ?? 4339); +const A_NAME = 'P1010256.JPG'; +const B_NAME = 'D0004128.JPG'; +const DB = 'recipescam-library'; +const STORES = ['folders', 'photos', 'thumbs', 'edits', 'dirs']; +// The stage's own idle clock, as the studio spells it: ten seconds. +const IDLE_MS = 10_000; + +let failures = 0; +function step(name, ok, detail = '') { + if (!ok) failures++; + console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`); + return ok; +} + +const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null); +if (!playwright) { + console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE='); + process.exit(0); +} + +// --- the two frames, off a local server (the page fetches them by CORS) ------ +const sampleServer = createServer(async (req, res) => { + const name = path.basename(decodeURIComponent((req.url ?? '/').split('?')[0])); + if (name !== A_NAME && name !== B_NAME) { + res.writeHead(404).end(); + return; + } + const bytes = await readFile(path.join(SAMPLES, name)); + res.writeHead(200, { + 'Content-Type': 'application/octet-stream', + 'Content-Length': bytes.length, + 'Access-Control-Allow-Origin': '*', + 'Cross-Origin-Resource-Policy': 'cross-origin', + }); + res.end(bytes); +}); +await new Promise((resolve) => sampleServer.listen(SAMPLE_PORT, '127.0.0.1', resolve)); + +// A server already up, when one is named; otherwise the build in `dist/`. +let app = null; +const base = process.env.BASE ?? `http://127.0.0.1:${APP_PORT}`; +if (!process.env.BASE) { + app = spawn('npx', ['vite', 'preview', '--port', String(APP_PORT), '--strictPort', '--host', '127.0.0.1'], { + cwd: FRONTEND, + stdio: 'ignore', + }); + let up = false; + for (let i = 0; i < 60 && !up; i++) { + up = await fetch(`${base}/library`).then((r) => r.ok).catch(() => false); + if (!up) await new Promise((r) => setTimeout(r, 500)); + } + if (!up) { + app.kill('SIGTERM'); + sampleServer.close(); + console.log(`FAIL no preview on ${base} — run \`npm run build\` first (vite preview serves dist/)`); + process.exit(1); + } +} + +// --- the browser ------------------------------------------------------------ +const browser = await playwright.chromium.launch({ + executablePath: process.env.CHROME || undefined, + args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'], +}); + +// A folder of two frames, handed over the way the browser hands one over. +const INIT = ({ origin, names, folder }) => { + const bytes = new Map(); + const load = async (n) => { + if (!bytes.has(n)) bytes.set(n, fetch(`${origin}/${n}`).then((r) => r.arrayBuffer())); + return bytes.get(n); + }; + const fileHandle = (name) => { + const h = { kind: 'file', name, __fake: true }; + Object.defineProperties(h, { + getFile: { + value: async () => + new File([await load(name)], name, { + type: name.endsWith('.JPG') ? 'image/jpeg' : 'application/octet-stream', + lastModified: Date.UTC(2026, 0, 1), + }), + }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + }); + return h; + }; + const dirHandle = (label) => { + const h = { kind: 'directory', name: label, __fake: true }; + Object.defineProperties(h, { + values: { value: () => names.map(fileHandle)[Symbol.iterator]() }, + queryPermission: { value: async () => 'granted' }, + requestPermission: { value: async () => 'granted' }, + getDirectoryHandle: { value: async () => { throw new DOMException('no folder', 'NotFoundError'); } }, + getFileHandle: { + value: async (name) => { + if (!names.includes(name)) throw new DOMException('no frame', 'NotFoundError'); + return fileHandle(name); + }, + }, + }); + return h; + }; + const root = () => dirHandle(folder); + window.showDirectoryPicker = async () => root(); + const revive = (row) => { + if (row?.handle?.__fake && !Object.getOwnPropertyDescriptor(row.handle, 'getFile')) + row.handle = row.handle.kind === 'directory' ? root() : fileHandle(row.handle.name); + return row; + }; + for (const m of ['get', 'getAll']) { + const original = IDBObjectStore.prototype[m]; + IDBObjectStore.prototype[m] = function (...a) { + const req = original.apply(this, a); + const d = Object.getOwnPropertyDescriptor(IDBRequest.prototype, 'result'); + Object.defineProperty(req, 'result', { + configurable: true, + get() { + const v = d.get.call(this); + return Array.isArray(v) ? v.map(revive) : revive(v); + }, + }); + return req; + }; + } +}; + +const open = async () => { + const context = await browser.newContext({ viewport: { width: 1500, height: 950 } }); + // No server is up behind this build, and a vite preview answers an API path + // with the SPA's own `index.html` — which parses to `{}` and so reads as an + // empty *answer* rather than as "no server", taking the studio down with it. + // The reads the two screens make on load are answered here; anything else is + // refused, the way a missing route is. + const replies = { + '/api/auth/me': { user: { id: 1, email: 'check@local', admin: true, verified: true, pro: true } }, + '/api/recipes': { recipes: [] }, + '/api/photos/mine': { photos: [] }, + '/api/photos': { photos: [] }, + }; + await context.route('**/api/**', (route) => { + const body = replies[new URL(route.request().url()).pathname]; + return route.fulfill( + body + ? { status: 200, contentType: 'application/json', body: JSON.stringify(body) } + : { status: 404, contentType: 'application/json', body: JSON.stringify({ error: 'HTTP 404' }) } + ); + }); + await context.addInitScript(INIT, { origin: `http://127.0.0.1:${SAMPLE_PORT}`, names: [A_NAME, B_NAME], folder: 'CheckRoll' }); + const page = await context.newPage(); + const errors = []; + page.on('pageerror', (e) => errors.push(e.message)); + return { context, page, errors }; +}; + +// The shelf's own strip of frames: one thumb per frame the catalogue holds, and +// the click that puts a frame on the stage (see Library.tsx). +const tiles = '[data-key^="lib-thumb-"]'; +const inStudio = (page) => /^\/app(\?|$)/.test(new URL(page.url()).pathname + new URL(page.url()).search); + +// The `edits` store as the shelf and the studio read it: what look each frame +// the catalogue holds was left at, and when it was written. +const readEdits = (page) => + page.evaluate( + (name) => + new Promise((resolve, reject) => { + const req = indexedDB.open(name); + req.onsuccess = () => { + const db = req.result; + const all = db.transaction('edits', 'readonly').objectStore('edits').getAll(); + all.onsuccess = () => { + db.close(); + resolve(all.result.map((r) => ({ photoId: r.photoId, simId: r.state?.simId, updatedAt: r.updatedAt }))); + }; + all.onerror = () => reject(all.error); + }; + req.onerror = () => reject(req.error); + }), + DB + ); + +// A fingerprint of every tile the store holds: the develop in a tile is not +// byte-stable across runs, so what is held down is that it *moved*. +const readThumbs = (page) => + page.evaluate( + (name) => + new Promise((resolve, reject) => { + const fp = (v) => { + let h = 2166136261 >>> 0; + for (let i = 0; i < v.length; i += 97) h = Math.imul(h ^ v[i], 16777619) >>> 0; + return `${v.length}:${h}`; + }; + const req = indexedDB.open(name); + req.onsuccess = () => { + const db = req.result; + const all = db.transaction('thumbs', 'readonly').objectStore('thumbs').getAll(); + all.onsuccess = async () => { + db.close(); + const out = {}; + for (const row of all.result) { + const blob = row.thumb ?? row.blob; + out[row.id] = blob ? fp(new Uint8Array(await blob.arrayBuffer())) : 'none'; + } + resolve(out); + }; + all.onerror = () => reject(all.error); + }; + req.onerror = () => reject(req.error); + }), + DB + ); + +// The tile is a develop of the look, made after the look itself is filed, so +// the shelf hears about the look first and the tile a beat later. Wait for it. +const waitForTileMove = async (page, before, ms) => { + const t0 = Date.now(); + for (;;) { + const now = await readThumbs(page); + const moved = Object.keys(now).filter((id) => now[id] !== before[id]); + if (moved.length || Date.now() - t0 >= ms) return { moved, now }; + await page.waitForTimeout(250); + } +}; + +// A change of the whole look, made the way a reader makes one: one pick in +// PHOTO STYLE, which is the rail's own strip on the tab the studio opens on. Any +// chip but the one in force is a different look, and every one of them moves the +// stage the way a turn or a knob does. +const pickStyle = async (page) => { + if (!(await page.$('[data-key^="style:"]'))) await page.click('[data-key="grp-style"]'); + const keys = await page.$$eval('[data-key^="style:"]', (els) => + els.filter((e) => !e.classList.contains('on')).map((e) => e.dataset.key) + ); + if (!keys.length) throw new Error('no look left to pick in the PHOTO STYLE strip'); + await page.click(`[data-key="${keys[0]}"]`); + // The chip's key carries its group (`style:`); what the studio files is + // the sim alone, which is what this is watched by. + return keys[0].slice('style:'.length); +}; +// Wait for the look to reach the shelf, and say how long that took. Only a +// filing made after `since` counts: a look picked a second time is a look the +// store already holds, and the row it holds from the last time is not a filing. +const waitForLook = async (page, simId, ms, since = 0) => { + const t0 = Date.now(); + for (;;) { + const row = (await readEdits(page)).find((r) => r.simId === simId && r.updatedAt >= since); + if (row) return { row, ms: Date.now() - t0 }; + if (Date.now() - t0 >= ms) return { row: null, ms: Date.now() - t0 }; + await page.waitForTimeout(250); + } +}; + +// --- 1. the filing clock ----------------------------------------------------- +{ + const { context, page, errors } = await open(); + await page.goto(`${base}/library`); + await page.click('[data-key="lib-add"]'); + await page.waitForFunction((t) => document.querySelectorAll(t.sel).length >= t.n, { sel: tiles, n: 2 }, { + timeout: 120_000, + }); + step('the roll is indexed', true); + + // The shelf's own hand-over: the frame the stage opens is the one its panel is + // on, which is what `lib-open` asks for. + const held = await page.$$eval(tiles, (els) => els.map((e) => e.dataset.key.slice('lib-thumb-'.length))); + const openName = await page.$eval('[data-key="lib-stage-meta"] b', (e) => e.textContent.trim()); + const firstId = held.find((id) => id.endsWith(openName)) ?? held[0]; + const otherId = held.find((id) => id !== firstId) ?? held[1]; + await page.click('[data-key="lib-open"]'); + await page.waitForSelector('[data-key="grp-style"]', { timeout: 120_000 }); + await page.waitForTimeout(1500); + step('the catalogue frame is on the stage', inStudio(page), `${page.url()} — ${openName}`); + + const edits0 = await readEdits(page); + const thumbs0 = await readThumbs(page); + step('the shelf holds one tile per frame and no look filed yet', Object.keys(thumbs0).length === 2, JSON.stringify(thumbs0)); + + // One pick: a change of the whole look, which is what a drag of a knob is too + // — the stage is asked for a preview per frame of it. + const t0 = Date.now(); + const look = await pickStyle(page); + await page.waitForTimeout(1500); + const early = await readEdits(page); + const thumbsEarly = await readThumbs(page); + step( + 'nothing is filed while the stage is being worked', + !early.some((r) => r.simId === look), + `+${Date.now() - t0}ms: ${JSON.stringify(early)}` + ); + step( + 'no develop behind the slider either', + JSON.stringify(thumbsEarly) === JSON.stringify(thumbs0), + `${JSON.stringify(thumbs0)} -> ${JSON.stringify(thumbsEarly)}` + ); + + // Ten seconds of nothing asked of the stage is a look the reader stopped + // making; the shelf hears about it once. + const waited = await waitForLook(page, look, IDLE_MS + 6000, t0); + const filedAt = Date.now() - t0; + step('the look goes back once the stage has gone quiet', !!waited.row, `after ${filedAt}ms`); + const tile = await waitForTileMove(page, thumbs0, 15_000); + const moved = tile.moved; + step('the shelf gets a fresh tile for that look', moved.length === 1, moved.join() || 'none moved'); + const filed = waited.row?.photoId ?? moved[0] ?? null; + step('the look went back to the frame the stage is on', filed !== null && filed.endsWith(openName), String(filed)); + + // --- 2. a page left behind ------------------------------------------------ + const look2 = await pickStyle(page); + const t2 = Date.now(); + await page.evaluate(() => window.dispatchEvent(new Event('pagehide'))); + const left = await waitForLook(page, look2, 5000, t2); + step( + 'a page left behind files the waiting look at once', + !!left.row && left.row.photoId === filed, + `after ${left.ms}ms, ${JSON.stringify(left.row)}` + ); + + // --- 3. a frame left behind ---------------------------------------------- + const look3 = await pickStyle(page); + const t3 = Date.now(); + await page.click('[data-key="nav-library"]'); + await page.waitForSelector(tiles, { timeout: 60_000 }); + await page.click(`[data-key="lib-thumb-${otherId}"]`); + await page.click('[data-key="lib-open"]'); + await page.waitForSelector('[data-key="grp-style"]', { timeout: 120_000 }); + const switched = await waitForLook(page, look3, IDLE_MS - 1000, t3); + step( + 'a frame left behind files its waiting look at once', + !!switched.row && switched.row.photoId === filed, + `${otherId} opened ${switched.ms}ms after the pick: ${JSON.stringify(switched.row)}` + ); + step('no uncaught error', errors.length === 0, errors.join(' | ')); + await context.close(); +} + +await browser.close(); +sampleServer.close(); +app?.kill('SIGTERM'); +console.log(failures ? `\n${failures} FAILED` : '\nall ok'); +process.exit(failures ? 1 : 0); diff --git a/docker/frontend/src/App.tsx b/docker/frontend/src/App.tsx index 7f5671f..923b16a 100644 --- a/docker/frontend/src/App.tsx +++ b/docker/frontend/src/App.tsx @@ -112,6 +112,12 @@ const MODEL_GAIN = 4; // on every notch. const PREVIEW_QUANTUM = 512; const PREVIEW_DEBOUNCE_MS = 120; +// How long the stage has to be still before the look goes back to the shelf: the +// tile it shows for a catalogue frame is a develop of that look and a write into +// the store the shelf reads, and paying both behind every frame of a slider drag +// is the drag the reader feels. Ten seconds of nothing asked of the stage is a +// look the reader has stopped making. See the filing effects in the studio. +const LIBRARY_EXPORT_MS = 10_000; // The catalogue frame the studio is holding, under the catalogue's own key: the // shelf reads it when it comes back up and opens on that frame — the reader has // just been working on it (see Library.tsx). @@ -1067,49 +1073,11 @@ export function Workspace() { if (prev) URL.revokeObjectURL(prev); return URL.createObjectURL(new Blob([out.bytes as BlobPart], { type: 'image/jpeg' })); }); - // A frame that came out of the catalogue leaves its own tile behind it: - // the shelf would otherwise keep showing the negative the reader has just - // graded. The tile is the look alone — the frame's turn, straighten, crop, - // frame and stamps are the library's to draw, and every tile it draws - // carries the frame's own standing on top — so the render in hand serves - // as the tile exactly while it carries none of them. The hovered look is - // not the frame's: only the committed one is filed. - if (libPhotoId && !preview && !cropEditing) { - const bare = - !options.cropRect && - !options.watermark && - !options.gpsWatermark && - rotation === 0 && - !straighten && - !flipH && - !flipV && - frame === 'none'; - const graded = - bare - ? out.bytes - : ( - await renderPhoto({ - source: previewBytes, - recipe: shownRecipe, - frameId: 'none', - useGeotag: false, - gpsInfo: null, - assets: engine.assets, - options: { - ...options, - photoRotation: 0, - photoStraighten: 0, - photoFlipH: false, - photoFlipV: false, - cropRect: null, - watermark: null, - gpsWatermark: null, - }, - }) - )?.bytes; - const tile = graded ? await makeTile(new Blob([graded as BlobPart], { type: 'image/jpeg' })) : null; - if (tile && token === renderToken.current) void putPhotoThumb(libPhotoId, tile); - } + // The tile the shelf shows for this frame is not made here. It is a + // develop of the look and a write into the store the shelf reads, and a + // develop behind every frame of a slider drag is the drag the reader + // feels — it is made on the idle clock below instead, with the look + // itself. See `LIBRARY_EXPORT_MS`. if (!comparing) { setCompareUrl((prev) => { if (prev) URL.revokeObjectURL(prev); @@ -2538,16 +2506,80 @@ export function Workspace() { openLibraryPhotoRef.current = openLibraryPhoto; }, [openLibraryPhoto]); - // What the catalogue remembers about a frame is the look it was left at, filed - // back on every change — the whole look, not just the recipe, so the next - // session reopens the frame cropped, straightened and marked as it was left. - // Debounced, because a slider drag fires this per frame of the drag and - // IndexedDB is on the wrong side of that. + // What the catalogue remembers about a frame is the look it was left at — the + // whole look, not just the recipe, so the next session reopens the frame + // cropped, straightened and marked as it was left — and the tile the shelf + // draws for it, which is that same look developed once more and written into + // the store the shelf reads. Both go back on an idle clock: a slider drag asks + // for a preview per frame of the drag, and a develop and a store write behind + // every one of those is the drag the reader feels. Ten seconds with nothing + // asked of the stage is a look the reader has stopped making — the shelf hears + // about it once, and the looks still being made are paid for by nothing. + const filedLookRef = useRef<{ id: string; run: () => Promise } | null>(null); + const filedTokenRef = useRef(0); + const flushFiledLook = useCallback(() => { + const job = filedLookRef.current; + filedLookRef.current = null; + if (job) void job.run().catch(() => undefined); + }, []); + useEffect(() => { - if (!libPhotoId || !source) return; - const timer = setTimeout(() => void saveEdit(libPhotoId, studioState), 600); + if (!libPhotoId || !source || !engine || !previewBytes) return; + // A look waiting to go back belongs to the frame it was made on: opening + // another frame in the shelf files the one left behind first, or the last + // ten seconds of its editing is thrown away by the frame switch. + if (filedLookRef.current && filedLookRef.current.id !== libPhotoId) flushFiledLook(); + const id = libPhotoId; + const token = ++filedTokenRef.current; + filedLookRef.current = { + id, + run: async () => { + await saveEdit(id, studioState); + // A crop still being framed is not the frame's yet, and the tile is the + // look alone: the turn, straighten, crop, frame and stamps are the + // library's to draw on top of every tile it shows, so the tile is made + // with none of them on it. The hovered look is not the frame's either — + // the shelf keeps no tile for a look the pointer only crossed. + if (cropEditing) return; + const bytes = ( + await renderPhoto({ + source: previewBytes, + recipe, + frameId: 'none', + useGeotag: false, + gpsInfo: null, + assets: engine.assets, + options: { + ...stageOptions, + photoRotation: 0, + photoStraighten: 0, + photoFlipH: false, + photoFlipV: false, + cropRect: null, + watermark: null, + gpsWatermark: null, + }, + }) + )?.bytes; + const tile = bytes ? await makeTile(new Blob([bytes as BlobPart], { type: 'image/jpeg' })) : null; + if (tile && token === filedTokenRef.current) await putPhotoThumb(id, tile); + }, + }; + const timer = setTimeout(flushFiledLook, LIBRARY_EXPORT_MS); return () => clearTimeout(timer); - }, [libPhotoId, source, studioState]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [libPhotoId, source, engine, previewBytes, recipe, studioState, stageOptions, cropEditing]); + + // The look a reader walks away from is the look they made: leaving the studio, + // the tab or the app files what is waiting there and then, rather than leaving + // the catalogue showing the frame as it stood ten seconds before the last edit. + useEffect(() => { + window.addEventListener('pagehide', flushFiledLook); + return () => { + window.removeEventListener('pagehide', flushFiledLook); + flushFiledLook(); + }; + }, [flushFiledLook]); // The same filing for the frame's own standing: the catalogue opened this // frame at an angle, and a turn made here is that number's to keep — the