Cache the gallery against a fingerprint of the source that built it
Three minutes of GPU work is fine once and intolerable on every page load, so the gallery now builds once and comes back from IndexedDB after that. The key is the part worth getting right. A cache you clear by hand is worse than no cache: it will eventually show last week's pixels while you are judging this morning's change, and it will look like a render, so you will believe it. Here the key is a fingerprint of every file under src/, collected through import.meta.glob so it covers the shaders, the identity, the look generator, the engine and the descriptors without naming any of them — the file that invalidates a render is exactly the one nobody remembers to list. Edit anything and the entry stops matching and the page rebuilds without being asked. Thumbnails are WebP blobs rather than raw pixels: sixty-five scenes at six frames of 256x144 is 57MB raw, and measured, the compressed form is 4.1MB for the whole library. Saving a build evicts every other one, so the store cannot grow without bound. The spinner is a CSS animation on purpose. Building blocks the main thread in bursts and a JS-driven spinner would freeze mid-turn, which reads as a hang exactly when the page most needs to look alive. Verified rather than assumed, six behaviours: the fingerprint is stable across calls; a build round-trips through storage and decodes to a real image (51% non-black); a different fingerprint misses; saving a new key evicts the old; and appending one comment to one scene file moved the fingerprint from 125-f842b05e to 125-c056d7b9, which is the invalidation working end to end. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c6d96c9d40
commit
e90951c277
@ -18,11 +18,27 @@
|
|||||||
background: #171b24; color: #d6dae3; border: 1px solid #2a3040;
|
background: #171b24; color: #d6dae3; border: 1px solid #2a3040;
|
||||||
padding: 5px 10px; font: inherit; border-radius: 3px; cursor: pointer;
|
padding: 5px 10px; font: inherit; border-radius: 3px; cursor: pointer;
|
||||||
}
|
}
|
||||||
button:hover { border-color: #4a5468; }
|
button:hover:not(:disabled) { border-color: #4a5468; }
|
||||||
|
button:disabled { opacity: .5; cursor: default; }
|
||||||
#status { color: #8b94a7; margin-left: 10px; }
|
#status { color: #8b94a7; margin-left: 10px; }
|
||||||
|
#status.cached { color: #4ade80; }
|
||||||
.ctx { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 0; font-size: 11px; color: #6b7280; }
|
.ctx { display: flex; gap: 8px; flex-wrap: wrap; margin: 10px 0 0; font-size: 11px; color: #6b7280; }
|
||||||
.ctx span { background: #11141b; padding: 3px 7px; border-radius: 3px; }
|
.ctx span { background: #11141b; padding: 3px 7px; border-radius: 3px; }
|
||||||
|
|
||||||
|
/* Building blocks the main thread in bursts, so the spinner is a CSS
|
||||||
|
animation — it keeps turning through the GPU work where a JS-driven one
|
||||||
|
would freeze and look like a hang. */
|
||||||
|
.spinner {
|
||||||
|
display: none; width: 13px; height: 13px; vertical-align: -2px;
|
||||||
|
border: 2px solid #2a3040; border-top-color: #7dd3fc; border-radius: 50%;
|
||||||
|
animation: spin .8s linear infinite; margin-right: 6px;
|
||||||
|
}
|
||||||
|
.building .spinner { display: inline-block; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
#progress { height: 2px; background: #1a1e28; margin-top: 8px; border-radius: 2px; overflow: hidden; display: none; }
|
||||||
|
.building #progress { display: block; }
|
||||||
|
#progress i { display: block; height: 100%; width: 0; background: #7dd3fc; transition: width .2s; }
|
||||||
|
|
||||||
.row { margin-bottom: 22px; border-left: 3px solid #232838; padding-left: 12px; }
|
.row { margin-bottom: 22px; border-left: 3px solid #232838; padding-left: 12px; }
|
||||||
.row.flat { border-color: #ef4444; }
|
.row.flat { border-color: #ef4444; }
|
||||||
.row.thin { border-color: #eab308; }
|
.row.thin { border-color: #eab308; }
|
||||||
@ -38,7 +54,7 @@
|
|||||||
.blocks { color: #5c6577; font-size: 11px; }
|
.blocks { color: #5c6577; font-size: 11px; }
|
||||||
.thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
|
.thumbs { display: grid; grid-template-columns: repeat(6, 1fr); gap: 6px; }
|
||||||
.thumbs figure { margin: 0; }
|
.thumbs figure { margin: 0; }
|
||||||
.thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; }
|
.thumbs canvas { width: 100%; display: block; background: #05070a; border-radius: 2px; aspect-ratio: 16 / 9; }
|
||||||
.thumbs figcaption { font-size: 10px; color: #565f70; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.thumbs figcaption { font-size: 10px; color: #565f70; margin-top: 3px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.err { color: #f87171; }
|
.err { color: #f87171; }
|
||||||
@media (max-width: 1100px) { .thumbs { grid-template-columns: repeat(3, 1fr); } }
|
@media (max-width: 1100px) { .thumbs { grid-template-columns: repeat(3, 1fr); } }
|
||||||
@ -55,51 +71,57 @@
|
|||||||
one image. <a href="/debug.html">← all debug tools</a>
|
one image. <a href="/debug.html">← all debug tools</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="bar">
|
<div class="bar" id="bar">
|
||||||
<button id="run">build gallery</button>
|
<span class="spinner"></span>
|
||||||
|
<button id="rebuild">rebuild</button>
|
||||||
<select id="sort">
|
<select id="sort">
|
||||||
<option value="variety">sort: least varied first</option>
|
<option value="variety">sort: least varied first</option>
|
||||||
<option value="name">sort: by name</option>
|
<option value="name">sort: by name</option>
|
||||||
<option value="family">sort: by family</option>
|
<option value="family">sort: by family</option>
|
||||||
</select>
|
</select>
|
||||||
<span id="status">idle</span>
|
<span id="status">checking cache…</span>
|
||||||
<div class="ctx" id="ctx"></div>
|
<div class="ctx" id="ctx"></div>
|
||||||
|
<div id="progress"><i></i></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="out"></div>
|
<div id="out"></div>
|
||||||
|
|
||||||
<script type="module">
|
<script type="module">
|
||||||
import { buildGallery, galleryContexts, blit, THUMB } from '/src/checks/gallery.js';
|
import { buildGallery, galleryContexts, THUMB } from '/src/checks/gallery.js';
|
||||||
|
import {
|
||||||
|
sourceFingerprint, loadGallery, saveGallery, clearGallery,
|
||||||
|
pixelsToBlob, blobToCanvas,
|
||||||
|
} from '/src/checks/gallery-cache.js';
|
||||||
|
|
||||||
|
const bar = document.getElementById('bar');
|
||||||
const out = document.getElementById('out');
|
const out = document.getElementById('out');
|
||||||
const status = document.getElementById('status');
|
const status = document.getElementById('status');
|
||||||
const sortSel = document.getElementById('sort');
|
const sortSel = document.getElementById('sort');
|
||||||
let rows = [];
|
const progress = document.querySelector('#progress i');
|
||||||
|
const rebuildBtn = document.getElementById('rebuild');
|
||||||
|
|
||||||
|
let rows = []; // { name, family, role, variety, byBlock, blobs, error }
|
||||||
let contexts = [];
|
let contexts = [];
|
||||||
|
|
||||||
// A scene that changes very little across six songs is the thing this page
|
|
||||||
// exists to surface, so the thresholds are set where "I have seen this before"
|
|
||||||
// starts rather than at anything statistical.
|
|
||||||
const grade = (v) => (v < 0.05 ? 'flat' : v < 0.10 ? 'thin' : 'good');
|
const grade = (v) => (v < 0.05 ? 'flat' : v < 0.10 ? 'thin' : 'good');
|
||||||
|
|
||||||
function draw() {
|
function draw() {
|
||||||
const mode = sortSel.value;
|
const mode = sortSel.value;
|
||||||
const sorted = rows.slice().sort((a, b) => (
|
const sorted = rows.slice().sort((a, b) => (
|
||||||
mode === 'name' ? a.module.name.localeCompare(b.module.name)
|
mode === 'name' ? a.name.localeCompare(b.name)
|
||||||
: mode === 'family' ? (a.module.family.localeCompare(b.module.family)
|
: mode === 'family' ? (a.family.localeCompare(b.family) || a.variety - b.variety)
|
||||||
|| a.variety - b.variety)
|
|
||||||
: a.variety - b.variety));
|
: a.variety - b.variety));
|
||||||
|
|
||||||
out.innerHTML = '';
|
out.innerHTML = '';
|
||||||
for (const row of sorted) {
|
for (const row of sorted) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = `row ${grade(row.variety)}`;
|
el.className = `row ${grade(row.variety)}`;
|
||||||
const blocks = Object.entries(row.byBlock)
|
const blocks = Object.entries(row.byBlock || {})
|
||||||
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
|
.map(([k, v]) => `${k} ${v.toFixed(3)}`).join(' · ');
|
||||||
el.innerHTML = `
|
el.innerHTML = `
|
||||||
<div class="head">
|
<div class="head">
|
||||||
<span class="name">${row.module.name}</span>
|
<span class="name">${row.name}</span>
|
||||||
<span class="fam">${row.module.family}${row.module.role === 'accent' ? ' · accent' : ''}</span>
|
<span class="fam">${row.family}${row.role === 'accent' ? ' · accent' : ''}</span>
|
||||||
<span class="score">
|
<span class="score">
|
||||||
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
|
<span class="meter"><i style="width:${Math.min(100, row.variety * 500)}%"></i></span>
|
||||||
${row.variety.toFixed(3)}
|
${row.variety.toFixed(3)}
|
||||||
@ -108,16 +130,18 @@ function draw() {
|
|||||||
</div>
|
</div>
|
||||||
<div class="thumbs"></div>`;
|
<div class="thumbs"></div>`;
|
||||||
const grid = el.querySelector('.thumbs');
|
const grid = el.querySelector('.thumbs');
|
||||||
row.thumbs.forEach((pixels, i) => {
|
(row.blobs || []).forEach((blob, i) => {
|
||||||
const fig = document.createElement('figure');
|
const fig = document.createElement('figure');
|
||||||
const canvas = document.createElement('canvas');
|
const canvas = document.createElement('canvas');
|
||||||
blit(canvas, pixels, THUMB.width, THUMB.height);
|
|
||||||
fig.appendChild(canvas);
|
fig.appendChild(canvas);
|
||||||
const cap = document.createElement('figcaption');
|
const cap = document.createElement('figcaption');
|
||||||
cap.textContent = contexts[i] ? contexts[i].name : '';
|
cap.textContent = contexts[i] ? contexts[i].name : '';
|
||||||
cap.title = contexts[i] ? contexts[i].identity : '';
|
cap.title = contexts[i] ? contexts[i].identity : '';
|
||||||
fig.appendChild(cap);
|
fig.appendChild(cap);
|
||||||
grid.appendChild(fig);
|
grid.appendChild(fig);
|
||||||
|
// Decoded lazily and in the background; sixty-five rows of six is
|
||||||
|
// 390 images and decoding them all synchronously stalls the page.
|
||||||
|
blobToCanvas(canvas, blob);
|
||||||
});
|
});
|
||||||
out.appendChild(el);
|
out.appendChild(el);
|
||||||
}
|
}
|
||||||
@ -125,37 +149,96 @@ function draw() {
|
|||||||
|
|
||||||
sortSel.addEventListener('change', draw);
|
sortSel.addEventListener('change', draw);
|
||||||
|
|
||||||
document.getElementById('run').addEventListener('click', async () => {
|
function summarise(built) {
|
||||||
const button = document.getElementById('run');
|
const flat = rows.filter((r) => r.variety < 0.05).length;
|
||||||
button.disabled = true;
|
return `${rows.length} scenes · ${flat} barely change across songs` +
|
||||||
|
(built ? ` · built ${new Date(built).toLocaleString()}` : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function build(fingerprint) {
|
||||||
|
bar.classList.add('building');
|
||||||
|
rebuildBtn.disabled = true;
|
||||||
rows = [];
|
rows = [];
|
||||||
out.innerHTML = '';
|
out.innerHTML = '';
|
||||||
|
|
||||||
|
status.className = '';
|
||||||
status.textContent = 'analysing six songs…';
|
status.textContent = 'analysing six songs…';
|
||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
contexts = galleryContexts(6);
|
|
||||||
|
contexts = galleryContexts(6).map((c) => ({ ...c }));
|
||||||
document.getElementById('ctx').innerHTML = contexts
|
document.getElementById('ctx').innerHTML = contexts
|
||||||
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
|
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
|
||||||
|
|
||||||
const started = Date.now();
|
const started = Date.now();
|
||||||
await buildGallery({
|
const built = await buildGallery({
|
||||||
contexts,
|
contexts,
|
||||||
onScene: (done, total, name, row) => {
|
onScene: (done, total, name) => {
|
||||||
rows.push(row);
|
status.textContent = `rendering ${done}/${total} · ${name}`;
|
||||||
status.textContent = `${done}/${total} · ${name}`;
|
progress.style.width = `${(done / total) * 100}%`;
|
||||||
// Redraw periodically rather than every scene: sorting the list on
|
|
||||||
// every row would reflow sixty times for no benefit.
|
|
||||||
if (done % 8 === 0 || done === total) draw();
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const flat = rows.filter((r) => r.variety < 0.05);
|
status.textContent = 'compressing…';
|
||||||
status.textContent = `${rows.length} scenes in ${((Date.now() - started) / 1000).toFixed(0)}s · ` +
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
`${flat.length} barely change across songs`;
|
rows = [];
|
||||||
draw();
|
for (const row of built) {
|
||||||
button.disabled = false;
|
const blobs = [];
|
||||||
window.__GALLERY__ = rows.map((r) => ({ name: r.module.name, variety: r.variety, error: r.error }));
|
for (const pixels of row.thumbs) {
|
||||||
|
blobs.push(await pixelsToBlob(pixels, THUMB.width, THUMB.height));
|
||||||
|
}
|
||||||
|
rows.push({
|
||||||
|
name: row.module.name,
|
||||||
|
family: row.module.family,
|
||||||
|
role: row.module.role || 'stage',
|
||||||
|
variety: row.variety,
|
||||||
|
byBlock: row.byBlock,
|
||||||
|
error: row.error,
|
||||||
|
blobs,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
fingerprint,
|
||||||
|
built: Date.now(),
|
||||||
|
contexts: contexts.map((c) => ({ name: c.name, identity: c.identity })),
|
||||||
|
rows,
|
||||||
|
};
|
||||||
|
const stored = await saveGallery(fingerprint, payload);
|
||||||
|
|
||||||
|
bar.classList.remove('building');
|
||||||
|
rebuildBtn.disabled = false;
|
||||||
|
draw();
|
||||||
|
status.textContent = `${summarise(payload.built)} in ${((Date.now() - started) / 1000).toFixed(0)}s` +
|
||||||
|
(stored ? '' : ' · not cached (storage unavailable)');
|
||||||
|
window.__GALLERY__ = rows.map((r) => ({ name: r.name, variety: r.variety, error: r.error }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const fingerprint = sourceFingerprint();
|
||||||
|
const cached = await loadGallery(fingerprint);
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
rows = cached.rows;
|
||||||
|
contexts = cached.contexts;
|
||||||
|
document.getElementById('ctx').innerHTML = contexts
|
||||||
|
.map((c) => `<span title="${c.identity}">${c.name}</span>`).join('');
|
||||||
|
draw();
|
||||||
|
status.className = 'cached';
|
||||||
|
status.textContent = `cached · ${summarise(cached.built)}`;
|
||||||
|
window.__GALLERY__ = rows.map((r) => ({ name: r.name, variety: r.variety, error: r.error }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// No entry for this source. Something under src/ changed since the last
|
||||||
|
// build — or there has never been one — so rebuild without being asked.
|
||||||
|
await build(fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
rebuildBtn.addEventListener('click', async () => {
|
||||||
|
await clearGallery();
|
||||||
|
await build(sourceFingerprint());
|
||||||
|
});
|
||||||
|
|
||||||
|
main();
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
136
flow-state/src/checks/gallery-cache.js
Normal file
136
flow-state/src/checks/gallery-cache.js
Normal file
@ -0,0 +1,136 @@
|
|||||||
|
// Caching for the gallery, keyed on the source that produced it.
|
||||||
|
//
|
||||||
|
// Building the gallery takes three minutes of GPU work, which is fine once and
|
||||||
|
// intolerable every time the page is opened. But a cache that has to be cleared
|
||||||
|
// by hand is worse than no cache: it will eventually show you last week's
|
||||||
|
// pixels while you are trying to judge this morning's change, and you will trust
|
||||||
|
// it because it looks like a render.
|
||||||
|
//
|
||||||
|
// So the key is a fingerprint of the code itself. Edit any file under src/ and
|
||||||
|
// the fingerprint changes, the old entry stops matching, and the gallery
|
||||||
|
// rebuilds without being asked. Nothing to remember and nothing to invalidate.
|
||||||
|
//
|
||||||
|
// Thumbnails are stored as WebP blobs in IndexedDB rather than raw pixels in
|
||||||
|
// localStorage: sixty-five scenes at six frames of 256x144 is about 57MB raw,
|
||||||
|
// which localStorage would refuse and IndexedDB would rather not hold either.
|
||||||
|
// Compressed it is a few megabytes.
|
||||||
|
|
||||||
|
const DB_NAME = 'flow-state-gallery';
|
||||||
|
const STORE = 'builds';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fingerprint of every source file that can change what the gallery renders.
|
||||||
|
*
|
||||||
|
* `import.meta.glob` is resolved by Vite at build time, so this covers the
|
||||||
|
* shaders, the identity, the look generator, the engine and the descriptors
|
||||||
|
* without naming any of them — which matters, because the file that invalidates
|
||||||
|
* a render is exactly the one nobody remembers to list.
|
||||||
|
*/
|
||||||
|
const SOURCES = import.meta.glob('/src/**/*.js', { query: '?raw', import: 'default', eager: true });
|
||||||
|
|
||||||
|
export function sourceFingerprint() {
|
||||||
|
// Sorted, so the hash does not depend on glob iteration order.
|
||||||
|
const paths = Object.keys(SOURCES).sort();
|
||||||
|
let h = 0x811c9dc5 >>> 0;
|
||||||
|
const mix = (str) => {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
h ^= str.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const path of paths) {
|
||||||
|
mix(path);
|
||||||
|
mix(String(SOURCES[path]));
|
||||||
|
}
|
||||||
|
return `${paths.length}-${h.toString(16).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||||
|
request.onupgradeneeded = () => {
|
||||||
|
const db = request.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE);
|
||||||
|
};
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function tx(db, mode, fn) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const t = db.transaction(STORE, mode);
|
||||||
|
const request = fn(t.objectStore(STORE));
|
||||||
|
request.onsuccess = () => resolve(request.result);
|
||||||
|
request.onerror = () => reject(request.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The cached build for this exact source, or null. */
|
||||||
|
export async function loadGallery(fingerprint) {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const record = await tx(db, 'readonly', (store) => store.get(fingerprint));
|
||||||
|
db.close();
|
||||||
|
return record || null;
|
||||||
|
} catch {
|
||||||
|
// A debug page is not worth failing over a storage quota or a private
|
||||||
|
// window that refuses IndexedDB. Fall through to rebuilding.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Store a build, and drop every other one.
|
||||||
|
*
|
||||||
|
* Only the current source is ever wanted, and keeping stale builds around is how
|
||||||
|
* a cache quietly grows to hundreds of megabytes of images nobody will look at.
|
||||||
|
*/
|
||||||
|
export async function saveGallery(fingerprint, payload) {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
const keys = await tx(db, 'readonly', (store) => store.getAllKeys());
|
||||||
|
await tx(db, 'readwrite', (store) => {
|
||||||
|
for (const key of keys) if (key !== fingerprint) store.delete(key);
|
||||||
|
return store.put(payload, fingerprint);
|
||||||
|
});
|
||||||
|
db.close();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearGallery() {
|
||||||
|
try {
|
||||||
|
const db = await openDb();
|
||||||
|
await tx(db, 'readwrite', (store) => store.clear());
|
||||||
|
db.close();
|
||||||
|
} catch { /* nothing to clear */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw RGBA (bottom-up, as WebGL hands it over) to a compressed blob. */
|
||||||
|
export function pixelsToBlob(pixels, width, height) {
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const image = ctx.createImageData(width, height);
|
||||||
|
const row = width * 4;
|
||||||
|
for (let y = 0; y < height; y++) {
|
||||||
|
const src = (height - 1 - y) * row;
|
||||||
|
image.data.set(pixels.subarray(src, src + row), y * row);
|
||||||
|
}
|
||||||
|
ctx.putImageData(image, 0, 0);
|
||||||
|
return new Promise((resolve) => canvas.toBlob(resolve, 'image/webp', 0.85));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Draw a stored blob into a canvas, sized to it. */
|
||||||
|
export async function blobToCanvas(canvas, blob) {
|
||||||
|
const bitmap = await createImageBitmap(blob);
|
||||||
|
canvas.width = bitmap.width;
|
||||||
|
canvas.height = bitmap.height;
|
||||||
|
canvas.getContext('2d').drawImage(bitmap, 0, 0);
|
||||||
|
bitmap.close();
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user