' +
+ '" data-assistant-open-image="' + escapeAttr(id) + '" data-image-thumb="640" title="Click to view full resolution" tabindex="0" role="button">' +
'
' +
'
Preview' +
'
Download' +
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');
+});