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'); +});