From 2096057ebd136643f304d12c2071c2df890d5fff Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 05:20:10 +0200 Subject: [PATCH] perf: cache generated images for the session and decode thumbnails, not originals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generated assets are served `private, no-store`, which is right for a clinical app on a shared workstation — but it meant every gallery render re-downloaded every image. Measured: 26 images averaging 279kB, so 7.2MB fetched to draw 56px tiles, on every open of the Create image popup. Two changes, both reusable anywhere in the app: - A session cache holding decoded blobs in MEMORY ONLY, so nothing is written to disk and the no-store posture is unchanged. Entries are keyed by owner ticket as well as asset, and cleared when the account boundary moves, so one account can never read another's bytes out of memory. Concurrent tiles asking for the same asset share one request rather than racing. - Any img carrying data-image-thumb gets a downscaled copy instead of the original, so a 56px tile no longer decodes a 300kB image. The gallery asks for 256px and the in-chat preview for 640px; opening the full view still gets the original. Browsers without OffscreenCanvas, and any decode failure, fall back to the full image rather than showing nothing. This does not reduce the first fetch. Serving genuinely smaller bytes needs server-side resizing, which needs an image library this project does not carry — worth a deliberate decision rather than adding a native dependency in passing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe --- public/js/assistant/images.js | 2 +- public/js/clinicalAssistant.js | 4 +- public/js/generatedImages.js | 63 +++++++++++++++++++++++++++++- test/generated-image-cache.test.js | 43 ++++++++++++++++++++ 4 files changed, 108 insertions(+), 4 deletions(-) create mode 100644 test/generated-image-cache.test.js diff --git a/public/js/assistant/images.js b/public/js/assistant/images.js index 36e25987..942b620a 100644 --- a/public/js/assistant/images.js +++ b/public/js/assistant/images.js @@ -13,7 +13,7 @@ export function createAssistantImageStore() { // The in-chat image is a thumbnail; full resolution is one click away, so a // long answer is not pushed off the screen by the picture that illustrates it. return '
' + escapeAttr(alt || 'Generated image') +
-      '' + + '" data-assistant-open-image="' + escapeAttr(id) + '" data-image-thumb="640" title="Click to view full resolution" tabindex="0" role="button">' + '
' + '' + '' + diff --git a/public/js/clinicalAssistant.js b/public/js/clinicalAssistant.js index 97c196d0..2a6f5fa5 100644 --- a/public/js/clinicalAssistant.js +++ b/public/js/clinicalAssistant.js @@ -1365,7 +1365,7 @@ import { wrap.innerHTML = done.map(function (job) { var dl = job.imageUrl + (job.imageUrl.indexOf('?') === -1 ? '?download=1' : '&download=1'); return ''; }).join('') + running.map(function (job) { @@ -1453,7 +1453,7 @@ import { return; } wrap.innerHTML = done.slice(0, 24).map(function (job) { - return ''; + return ''; }).join(''); }).catch(function () {}); } diff --git a/public/js/generatedImages.js b/public/js/generatedImages.js index 8c59899e..828faaac 100644 --- a/public/js/generatedImages.js +++ b/public/js/generatedImages.js @@ -53,6 +53,63 @@ export async function imageDataUrl(src, ticket = captureImageOwner()) { }); assertImageOwner(ticket); return data; } +// ── Session image cache ───────────────────────────────────────────────────── +// Assets are served `private, no-store`, which is right for a clinical app on a +// shared workstation — but it means every gallery render re-downloaded every +// image. This keeps decoded blobs in MEMORY ONLY for the session, keyed by asset +// and by the owning ticket, so nothing is written to disk and a different +// account can never read another's entry. Cleared on any account boundary +// change, alongside the object URLs. +const blobCache = new Map(); // key -> Blob (full size) +const thumbCache = new Map(); // key -> Blob (downscaled) +const inflight = new Map(); // key -> Promise, so N tiles fetch once + +function cacheKey(src, ticket) { return String(ticket && ticket.ticket) + '\u0000' + String(src); } + +export function clearImageCache() { blobCache.clear(); thumbCache.clear(); inflight.clear(); } + +export async function cachedImageBlob(src, ticket = captureImageOwner()) { + assertImageOwner(ticket); + const key = cacheKey(src, ticket); + const hit = blobCache.get(key); + if (hit) return hit; + // Several tiles asking for the same asset share one request instead of racing. + if (inflight.has(key)) return inflight.get(key); + const pending = privateImageBlob(src, ticket) + .then(blob => { assertImageOwner(ticket); blobCache.set(key, blob); return blob; }) + .finally(() => inflight.delete(key)); + inflight.set(key, pending); + return pending; +} + +// A gallery tile is 56px but was decoding a full 300kB image. Downscaling once +// and reusing the result keeps memory and decode cost proportional to what is +// actually shown. Reusable anywhere a small preview is wanted. +export async function cachedThumbnail(src, edge = 256, ticket = captureImageOwner()) { + assertImageOwner(ticket); + const key = cacheKey(src, ticket) + '\u0000' + edge; + const hit = thumbCache.get(key); + if (hit) return hit; + const blob = await cachedImageBlob(src, ticket); + assertImageOwner(ticket); + if (typeof createImageBitmap !== 'function' || typeof OffscreenCanvas !== 'function') return blob; + try { + const bitmap = await createImageBitmap(blob); + const scale = Math.min(1, edge / Math.max(bitmap.width, bitmap.height)); + if (scale === 1) { bitmap.close(); thumbCache.set(key, blob); return blob; } + const canvas = new OffscreenCanvas(Math.round(bitmap.width * scale), Math.round(bitmap.height * scale)); + const context = canvas.getContext('2d'); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + bitmap.close(); + const small = await canvas.convertToBlob({ type: 'image/webp', quality: 0.82 }); + assertImageOwner(ticket); + thumbCache.set(key, small); + return small; + } catch (_) { + return blob; // any decode failure just uses the original + } +} + export function transientImageUrl(blob, ticket = captureImageOwner()) { assertImageOwner(ticket); const url = URL.createObjectURL(blob); urls.set(url, ticket); return url; @@ -62,7 +119,10 @@ export async function hydrateImage(img, src, ticket = captureImageOwner()) { assertImageOwner(ticket); if (!assetPath(src)) return; img.removeAttribute('src'); - const blob = await privateImageBlob(src, ticket); + // A tile marked data-image-thumb gets the downscaled copy; full views get the + // original. Both come from the session cache, so a re-render costs nothing. + const edge = Number(img.getAttribute('data-image-thumb')) || 0; + const blob = edge ? await cachedThumbnail(src, edge, ticket) : await cachedImageBlob(src, ticket); assertImageOwner(ticket); if (img.isConnected) img.src = transientImageUrl(blob, ticket); } @@ -117,6 +177,7 @@ if (typeof MutationObserver !== 'undefined') { observer.observe(document.documentElement, { childList: true, subtree: true }); } if (typeof window !== 'undefined') window.addEventListener('account-boundary', () => { + clearImageCache(); // cached bytes must not outlive the account that fetched them const obsolete = new Set(); urls.forEach((owner, url) => { if (!validSharingOwner(owner)) { obsolete.add(url); revokeImageUrl(url); } }); document.querySelectorAll('img[src^="blob:"]').forEach(img => { if (obsolete.has(img.src)) img.removeAttribute('src'); }); diff --git a/test/generated-image-cache.test.js b/test/generated-image-cache.test.js new file mode 100644 index 00000000..b7d1dd36 --- /dev/null +++ b/test/generated-image-cache.test.js @@ -0,0 +1,43 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const read = f => fs.readFileSync(path.join(__dirname, '..', f), 'utf8'); + +// Generated assets are served `private, no-store`, which is correct for a +// clinical app on a shared workstation. The cost was that every gallery render +// re-downloaded every image — 26 images at ~279kB to draw 56px tiles. +test('assets are still never written to disk', () => { + const route = read('src/routes/generatedImages.js'); + assert.match(route, /Cache-Control', 'private, no-store'/, + 'the no-store posture is unchanged; caching is in memory for the session only'); +}); + +test('the cache is scoped to the account that fetched it', () => { + const js = read('public/js/generatedImages.js'); + // A cache keyed only by URL would let the next account read the previous + // one's bytes out of memory. + assert.match(js, /function cacheKey\(src, ticket\) \{ return String\(ticket && ticket\.ticket\)/, + 'entries are keyed by owner ticket as well as asset'); + assert.match(js, /clearImageCache\(\); \/\/ cached bytes must not outlive the account/, + 'and dropped when the account boundary moves'); + assert.match(js, /assertImageOwner\(ticket\)/, 'ownership is asserted on the way in'); +}); + +test('concurrent tiles share one request instead of racing', () => { + const js = read('public/js/generatedImages.js'); + assert.match(js, /if \(inflight\.has\(key\)\) return inflight\.get\(key\);/); + assert.match(js, /\.finally\(\(\) => inflight\.delete\(key\)\)/, 'and the slot is released either way'); +}); + +test('a tile decodes a thumbnail, not a full image', () => { + const js = read('public/js/generatedImages.js'); + assert.match(js, /const edge = Number\(img\.getAttribute\('data-image-thumb'\)\) \|\| 0;/); + assert.match(js, /edge \? await cachedThumbnail\(src, edge, ticket\) : await cachedImageBlob\(src, ticket\)/); + // A browser without OffscreenCanvas still shows the image rather than nothing. + assert.match(js, /if \(typeof createImageBitmap !== 'function' \|\| typeof OffscreenCanvas !== 'function'\) return blob;/); + assert.match(js, /catch \(_\) \{\s*\n\s*return blob;/, 'and a decode failure falls back to the original'); + + const assistant = read('public/js/clinicalAssistant.js'); + assert.match(assistant, /data-image-thumb="256"/, 'the 56px gallery asks for a small copy'); +});