Two devices that were built, wired up, and doing nothing. OVERLAYS. buildStack gated layering on `surfaceOf(m) === 'composable'`, and no scene in the library declared `surface` — so the only thing that could ever sit on top was the one scene declaring `role: 'accent'`, and the overlay roster excluded accents by construction. Empty intersection, every time: 0/144 stacks carried an overlay. Every layered frame in every song was the same particle field. Labelled the library from the phase 12 coverage gate rather than by eye: the 37 scenes painting under 30% of the frame are composable. Particle Field loses its privileged slot and becomes one of them, keeping only `background: false`, which is the honest part — points in empty space cannot carry a section alone. The reserved accent slot is gone; one roster, two passes at it. 1 distinct overlay scene becomes 27, and the rate lands at 34% of stacks after trimming a base chance that had been tuned while the branch was dead. THE CAMERA. framing.shift moved the frame by a median of 0.029 of a half-frame at a fresh random angle every shot, so successive offsets cancelled and the median jump at a cut was 0.014. Present in every frame, visible in none. look/Camera.js is the director's camera department: the story says tension, order and which act a section is in, and this turns that into where the frame looks and how it travels there. Each director names a camera. Jump distance follows tension and act, speed follows energy, and the curve is one of four. Cuts choose between reframing and matching, so two scenes can still read as one place. Median offset 0.190, median reframe 0.135, and 94% of shots now move during the shot rather than only at the cut. Four new gates, each with a FLOOR — the device this replaces passed every existing check while doing nothing, because the only bound was a ceiling. Also lands the in-progress Epic 4 story layer it builds on: Story.js, phase 13, and the direction statistic. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
5.6 KiB
JavaScript
154 lines
5.6 KiB
JavaScript
// 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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Which page a key belongs to.
|
|
*
|
|
* The store holds more than one page's builds now — the gallery keys on the
|
|
* bare source fingerprint, the filmstrip prefixes its sampling parameters — and
|
|
* "drop every other build" has to mean every other build OF THIS PAGE. Without
|
|
* the namespace the two evict each other on every save, and each page rebuilds
|
|
* for three minutes every time you visit the other one.
|
|
*/
|
|
const namespaceOf = (key) => {
|
|
const k = String(key);
|
|
return k.includes(':') ? k.slice(0, k.indexOf(':')) : 'gallery';
|
|
};
|
|
|
|
/**
|
|
* Store a build, and drop every other one from the same page.
|
|
*
|
|
* 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());
|
|
const mine = namespaceOf(fingerprint);
|
|
await tx(db, 'readwrite', (store) => {
|
|
for (const key of keys) {
|
|
if (key !== fingerprint && namespaceOf(key) === mine) 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();
|
|
}
|